| 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 |