返回 DeepSeek-Reasonix
HeartbeatPanel.tsx
根目录 / desktop / frontend / src / custom / features / heartbeat / HeartbeatPanel.tsx
1 // Heartbeat Panel — Modal for configuring scheduled heartbeat tasks.
2 //
3 // Renders a list of tasks with add/edit/delete controls, plus a manual
4 // "run now" button for each. The panel is opened from the sidebar nav item.
5
6 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
7 import {
8 Activity,
9 ChevronLeft,
10 ChevronsUpDown,
11 Clock,
12 Check,
13 Heart,
14 MessageSquare,
15 Play,
16 Plus,
17 Search,
18 Trash2,
19 X,
20 } from "lucide-react";
21 import { app } from "../../../lib/bridge";
22 import { useT } from "../../../lib/i18n";
23 import { AnchoredPopover } from "../../../components/AnchoredPopover";
24 import {
25 heartbeatListTasks,
26 heartbeatSaveTasks,
27 heartbeatTriggerNow,
28 heartbeatGenerateID,
29 } from "./heartbeat.bridge";
30 import type { HeartbeatTask } from "./heartbeat.types";
31 import type { WorkspaceView } from "../../../lib/types";
32
33 const INTERVAL_MS: Record<"s" | "m" | "h", number> = {
34 s: 1000,
35 m: 60_000,
36 h: 3_600_000,
37 };
38
39 function heartbeatIntervalMs(interval?: string): number | null {
40 const clean = (interval || "").replace(/\|.*$/, "");
41 const m = clean.match(/^(\d+)([smh])$/);
42 if (!m) return null;
43 return parseInt(m[1], 10) * INTERVAL_MS[m[2] as "s" | "m" | "h"];
44 }
45
46 function heartbeatClockMinutes(value?: string): number | null {
47 const m = (value || "").match(/^(\d{2}):(\d{2})$/);
48 if (!m) return null;
49 const hour = parseInt(m[1], 10);
50 const minute = parseInt(m[2], 10);
51 if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
52 return hour * 60 + minute;
53 }
54
55 function dateAtMinutes(base: Date, minutes: number): Date {
56 const d = new Date(base);
57 d.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
58 return d;
59 }
60
61 function heartbeatWithinWindow(date: Date, start: number | null, end: number | null): boolean {
62 if (start === null && end === null) return true;
63 const minutes = date.getHours() * 60 + date.getMinutes();
64 if (start !== null && end === null) return minutes >= start;
65 if (start === null && end !== null) return minutes < end;
66 if (start === end) return true;
67 if (start! < end!) return minutes >= start! && minutes < end!;
68 return minutes >= start! || minutes < end!;
69 }
70
71 function nextHeartbeatWindowTime(from: Date, start: number | null, end: number | null): Date {
72 if (heartbeatWithinWindow(from, start, end)) return from;
73 if (start !== null && end === null) return dateAtMinutes(from, start);
74 if (start === null && end !== null) {
75 const next = new Date(from);
76 next.setDate(next.getDate() + 1);
77 next.setHours(0, 0, 0, 0);
78 return next;
79 }
80 const minutes = from.getHours() * 60 + from.getMinutes();
81 if (start! < end! && minutes < start!) return dateAtMinutes(from, start!);
82 if (start! > end! && minutes < start! && minutes >= end!) return dateAtMinutes(from, start!);
83 const next = dateAtMinutes(from, start!);
84 next.setDate(next.getDate() + 1);
85 return next;
86 }
87
88 export function heartbeatNextRunAt(task: Pick<HeartbeatTask, "interval" | "lastRunAt" | "timeWindowStart" | "timeWindowEnd">, now = Date.now()): number | null {
89 if (!task.lastRunAt) return null;
90 const intervalMs = heartbeatIntervalMs(task.interval);
91 if (intervalMs === null) return null;
92 const rawNext = task.lastRunAt + intervalMs;
93 if ((task.interval || "").includes("|")) return rawNext;
94 const start = heartbeatClockMinutes(task.timeWindowStart);
95 const end = heartbeatClockMinutes(task.timeWindowEnd);
96 if (start === null && end === null) return rawNext;
97 const candidate = new Date(Math.max(rawNext, now));
98 return nextHeartbeatWindowTime(candidate, start, end).getTime();
99 }
100
101 function heartbeatIntervalLabel(interval: string | undefined, t: ReturnType<typeof useT>): string {
102 const cycleMatch = (interval || "").match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/);
103 if (cycleMatch) {
104 const [, , type, days, time] = cycleMatch;
105 const timeStr = time ? ` ${time}` : "";
106 if (type === "daily") return `${t("heartbeat.cycleDaily")}${timeStr}`;
107 if (type === "weekly") return `${t("heartbeat.cycleWeekly")}${timeStr}`;
108 if (type === "biweekly") return `${t("heartbeat.cycleBiweekly")}${timeStr}`;
109 if (type === "monthly") return `${t("heartbeat.cycleMonthly")}${days ? ` ${days}` : ""}${timeStr}`;
110 if (type === "yearly") {
111 const parts = (days || "").split("-");
112 return `${t("heartbeat.cycleYearly")} ${parts[0] || "1"}/${parts[1] || "1"}${timeStr}`;
113 }
114 }
115 const clean = (interval || "").replace(/\|.*$/, "");
116 const m = clean.match(/^(\d+)([smh])$/);
117 if (!m) return clean;
118 const unitLabels: Record<string, string> = {
119 s: t("heartbeat.unitSec"),
120 m: t("heartbeat.unitMin"),
121 h: t("heartbeat.unitHour"),
122 };
123 return `${t("heartbeat.freqEvery")}${t("heartbeat.everyJoiner")}${m[1]}${unitLabels[m[2]] || m[2]}`;
124 }
125
126 interface HeartbeatPanelProps {
127 open: boolean;
128 onClose: () => void;
129 startNew?: boolean;
130 onOpenTopic: (scope: string, workspaceRoot: string, topicId: string) => void;
131 }
132
133 export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: HeartbeatPanelProps) {
134 const t = useT();
135 const [tasks, setTasks] = useState<HeartbeatTask[]>([]);
136 const [loading, setLoading] = useState(false);
137 const [editing, setEditing] = useState<HeartbeatTask | null>(null);
138 const [searchQuery, setSearchQuery] = useState("");
139 const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
140 const [scopeFilter, setScopeFilter] = useState<string>("all");
141 const [scopeFilterOpen, setScopeFilterOpen] = useState(false);
142 const scopeFilterRef = useRef<HTMLButtonElement>(null);
143 const [statusFilterOpen, setStatusFilterOpen] = useState(false);
144 const statusFilterRef = useRef<HTMLButtonElement>(null);
145 const [workspaceMap, setWorkspaceMap] = useState<Record<string, string>>({});
146 const backdropRef = useRef<HTMLDivElement>(null);
147 const dirtyRef = useRef(false);
148 const startedRef = useRef(false);
149
150 // Reset dirty ref when leaving edit mode
151 useEffect(() => {
152 if (!editing) dirtyRef.current = false;
153 }, [editing]);
154
155 const loadTasks = useCallback(async () => {
156 setLoading(true);
157 try {
158 const [taskList, wsList] = await Promise.all([
159 heartbeatListTasks(),
160 app.ListWorkspaces(),
161 ]);
162 setTasks(taskList);
163 const map: Record<string, string> = {};
164 if (wsList) {
165 wsList.forEach((ws) => { if (ws.path) map[ws.path] = ws.name; });
166 }
167 setWorkspaceMap(map);
168 } catch {
169 // ignore
170 } finally {
171 setLoading(false);
172 }
173 }, []);
174
175 useEffect(() => {
176 if (open) {
177 setEditing(null);
178 setSearchQuery("");
179 setStatusFilter("all");
180 setScopeFilter("all");
181 startedRef.current = false;
182 void loadTasks();
183 }
184 }, [open, loadTasks]);
185
186 // Open directly in add mode when startNew is true
187 useEffect(() => {
188 if (open && startNew && !startedRef.current) {
189 startedRef.current = true;
190 void heartbeatGenerateID().then((id) => {
191 setEditing({
192 id,
193 title: "",
194 prompt: "",
195 interval: "30m",
196 enabled: true,
197 approvalMode: "yolo",
198 newConversationEachRun: false,
199 notifyChannels: false,
200 createdAt: Date.now(),
201 });
202 }).catch(() => {});
203 }
204 }, [open, startNew]);
205
206 const save = useCallback(
207 async (next: HeartbeatTask[]) => {
208 setTasks(next);
209 try {
210 await heartbeatSaveTasks(next);
211 } catch {
212 // ignore
213 }
214 },
215 [],
216 );
217
218 const handleAdd = useCallback(async () => {
219 try {
220 const id = await heartbeatGenerateID();
221 setEditing({
222 id,
223 title: "",
224 prompt: "",
225 interval: "30m",
226 enabled: true,
227 approvalMode: "yolo",
228 newConversationEachRun: false,
229 notifyChannels: false,
230 createdAt: Date.now(),
231 });
232 } catch {
233 // ignore
234 }
235 }, []);
236
237 const handleEdit = useCallback((task: HeartbeatTask) => {
238 setEditing({ ...task });
239 }, []);
240
241 const handleDelete = useCallback(
242 async (id: string) => {
243 const next = tasks.filter((t) => t.id !== id);
244 await save(next);
245 },
246 [tasks, save],
247 );
248
249 const handleTrigger = useCallback(
250 async (id: string) => {
251 try {
252 await heartbeatTriggerNow(id);
253 void loadTasks();
254 } catch {
255 // ignore
256 }
257 },
258 [loadTasks],
259 );
260
261 const handleSaveEdit = useCallback(
262 async (task: HeartbeatTask) => {
263 const idx = tasks.findIndex((t) => t.id === task.id);
264 const next = [...tasks];
265 if (idx >= 0) {
266 next[idx] = task;
267 } else {
268 next.push(task);
269 }
270 await save(next);
271 setEditing(null);
272 },
273 [tasks, save],
274 );
275
276 const handleBackdrop = useCallback(
277 (e: React.MouseEvent) => {
278 if (e.target === backdropRef.current && !dirtyRef.current) onClose();
279 },
280 [onClose],
281 );
282
283 useEffect(() => {
284 if (!open) return;
285 const onKey = (e: globalThis.KeyboardEvent) => {
286 if (e.key === "Escape" && !dirtyRef.current && !document.querySelector("[data-anchored-popover='active']")) onClose();
287 };
288 window.addEventListener("keydown", onKey);
289 return () => window.removeEventListener("keydown", onKey);
290 }, [open, onClose]);
291
292 if (!open) return null;
293
294 const scopeFilterLabel = (filter: string, map: Record<string, string>): string => {
295 if (filter === "all") return t("heartbeat.filterAllProjects");
296 if (filter === "global") return t("heartbeat.scopeGlobal");
297 return map[filter] || filter.split("/").pop() || filter;
298 };
299
300 const statusFilterLabel = (filter: string): string => {
301 if (filter === "all") return t("heartbeat.filterAll" as any);
302 if (filter === "enabled") return t("heartbeat.filterEnabled" as any);
303 return t("heartbeat.filterDisabled" as any);
304 };
305
306 return (
307 <div ref={backdropRef} className="heartbeat-backdrop" onMouseDown={handleBackdrop}>
308 <div className="heartbeat-modal">
309 <header className="heartbeat-modal__header">
310 {editing ? (
311 <button className="heartbeat-modal__back" onClick={() => setEditing(null)}>
312 <ChevronLeft size={16} />
313 </button>
314 ) : (
315 <Activity size={16} />
316 )}
317 <span>{editing ? t("heartbeat.editTask") : t("heartbeat.scheduler")}</span>
318 <button
319 className="heartbeat-modal__close"
320 onClick={onClose}
321 aria-label={t("common.close")}
322 >
323 <X size={16} />
324 </button>
325 </header>
326
327 {editing ? (
328 <TaskEditor key={editing.id} task={editing} onSave={handleSaveEdit} onCancel={() => setEditing(null)} onDelete={() => { handleDelete(editing.id); setEditing(null); }} onDirtyChange={(d) => { dirtyRef.current = d; }} />
329 ) : (
330 <div className="heartbeat-modal__body">
331 <div className="heartbeat-toolbar">
332 <div className="heartbeat-toolbar__search">
333 <Search size={13} className="heartbeat-toolbar__search-icon" />
334 <input
335 className="heartbeat-toolbar__search-input"
336 value={searchQuery}
337 onChange={(e) => setSearchQuery(e.target.value)}
338 placeholder={t("heartbeat.searchPlaceholder" as any)}
339 />
340 {searchQuery && (
341 <button className="heartbeat-toolbar__search-clear" onClick={() => setSearchQuery("")}>
342 <X size={12} />
343 </button>
344 )}
345 </div>
346 <div className="heartbeat-scope-filter">
347 <button
348 ref={statusFilterRef}
349 className="heartbeat-toolbar__btn heartbeat-toolbar__btn--select"
350 type="button"
351 onClick={() => setStatusFilterOpen((v) => !v)}
352 >
353 <span>{statusFilterLabel(statusFilter)}</span>
354 <ChevronsUpDown size={12} />
355 </button>
356 <AnchoredPopover
357 open={statusFilterOpen}
358 anchorRef={statusFilterRef}
359 onClose={() => setStatusFilterOpen(false)}
360 className="heartbeat-filter-menu"
361 placement="bottom"
362 >
363 <div className="heartbeat-filter-menu__list" role="listbox">
364 {(["all", "enabled", "disabled"] as const).map((key) => (
365 <button
366 key={key}
367 className={`heartbeat-filter-menu__option${statusFilter === key ? " heartbeat-filter-menu__option--selected" : ""}`}
368 role="option"
369 aria-selected={statusFilter === key}
370 type="button"
371 onClick={() => { setStatusFilter(key); setStatusFilterOpen(false); }}
372 >
373 <span>{key === "all" ? t("heartbeat.filterAll" as any) : key === "enabled" ? t("heartbeat.filterEnabled" as any) : t("heartbeat.filterDisabled" as any)}</span>
374 {statusFilter === key && <Check size={12} className="heartbeat-filter-menu__check" />}
375 </button>
376 ))}
377 </div>
378 </AnchoredPopover>
379 </div>
380 <div className="heartbeat-scope-filter">
381 <button
382 ref={scopeFilterRef}
383 className="heartbeat-toolbar__btn heartbeat-toolbar__btn--select"
384 type="button"
385 onClick={() => setScopeFilterOpen((v) => !v)}
386 >
387 <span>{scopeFilterLabel(scopeFilter, workspaceMap)}</span>
388 <ChevronsUpDown size={12} />
389 </button>
390 <AnchoredPopover
391 open={scopeFilterOpen}
392 anchorRef={scopeFilterRef}
393 onClose={() => setScopeFilterOpen(false)}
394 className="heartbeat-filter-menu"
395 placement="bottom"
396 >
397 <div className="heartbeat-filter-menu__list" role="listbox">
398 <button
399 className={`heartbeat-filter-menu__option${scopeFilter === "all" ? " heartbeat-filter-menu__option--selected" : ""}`}
400 role="option"
401 aria-selected={scopeFilter === "all"}
402 type="button"
403 onClick={() => { setScopeFilter("all"); setScopeFilterOpen(false); }}
404 >
405 <span>{t("heartbeat.filterAllProjects")}</span>
406 {scopeFilter === "all" && <Check size={12} className="heartbeat-filter-menu__check" />}
407 </button>
408 <button
409 className={`heartbeat-filter-menu__option${scopeFilter === "global" ? " heartbeat-filter-menu__option--selected" : ""}`}
410 role="option"
411 aria-selected={scopeFilter === "global"}
412 type="button"
413 onClick={() => { setScopeFilter("global"); setScopeFilterOpen(false); }}
414 >
415 <span>{t("heartbeat.scopeGlobal")}</span>
416 {scopeFilter === "global" && <Check size={12} className="heartbeat-filter-menu__check" />}
417 </button>
418 {(() => {
419 const seen = new Set<string>();
420 const items: { value: string; label: string }[] = [];
421 for (const task of tasks) {
422 const key = task.scope !== "project" || !task.workspaceRoot ? "global" : task.workspaceRoot;
423 if (seen.has(key)) continue;
424 seen.add(key);
425 if (key !== "global") {
426 items.push({
427 value: key,
428 label: workspaceMap[key] || key.split("/").pop() || key,
429 });
430 }
431 }
432 return items.map((item) => (
433 <button
434 key={item.value}
435 className={`heartbeat-filter-menu__option${scopeFilter === item.value ? " heartbeat-filter-menu__option--selected" : ""}`}
436 role="option"
437 aria-selected={scopeFilter === item.value}
438 type="button"
439 onClick={() => { setScopeFilter(item.value); setScopeFilterOpen(false); }}
440 >
441 <span>{item.label}</span>
442 {scopeFilter === item.value && <Check size={12} className="heartbeat-filter-menu__check" />}
443 </button>
444 ));
445 })()}
446 </div>
447 </AnchoredPopover>
448 </div>
449 <button className="heartbeat-toolbar__btn heartbeat-toolbar__btn--primary" style={{ marginLeft: "auto" }} onClick={handleAdd}>
450 <Plus size={14} />
451 {t("heartbeat.addTask")}
452 </button>
453 </div>
454
455 {(() => {
456 const filtered = tasks
457 .filter((task) => {
458 if (statusFilter === "enabled" && !task.enabled) return false;
459 if (statusFilter === "disabled" && task.enabled) return false;
460 if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
461 if (scopeFilter === "global" && (task.scope === "project" && task.workspaceRoot)) return false;
462 if (scopeFilter !== "all" && scopeFilter !== "global") {
463 if (task.scope !== "project" || task.workspaceRoot !== scopeFilter) return false;
464 }
465 return true;
466 })
467 .sort((a, b) => {
468 if (a.enabled && !b.enabled) return -1;
469 if (!a.enabled && b.enabled) return 1;
470 return 0;
471 });
472
473 const scopeLabel = (task: HeartbeatTask): string => {
474 if (task.scope !== "project" || !task.workspaceRoot) return t("heartbeat.scopeGlobal");
475 return workspaceMap[task.workspaceRoot] || task.workspaceRoot.split("/").pop() || task.workspaceRoot;
476 };
477
478 return loading ? (
479 <div className="heartbeat-empty">
480 <Heart size={24} className="heartbeat-pulse" />
481 <span>{t("workspace.loading")}</span>
482 </div>
483 ) : filtered.length === 0 ? (
484 <div className="heartbeat-empty">
485 <Heart size={24} />
486 <span>{tasks.length === 0 ? t("heartbeat.noTasks") : t("heartbeat.noMatchingTasks")}</span>
487 </div>
488 ) : (
489 <ul className="heartbeat-tasklist">
490 {filtered.map((task) => (
491 <TaskCard
492 key={task.id}
493 task={task}
494 scopeLabel={scopeLabel(task)}
495 onToggle={() => {
496 const next = tasks.map((t) =>
497 t.id === task.id ? { ...t, enabled: !t.enabled } : t,
498 );
499 save(next);
500 }}
501 onEdit={() => handleEdit(task)}
502 onTrigger={() => void handleTrigger(task.id)}
503 onOpenTopic={onOpenTopic}
504 onClose={onClose}
505 />
506 ))}
507 </ul>
508 );
509 })()}
510 </div>
511 )}
512 </div>
513 </div>
514 );
515 }
516
517 // ── Task Card ─────────────────────────────────────────────────────────────────
518
519 function TaskCard({
520 task,
521 scopeLabel,
522 onToggle,
523 onEdit,
524 onTrigger,
525 onOpenTopic,
526 onClose,
527 }: {
528 task: HeartbeatTask;
529 scopeLabel: string;
530 onToggle: () => void;
531 onEdit: () => void;
532 onTrigger: () => void;
533 onOpenTopic: (scope: string, workspaceRoot: string, topicId: string) => void;
534 onClose: () => void;
535 }) {
536 const t = useT();
537
538 const intervalLabel = heartbeatIntervalLabel(task.interval, t);
539
540 const nextRunLabel = (() => {
541 if (!task.enabled) return t("heartbeat.disabled");
542 const now = Date.now();
543 const next = heartbeatNextRunAt(task, now);
544 if (next === null) return task.lastRunAt ? "" : t("heartbeat.neverRun");
545 const diff = next - now;
546 if (diff <= 0) return t("heartbeat.due" as any);
547 if (diff < 60000) return t("heartbeat.soon" as any);
548 if (diff < 3600000) return `${Math.floor(diff / 60000)}${t("heartbeat.minLater" as any)}`;
549 if (diff < 86400000) return `${Math.floor(diff / 3600000)}${t("heartbeat.hourLater" as any)}`;
550 const d = new Date(next);
551 return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
552 })();
553
554 const lastRunLabel = task.lastRunAt
555 ? (() => {
556 const d = new Date(task.lastRunAt);
557 const now = new Date();
558 const diff = now.getTime() - task.lastRunAt;
559 if (diff < 60000) return t("heartbeat.justNow" as any);
560 if (diff < 3600000) return `${Math.floor(diff / 60000)}${t("heartbeat.minAgo" as any)}`;
561 if (diff < 86400000) return `${Math.floor(diff / 3600000)}${t("heartbeat.hourAgo" as any)}`;
562 return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
563 })()
564 : t("heartbeat.neverRun");
565
566 return (
567 <li className={`heartbeat-card${!task.enabled ? " heartbeat-card--disabled" : ""}`}>
568 <div className="heartbeat-card__head">
569 <span className={`heartbeat-card__dot${task.enabled ? " heartbeat-card__dot--on" : ""}`} />
570 <span className="heartbeat-card__title">
571 <button
572 type="button"
573 className="heartbeat-card__title-btn"
574 onClick={onEdit}
575 >
576 <span className="heartbeat-card__title-text">{task.title || t("heartbeat.untitled")}</span>
577 <span className="heartbeat-card__title-scope">{scopeLabel}</span>
578 </button>
579 </span>
580 <span className="heartbeat-card__meta-item heartbeat-card__meta-item--compact">
581 <Clock size={10} />
582 {intervalLabel}
583 <span className="heartbeat-card__meta-sep">·</span>
584 {task.enabled ? nextRunLabel : lastRunLabel}
585 </span>
586 <span className="heartbeat-card__head-actions">
587 <button
588 className="heartbeat-card__open-btn heartbeat-card__open-btn--play"
589 onClick={onTrigger}
590 title={t("heartbeat.runNow")}
591 >
592 <Play size={12} />
593 </button>
594 <button
595 className="heartbeat-card__open-btn"
596 type="button"
597 disabled={!task.topicId}
598 onClick={() => {
599 if (task.topicId) {
600 onClose();
601 onOpenTopic(task.scope || "global", task.workspaceRoot || "", task.topicId);
602 }
603 }}
604 title={task.topicId ? (t("heartbeat.openTopic" as any)) : ""}
605 >
606 <MessageSquare size={13} />
607 </button>
608 <button
609 className={`heartbeat-card__toggle${task.enabled ? " heartbeat-card__toggle--on" : ""}`}
610 onClick={onToggle}
611 aria-label={task.enabled ? t("heartbeat.disable") : t("heartbeat.enabled")}
612 >
613 <span className="heartbeat-card__toggle-knob" />
614 </button>
615 </span>
616 </div>
617 </li>
618 );
619 }
620
621 // ── Cycle Editor ──────────────────────────────────────────────────────────────
622
623 const WEEKDAYS = [
624 { key: "mon", labelKey: "heartbeat.weekdayMon" },
625 { key: "tue", labelKey: "heartbeat.weekdayTue" },
626 { key: "wed", labelKey: "heartbeat.weekdayWed" },
627 { key: "thu", labelKey: "heartbeat.weekdayThu" },
628 { key: "fri", labelKey: "heartbeat.weekdayFri" },
629 { key: "sat", labelKey: "heartbeat.weekdaySat" },
630 { key: "sun", labelKey: "heartbeat.weekdaySun" },
631 ] as const;
632
633 const ALL_WEEKDAYS = WEEKDAYS.map(w => w.key);
634 const DEFAULT_WEEKLY_DAY = "mon";
635
636 function defaultHeartbeatCycleDays(cycleType: string): string[] {
637 if (cycleType === "daily") return [...ALL_WEEKDAYS];
638 if (cycleType === "weekly" || cycleType === "biweekly") return [DEFAULT_WEEKLY_DAY];
639 return [];
640 }
641
642 export function heartbeatBuildCycleInterval(cycleType: string, days: string[], time: string): string {
643 const base: Record<string, string> = {
644 daily: "24h",
645 weekly: "168h",
646 biweekly: "336h",
647 monthly: "720h",
648 yearly: "8760h",
649 };
650 const selectedDays = days.filter(Boolean);
651 const isDailyWithSelection = cycleType === "daily" && selectedDays.length > 0 && selectedDays.length < 7;
652 const isDailyWithoutSelection = cycleType === "daily" && selectedDays.length === 0;
653 const effectiveType = isDailyWithoutSelection || isDailyWithSelection ? "weekly" : cycleType;
654 const scheduleDays =
655 (effectiveType === "weekly" || effectiveType === "biweekly") && selectedDays.length === 0
656 ? defaultHeartbeatCycleDays(effectiveType)
657 : selectedDays;
658
659 let suffix = `|${effectiveType}`;
660 if (effectiveType === "weekly" || effectiveType === "biweekly") {
661 suffix += `:${scheduleDays.join(",")}`;
662 } else if (effectiveType === "monthly") {
663 suffix += `:${scheduleDays[0] || "1"}`;
664 } else if (effectiveType === "yearly") {
665 suffix += `:${scheduleDays[0] || "1"}-${scheduleDays[1] || "1"}`;
666 }
667 suffix += `@${time}`;
668 return (base[cycleType] || "24h") + suffix;
669 }
670
671 function CycleEditor({
672 draft,
673 setDraft,
674 }: {
675 draft: HeartbeatTask;
676 setDraft: (field: keyof HeartbeatTask, value: string | boolean) => void;
677 }) {
678 const t = useT();
679 const cycleMatch = (draft.interval || "").match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/);
680 const [cycleType, setCycleType] = useState<string>(
681 cycleMatch ? cycleMatch[2] : "daily"
682 );
683 const cycleDays = cycleMatch?.[3] || "";
684 const cycleTime = cycleMatch?.[4] || "09:00";
685 const [selectedDays, setSelectedDays] = useState<string[]>(
686 cycleDays ? cycleDays.split(",").filter(Boolean) :
687 defaultHeartbeatCycleDays(cycleMatch ? cycleMatch[2] : "daily")
688 );
689 const [monthDay, setMonthDay] = useState(cycleDays || "1");
690 const [yearMonth, setYearMonth] = useState(cycleDays.split("-")[0] || "1");
691 const [yearDay, setYearDay] = useState(cycleDays.split("-")[1] || "1");
692 const [timeVal, setTimeVal] = useState(cycleTime);
693
694 const hasWeekdays = cycleType === "daily" || cycleType === "weekly" || cycleType === "biweekly";
695
696 // Build interval string when config changes
697 const buildInterval = useCallback(heartbeatBuildCycleInterval, []);
698
699 const onCycleTypeChange = useCallback((ct: string) => {
700 setCycleType(ct);
701 const days = defaultHeartbeatCycleDays(ct);
702 setSelectedDays(days);
703 setMonthDay("1");
704 setYearMonth("1");
705 setYearDay("1");
706 setDraft("interval", buildInterval(ct, days, timeVal));
707 }, [buildInterval, setDraft, timeVal]);
708
709 const onDayToggle = useCallback((day: string) => {
710 setSelectedDays((prev) => {
711 if (prev.includes(day) && prev.length <= 1) return prev;
712 const next = prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day];
713 setDraft("interval", buildInterval(cycleType, next, timeVal));
714 return next;
715 });
716 }, [buildInterval, cycleType, setDraft, timeVal]);
717
718 const onMonthDayChange = useCallback((d: string) => {
719 setMonthDay(d);
720 setDraft("interval", buildInterval(cycleType, [d], timeVal));
721 }, [buildInterval, cycleType, setDraft, timeVal]);
722
723 const onYearMonthChange = useCallback((m: string) => {
724 setYearMonth(m);
725 setDraft("interval", buildInterval(cycleType, [m, yearDay], timeVal));
726 }, [buildInterval, cycleType, setDraft, timeVal, yearDay]);
727
728 const onYearDayChange = useCallback((d: string) => {
729 setYearDay(d);
730 setDraft("interval", buildInterval(cycleType, [yearMonth, d], timeVal));
731 }, [buildInterval, cycleType, setDraft, timeVal, yearMonth]);
732
733 const onTimeChange = useCallback((tm: string) => {
734 setTimeVal(tm);
735 const days = hasWeekdays ? selectedDays
736 : cycleType === "monthly" ? [monthDay]
737 : cycleType === "yearly" ? [yearMonth, yearDay]
738 : [];
739 setDraft("interval", buildInterval(cycleType, days, tm));
740 }, [buildInterval, cycleType, selectedDays, monthDay, yearMonth, yearDay, setDraft]);
741
742 const MONTHS = Array.from({ length: 12 }, (_, i) => ({
743 value: String(i + 1),
744 label: t("heartbeat.monthOption", { n: i + 1 }),
745 }));
746 const DAYS = Array.from({ length: 31 }, (_, i) => ({
747 value: String(i + 1),
748 label: t("heartbeat.dayOption", { n: i + 1 }),
749 }));
750
751 return (
752 <div className="heartbeat-editor__cycle-wrap">
753 <div className="heartbeat-editor__cycle-row">
754 <select
755 className="heartbeat-editor__freq-select"
756 value={cycleType}
757 onChange={(e) => onCycleTypeChange(e.target.value)}
758 >
759 <option value="daily">{t("heartbeat.cycleDaily")}</option>
760 <option value="weekly">{t("heartbeat.cycleWeekly")}</option>
761 <option value="biweekly">{t("heartbeat.cycleBiweekly")}</option>
762 <option value="monthly">{t("heartbeat.cycleMonthly")}</option>
763 <option value="yearly">{t("heartbeat.cycleYearly")}</option>
764 </select>
765
766 {cycleType === "monthly" && (
767 <select
768 className="heartbeat-editor__freq-select"
769 value={monthDay}
770 onChange={(e) => onMonthDayChange(e.target.value)}
771 >
772 {DAYS.map((d) => (
773 <option key={d.value} value={d.value}>{d.label}</option>
774 ))}
775 </select>
776 )}
777
778 {cycleType === "yearly" && (
779 <>
780 <select
781 className="heartbeat-editor__freq-select"
782 value={yearMonth}
783 onChange={(e) => onYearMonthChange(e.target.value)}
784 >
785 {MONTHS.map((m) => (
786 <option key={m.value} value={m.value}>{m.label}</option>
787 ))}
788 </select>
789 <select
790 className="heartbeat-editor__freq-select"
791 value={yearDay}
792 onChange={(e) => onYearDayChange(e.target.value)}
793 >
794 {DAYS.map((d) => (
795 <option key={d.value} value={d.value}>{d.label}</option>
796 ))}
797 </select>
798 </>
799 )}
800
801 <input
802 className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time"
803 type="time"
804 value={timeVal}
805 onChange={(e) => onTimeChange(e.target.value)}
806 />
807
808 {hasWeekdays && (
809 <div className="set-seg">
810 {WEEKDAYS.map((wd) => (
811 <button
812 key={wd.key}
813 type="button"
814 className={`set-seg__btn${selectedDays.includes(wd.key) ? " set-seg__btn--on" : ""}`}
815 onClick={() => onDayToggle(wd.key)}
816 aria-pressed={selectedDays.includes(wd.key)}
817 >
818 {t(wd.labelKey)}
819 </button>
820 ))}
821 </div>
822 )}
823 </div>
824 </div>
825 );
826 }
827
828 // ── Editor ─────────────────────────────────────────────────────────────────────
829
830 function normalizeMode(mode: "ask" | "auto" | "yolo" | undefined): "ask" | "auto" | "yolo" {
831 if (mode === "ask" || mode === "auto" || mode === "yolo") return mode;
832 return "yolo"; // default
833 }
834
835 function TaskEditor({
836 task,
837 onSave,
838 onCancel,
839 onDelete,
840 onDirtyChange,
841 }: {
842 task: HeartbeatTask;
843 onSave: (t: HeartbeatTask) => void;
844 onCancel: () => void;
845 onDelete: () => void;
846 onDirtyChange?: (dirty: boolean) => void;
847 }) {
848 const t = useT();
849 const titleRef = useRef<HTMLInputElement>(null);
850 const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
851 const [projectOpen, setProjectOpen] = useState(false);
852 const [confirmingDelete, setConfirmingDelete] = useState(false);
853 const projectRef = useRef<HTMLDivElement>(null);
854
855 useEffect(() => {
856 titleRef.current?.focus();
857 app.ListWorkspaces().then((list) => setWorkspaces(list ?? [])).catch(() => {});
858 }, []);
859
860 useEffect(() => {
861 if (!projectOpen) return;
862 const close = (e: MouseEvent) => {
863 if (projectRef.current && !projectRef.current.contains(e.target as Node)) {
864 setProjectOpen(false);
865 }
866 };
867 document.addEventListener("click", close);
868 return () => document.removeEventListener("click", close);
869 }, [projectOpen]);
870
871 const [draft, setDraft] = useState(task);
872 const initialTaskRef = useRef(task);
873 const isDirty = draft.title !== initialTaskRef.current.title
874 || draft.prompt !== initialTaskRef.current.prompt
875 || draft.interval !== initialTaskRef.current.interval
876 || draft.enabled !== initialTaskRef.current.enabled
877 || draft.approvalMode !== initialTaskRef.current.approvalMode
878 || draft.newConversationEachRun !== initialTaskRef.current.newConversationEachRun
879 || draft.notifyChannels !== initialTaskRef.current.notifyChannels
880 || draft.scope !== initialTaskRef.current.scope
881 || draft.workspaceRoot !== initialTaskRef.current.workspaceRoot
882 || draft.timeWindowStart !== initialTaskRef.current.timeWindowStart
883 || draft.timeWindowEnd !== initialTaskRef.current.timeWindowEnd;
884
885 useEffect(() => {
886 onDirtyChange?.(isDirty);
887 }, [isDirty, onDirtyChange]);
888
889 const intervalBeforeCycle = useRef<string | null>(null);
890 const promptRef = useRef<HTMLTextAreaElement>(null);
891
892 // Auto-grow prompt textarea: shrink-to-fit then cap at 180px
893 const autoGrowPrompt = useCallback(() => {
894 const el = promptRef.current;
895 if (!el) return;
896 el.style.height = "auto";
897 el.style.height = Math.min(el.scrollHeight, 180) + "px";
898 }, []);
899
900 useLayoutEffect(() => {
901 autoGrowPrompt();
902 }, [draft.prompt, autoGrowPrompt]);
903 const set = useCallback((field: keyof HeartbeatTask, value: string | boolean) => {
904 setDraft((prev) => ({ ...prev, [field]: value }));
905 }, []);
906
907 // Detect frequency type from interval value
908 const [freqType, setFreqType] = useState<"cycle" | "interval">(
909 (task.interval && task.interval.includes("|")) ? "cycle" : "interval"
910 );
911
912 const isNew = !task.createdAt;
913 const selectedWorkspace = draft.scope === "project" && draft.workspaceRoot
914 ? workspaces.find((w) => w.path === draft.workspaceRoot)
915 : null;
916
917 return (
918 <div className="heartbeat-editor">
919 <div className="heartbeat-editor__fields">
920 {/* Title */}
921 <div className="heartbeat-editor__field">
922 <label>{t("heartbeat.fieldTitle")}</label>
923 <input
924 ref={titleRef}
925 className="heartbeat-editor__input"
926 value={draft.title}
927 onChange={(e) => set("title", e.target.value)}
928 placeholder={t("heartbeat.titlePlaceholder")}
929 />
930 </div>
931
932 {/* Scope */}
933 <div className="heartbeat-editor__field">
934 <label>{t("heartbeat.fieldScope")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
935 <div className="heartbeat-editor__scope-row">
936 <button
937 className={`heartbeat-scope-btn${draft.scope !== "project" ? " heartbeat-scope-btn--active" : ""}`}
938 onClick={() => setDraft((prev) => ({ ...prev, scope: "global", workspaceRoot: "" }))}
939 >
940 {t("heartbeat.scopeGlobal")}
941 </button>
942 <div className="heartbeat-project-wrap" ref={projectRef}>
943 <button
944 className={`heartbeat-scope-btn${draft.scope === "project" ? " heartbeat-scope-btn--active" : ""}`}
945 onClick={() => setProjectOpen((v) => !v)}
946 >
947 {selectedWorkspace ? selectedWorkspace.name : t("heartbeat.scopeProject")}
948 <ChevronsUpDown size={12} />
949 </button>
950 {projectOpen && (
951 <div className="heartbeat-project-menu">
952 {workspaces.length === 0 ? (
953 <div className="heartbeat-project-menu__empty">{t("heartbeat.noProjects")}</div>
954 ) : (
955 workspaces.map((ws) => (
956 <button
957 key={ws.path}
958 className={`heartbeat-project-menu__item${draft.workspaceRoot === ws.path ? " heartbeat-project-menu__item--active" : ""}`}
959 onClick={() => {
960 setDraft((prev) => ({ ...prev, scope: "project", workspaceRoot: ws.path }));
961 setProjectOpen(false);
962 }}
963 >
964 {ws.name}
965 {ws.current && <span className="heartbeat-project-menu__current">{t("heartbeat.currentWorkspace")}</span>}
966 </button>
967 ))
968 )}
969 </div>
970 )}
971 </div>
972 </div>
973 </div>
974
975 {/* Prompt */}
976 <div className="heartbeat-editor__field">
977 <label>{t("heartbeat.fieldPrompt")}</label>
978 <textarea
979 ref={promptRef}
980 className="heartbeat-editor__textarea"
981 value={draft.prompt}
982 onChange={(e) => {
983 set("prompt", e.target.value);
984 // autoGrowPrompt is called via useEffect watching draft.prompt
985 }}
986 placeholder={t("heartbeat.promptPlaceholder")}
987 />
988 </div>
989
990 {/* Approval Mode + Push to bot (side by side) */}
991 <div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
992 <div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px" }}>
993 <label>{t("heartbeat.fieldApprovalMode")}</label>
994 <div className="set-seg" style={{ alignSelf: "flex-start" }}>
995 <button
996 className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
997 onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
998 title={t("heartbeat.approvalModeAskTooltip")}
999 >
1000 {t("heartbeat.approvalModeAsk")}
1001 </button>
1002 <button
1003 className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
1004 onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
1005 title={t("heartbeat.approvalModeAutoTooltip")}
1006 >
1007 {t("heartbeat.approvalModeAuto")}
1008 </button>
1009 <button
1010 className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
1011 onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
1012 title={t("heartbeat.approvalModeYoloTooltip")}
1013 >
1014 {t("heartbeat.approvalModeYolo")}
1015 </button>
1016 </div>
1017 <span className="heartbeat-editor__mode-hint">
1018 {normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
1019 normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
1020 t("heartbeat.approvalModeAskHint")}
1021 </span>
1022 </div>
1023
1024 {/* Push to bot channels */}
1025 <div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px", textAlign: "left" }}>
1026 <label>{t("heartbeat.notifyChannels")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
1027 <div className="set-seg" style={{ alignSelf: "flex-start" }}>
1028 <button
1029 className={`set-seg__btn${draft.notifyChannels === true ? " set-seg__btn--on" : ""}`}
1030 onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: true }))}
1031 >
1032 {t("heartbeat.notifyChannelsOn")}
1033 </button>
1034 <button
1035 className={`set-seg__btn${draft.notifyChannels !== true ? " set-seg__btn--on" : ""}`}
1036 onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: false }))}
1037 >
1038 {t("heartbeat.notifyChannelsOff")}
1039 </button>
1040 </div>
1041 <span className="heartbeat-editor__mode-hint">
1042 {draft.notifyChannels === true
1043 ? t("heartbeat.notifyChannelsOnHint")
1044 : t("heartbeat.notifyChannelsOffHint")}
1045 </span>
1046 </div>
1047 </div>
1048
1049 {/* New conversation per run */}
1050 <div className="heartbeat-editor__field">
1051 <label>{t("heartbeat.fieldNewConversation")}</label>
1052 <div className="set-seg" style={{ alignSelf: "flex-start" }}>
1053 <button
1054 className={`set-seg__btn${!draft.newConversationEachRun ? " set-seg__btn--on" : ""}`}
1055 onClick={() => setDraft((prev) => ({ ...prev, newConversationEachRun: false }))}
1056 >
1057 {t("heartbeat.newConversationEachRunOff")}
1058 </button>
1059 <button
1060 className={`set-seg__btn${draft.newConversationEachRun ? " set-seg__btn--on" : ""}`}
1061 onClick={() => setDraft((prev) => ({ ...prev, newConversationEachRun: true }))}
1062 >
1063 {t("heartbeat.newConversationEachRunOn")}
1064 </button>
1065 </div>
1066 </div>
1067
1068 {/* Frequency */}
1069 <div className="heartbeat-editor__field">
1070 <label>{t("heartbeat.fieldInterval")}</label>
1071 <div className="set-seg" style={{ alignSelf: "flex-start" }}>
1072 <button
1073 className={`set-seg__btn${freqType === "cycle" ? " set-seg__btn--on" : ""}`}
1074 onClick={() => {
1075 setFreqType("cycle");
1076 // Save the original interval so switching back can restore it
1077 const cur = draft.interval || "";
1078 const nextInterval = cur.includes("|") ? cur : "24h|daily@09:00";
1079 if (!cur.includes("|")) {
1080 intervalBeforeCycle.current = cur;
1081 }
1082 setDraft((prev) => ({ ...prev, interval: nextInterval, timeWindowStart: undefined, timeWindowEnd: undefined }));
1083 }}
1084 >
1085 {t("heartbeat.freqCycle")}
1086 </button>
1087 <button
1088 className={`set-seg__btn${freqType === "interval" ? " set-seg__btn--on" : ""}`}
1089 onClick={() => {
1090 setFreqType("interval");
1091 // Restore original interval if user toggled cycle and back without saving
1092 if (intervalBeforeCycle.current !== null) {
1093 setDraft((prev) => ({ ...prev, interval: intervalBeforeCycle.current! }));
1094 intervalBeforeCycle.current = null;
1095 } else if ((draft.interval || "").includes("|")) {
1096 // Fallback: strip cycle suffix
1097 setDraft((prev) => ({ ...prev, interval: (prev.interval || "").replace(/\|.*$/, "") }));
1098 }
1099 }}
1100 >
1101 {t("heartbeat.freqInterval")}
1102 </button>
1103 </div>
1104
1105 {freqType === "cycle" ? <CycleEditor draft={draft} setDraft={set} /> : (
1106 <div className="heartbeat-editor__freq-interval">
1107 <span className="heartbeat-editor__freq-label">{t("heartbeat.freqEvery")}</span>
1108 <input
1109 className="heartbeat-editor__freq-input"
1110 value={(() => {
1111 const m = (draft.interval || "").match(/^(\d+)/);
1112 return m ? m[1] : "1";
1113 })()}
1114 onChange={(e) => {
1115 const num = e.target.value.replace(/\D/g, "");
1116 const mUnit = (draft.interval || "").match(/^(\d+)([smh])/);
1117 const unit = mUnit ? mUnit[2] : "h";
1118 // Guard: never save a bare unit string like "h" or "m"
1119 setDraft((prev) => ({ ...prev, interval: num ? num + unit : "1" + unit }));
1120 }}
1121 placeholder="1"
1122 />
1123 <select
1124 className="heartbeat-editor__freq-select"
1125 value={(() => {
1126 const m = (draft.interval || "").match(/^(\d+)([smh])/);
1127 return m ? m[2] : "h";
1128 })()}
1129 onChange={(e) => {
1130 const num = (draft.interval || "").match(/^(\d+)/)?.[1] || "1";
1131 setDraft((prev) => ({ ...prev, interval: num + e.target.value }));
1132 }}
1133 >
1134 <option value="m">{t("heartbeat.unitMin")}</option>
1135 <option value="h">{t("heartbeat.unitHour")}</option>
1136 </select>
1137 <span className="heartbeat-editor__freq-label" style={{ marginLeft: "6px" }}>
1138 {draft.timeWindowStart || draft.timeWindowEnd ? (
1139 <>{t("heartbeat.timeWindow")}</>
1140 ) : (
1141 <span className="heartbeat-editor__tw-add"
1142 onClick={() => setDraft((prev) => ({ ...prev, timeWindowStart: "09:00", timeWindowEnd: "17:00" }))}
1143 >
1144 + {t("heartbeat.timeWindow")}
1145 </span>
1146 )}
1147 </span>
1148 {(draft.timeWindowStart || draft.timeWindowEnd) && (
1149 <>
1150 <input
1151 className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time"
1152 type="time"
1153 value={draft.timeWindowStart || ""}
1154 onChange={(e) => setDraft((prev) => ({ ...prev, timeWindowStart: e.target.value || undefined }))}
1155 placeholder="09:00"
1156 />
1157 <span className="heartbeat-editor__freq-label heartbeat-editor__tw-sep">—</span>
1158 <input
1159 className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time"
1160 type="time"
1161 value={draft.timeWindowEnd || ""}
1162 onChange={(e) => setDraft((prev) => ({ ...prev, timeWindowEnd: e.target.value || undefined }))}
1163 placeholder="17:00"
1164 />
1165 <button
1166 className="heartbeat-card__open-btn heartbeat-editor__tw-clear"
1167 onClick={() => setDraft((prev) => ({ ...prev, timeWindowStart: undefined, timeWindowEnd: undefined }))}
1168 title={t("heartbeat.clearTimeWindow")}
1169 >
1170 ×
1171 </button>
1172 </>
1173 )}
1174 </div>
1175 )}
1176 </div>
1177
1178 </div>
1179
1180 {/* Actions */}
1181 <div className="heartbeat-editor__actions">
1182 {!isNew && !confirmingDelete && (
1183 <button className="heartbeat-btn heartbeat-btn--danger" onClick={() => setConfirmingDelete(true)} style={{ marginRight: "auto" }}>
1184 <Trash2 size={13} />
1185 {t("heartbeat.delete")}
1186 </button>
1187 )}
1188 {!isNew && confirmingDelete && (
1189 <span className="heartbeat-editor__confirm-del" style={{ marginRight: "auto" }}>
1190 <span>{t("heartbeat.confirmDelete")}</span>
1191 <button className="heartbeat-btn heartbeat-btn--danger" onClick={onDelete}>
1192 {t("common.delete")}
1193 </button>
1194 <button className="heartbeat-btn" onClick={() => setConfirmingDelete(false)}>
1195 {t("common.cancel")}
1196 </button>
1197 </span>
1198 )}
1199 <button
1200 className="heartbeat-btn heartbeat-btn--primary"
1201 onClick={() => onSave(draft)}
1202 disabled={!draft.title.trim() || !draft.prompt.trim() || !isDirty}
1203 title={!draft.title.trim() || !draft.prompt.trim() ? t("heartbeat.requiredFields") : !isDirty ? t("heartbeat.noChanges") : undefined}
1204 >
1205 {isNew ? t("heartbeat.add") : t("heartbeat.save")}
1206 </button>
1207 <button className="heartbeat-btn" onClick={onCancel}>
1208 {t("common.cancel")}
1209 </button>
1210 </div>
1211 </div>
1212 );
1213 }
1214
1214 lines Plain Text