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