botschat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +213 -0
  3. package/migrations/0001_initial.sql +88 -0
  4. package/migrations/0002_rename_projects_to_channels.sql +53 -0
  5. package/migrations/0003_messages.sql +14 -0
  6. package/migrations/0004_jobs.sql +15 -0
  7. package/migrations/0005_deleted_cron_jobs.sql +6 -0
  8. package/migrations/0006_tasks_add_model.sql +2 -0
  9. package/migrations/0007_sessions.sql +25 -0
  10. package/migrations/0008_remove_openclaw_fields.sql +8 -0
  11. package/package.json +53 -0
  12. package/packages/api/package.json +17 -0
  13. package/packages/api/src/do/connection-do.ts +929 -0
  14. package/packages/api/src/env.ts +8 -0
  15. package/packages/api/src/index.ts +297 -0
  16. package/packages/api/src/routes/agents.ts +68 -0
  17. package/packages/api/src/routes/auth.ts +105 -0
  18. package/packages/api/src/routes/channels.ts +185 -0
  19. package/packages/api/src/routes/jobs.ts +65 -0
  20. package/packages/api/src/routes/models.ts +22 -0
  21. package/packages/api/src/routes/pairing.ts +76 -0
  22. package/packages/api/src/routes/projects.ts +177 -0
  23. package/packages/api/src/routes/sessions.ts +171 -0
  24. package/packages/api/src/routes/tasks.ts +375 -0
  25. package/packages/api/src/routes/upload.ts +52 -0
  26. package/packages/api/src/utils/auth.ts +101 -0
  27. package/packages/api/src/utils/id.ts +19 -0
  28. package/packages/api/tsconfig.json +18 -0
  29. package/packages/plugin/dist/index.d.ts +19 -0
  30. package/packages/plugin/dist/index.d.ts.map +1 -0
  31. package/packages/plugin/dist/index.js +17 -0
  32. package/packages/plugin/dist/index.js.map +1 -0
  33. package/packages/plugin/dist/src/accounts.d.ts +12 -0
  34. package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
  35. package/packages/plugin/dist/src/accounts.js +103 -0
  36. package/packages/plugin/dist/src/accounts.js.map +1 -0
  37. package/packages/plugin/dist/src/channel.d.ts +206 -0
  38. package/packages/plugin/dist/src/channel.d.ts.map +1 -0
  39. package/packages/plugin/dist/src/channel.js +1248 -0
  40. package/packages/plugin/dist/src/channel.js.map +1 -0
  41. package/packages/plugin/dist/src/runtime.d.ts +3 -0
  42. package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
  43. package/packages/plugin/dist/src/runtime.js +18 -0
  44. package/packages/plugin/dist/src/runtime.js.map +1 -0
  45. package/packages/plugin/dist/src/types.d.ts +179 -0
  46. package/packages/plugin/dist/src/types.d.ts.map +1 -0
  47. package/packages/plugin/dist/src/types.js +6 -0
  48. package/packages/plugin/dist/src/types.js.map +1 -0
  49. package/packages/plugin/dist/src/ws-client.d.ts +51 -0
  50. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
  51. package/packages/plugin/dist/src/ws-client.js +170 -0
  52. package/packages/plugin/dist/src/ws-client.js.map +1 -0
  53. package/packages/plugin/openclaw.plugin.json +11 -0
  54. package/packages/plugin/package.json +39 -0
  55. package/packages/plugin/tsconfig.json +20 -0
  56. package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
  57. package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
  58. package/packages/web/dist/index.html +17 -0
  59. package/packages/web/index.html +16 -0
  60. package/packages/web/package.json +29 -0
  61. package/packages/web/postcss.config.js +6 -0
  62. package/packages/web/src/App.tsx +827 -0
  63. package/packages/web/src/api.ts +242 -0
  64. package/packages/web/src/components/ChatWindow.tsx +864 -0
  65. package/packages/web/src/components/CronDetail.tsx +943 -0
  66. package/packages/web/src/components/CronSidebar.tsx +123 -0
  67. package/packages/web/src/components/DebugLogPanel.tsx +258 -0
  68. package/packages/web/src/components/IconRail.tsx +163 -0
  69. package/packages/web/src/components/JobList.tsx +120 -0
  70. package/packages/web/src/components/LoginPage.tsx +178 -0
  71. package/packages/web/src/components/MessageContent.tsx +1082 -0
  72. package/packages/web/src/components/ModelSelect.tsx +87 -0
  73. package/packages/web/src/components/ScheduleEditor.tsx +403 -0
  74. package/packages/web/src/components/SessionTabs.tsx +246 -0
  75. package/packages/web/src/components/Sidebar.tsx +331 -0
  76. package/packages/web/src/components/TaskBar.tsx +413 -0
  77. package/packages/web/src/components/ThreadPanel.tsx +212 -0
  78. package/packages/web/src/debug-log.ts +58 -0
  79. package/packages/web/src/index.css +170 -0
  80. package/packages/web/src/main.tsx +10 -0
  81. package/packages/web/src/store.ts +492 -0
  82. package/packages/web/src/ws.ts +99 -0
  83. package/packages/web/tailwind.config.js +65 -0
  84. package/packages/web/tsconfig.json +18 -0
  85. package/packages/web/vite.config.ts +20 -0
  86. package/scripts/dev.sh +122 -0
  87. package/tsconfig.json +18 -0
  88. package/wrangler.toml +40 -0
@@ -0,0 +1,943 @@
1
+ import React, { useEffect, useCallback, useState, useRef } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import { useAppState, useAppDispatch } from "../store";
5
+ import { jobsApi, tasksApi } from "../api";
6
+ import { ModelSelect } from "./ModelSelect";
7
+ import { ScheduleEditor, ScheduleDisplay } from "./ScheduleEditor";
8
+ import { dlog } from "../debug-log";
9
+
10
+ function relativeTime(ts: number): string {
11
+ const now = Date.now() / 1000;
12
+ const diff = now - ts;
13
+ if (diff < 0) return "in " + formatDuration(-diff);
14
+ if (diff < 60) return "just now";
15
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
16
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
17
+ return `${Math.floor(diff / 86400)}d ago`;
18
+ }
19
+
20
+ function formatDuration(seconds: number): string {
21
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
22
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
23
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
24
+ }
25
+
26
+ function formatTimestamp(ts: number): string {
27
+ return new Date(ts * 1000).toLocaleString();
28
+ }
29
+
30
+ function statusLabel(status: string): string {
31
+ switch (status) {
32
+ case "ok": return "OK";
33
+ case "error": return "ERR";
34
+ case "skipped": return "SKIP";
35
+ case "running": return "RUN";
36
+ default: return status.toUpperCase();
37
+ }
38
+ }
39
+
40
+ function statusColors(status: string): { bg: string; fg: string } {
41
+ switch (status) {
42
+ case "ok": return { bg: "rgba(43,172,118,0.15)", fg: "var(--accent-green)" };
43
+ case "error": return { bg: "rgba(224,30,90,0.15)", fg: "var(--accent-red)" };
44
+ case "running": return { bg: "rgba(29,155,209,0.15)", fg: "var(--text-link)" };
45
+ default: return { bg: "rgba(232,162,48,0.15)", fg: "var(--accent-yellow)" };
46
+ }
47
+ }
48
+
49
+ export function CronDetail() {
50
+ const state = useAppState();
51
+ const dispatch = useAppDispatch();
52
+
53
+ const task = state.cronTasks.find((t) => t.id === state.selectedCronTaskId);
54
+
55
+ // Editing state
56
+ const [editingField, setEditingField] = useState<"name" | "schedule" | "instructions" | null>(null);
57
+ const [editValue, setEditValue] = useState("");
58
+ const [saving, setSaving] = useState(false);
59
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
60
+ const [deleting, setDeleting] = useState(false);
61
+ const [running, setRunning] = useState(false);
62
+ const [infoExpanded, setInfoExpanded] = useState(true);
63
+ const editRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
64
+
65
+ // Reset edit state when task changes
66
+ useEffect(() => {
67
+ setEditingField(null);
68
+ setShowDeleteConfirm(false);
69
+ }, [state.selectedCronTaskId]);
70
+
71
+ // Focus input when editing starts
72
+ useEffect(() => {
73
+ if (editingField && editRef.current) {
74
+ editRef.current.focus();
75
+ }
76
+ }, [editingField]);
77
+
78
+ // Load jobs when a cron task is selected
79
+ const loadJobs = useCallback(() => {
80
+ if (!task) return;
81
+ dlog.info("Cron", `Loading jobs for task: ${task.name} (${task.id})`);
82
+ jobsApi.listByTask(task.id).then(({ jobs }) => {
83
+ dlog.info("Cron", `Loaded ${jobs.length} jobs for task ${task.name}`);
84
+ // Preserve in-memory summaries (streaming data isn't persisted to D1).
85
+ // Use the ref so we always get the latest data.
86
+ const latestCronJobs = cronJobsRef.current;
87
+ const cachedSummaries = new Map<string, string>();
88
+ for (const j of latestCronJobs) {
89
+ if (j.summary) {
90
+ cachedSummaries.set(j.id, j.summary);
91
+ }
92
+ }
93
+ const merged = jobs.map((j) => {
94
+ const cached = cachedSummaries.get(j.id);
95
+ if (cached && cached.length > (j.summary?.length || 0)) {
96
+ return { ...j, summary: cached };
97
+ }
98
+ return j;
99
+ });
100
+ dispatch({ type: "SET_CRON_JOBS", cronJobs: merged });
101
+ if (jobs.length > 0 && !state.selectedCronJobId) {
102
+ dispatch({
103
+ type: "SELECT_CRON_JOB",
104
+ jobId: jobs[0].id,
105
+ sessionKey: jobs[0].sessionKey,
106
+ });
107
+ }
108
+ }).catch((err) => {
109
+ dlog.error("Cron", `Failed to load jobs: ${err}`);
110
+ });
111
+ }, [task?.id]);
112
+
113
+ useEffect(() => {
114
+ loadJobs();
115
+ }, [loadJobs]);
116
+
117
+ // Keep a ref with the latest cronJobs to avoid stale closures in the
118
+ // auto-refresh interval (which would overwrite fresh streaming summaries
119
+ // with older data captured at effect-setup time).
120
+ const cronJobsRef = useRef(state.cronJobs);
121
+ useEffect(() => { cronJobsRef.current = state.cronJobs; }, [state.cronJobs]);
122
+
123
+ // Auto-refresh while any job is in "running" state.
124
+ // Preserve streaming summaries for running jobs (API returns empty summary
125
+ // because job.output chunks are not persisted to D1).
126
+ useEffect(() => {
127
+ const hasRunning = state.cronJobs.some((j) => j.status === "running");
128
+ if (!hasRunning || !task) return;
129
+ const taskId = task.id;
130
+ const timer = setInterval(() => {
131
+ dlog.info("Cron", `Auto-refreshing jobs (running job detected)`);
132
+ jobsApi.listByTask(taskId).then(({ jobs }) => {
133
+ // Use the ref (always up-to-date) to get the latest streaming summaries
134
+ const latestCronJobs = cronJobsRef.current;
135
+ const runningSummaries = new Map<string, string>();
136
+ for (const j of latestCronJobs) {
137
+ if (j.summary) {
138
+ runningSummaries.set(j.id, j.summary);
139
+ }
140
+ }
141
+ // Merge: keep in-memory summary if it's longer than the API summary
142
+ // (streaming data isn't persisted to D1)
143
+ const merged = jobs.map((j) => {
144
+ const cached = runningSummaries.get(j.id);
145
+ if (cached && cached.length > (j.summary?.length || 0)) {
146
+ return { ...j, summary: cached };
147
+ }
148
+ return j;
149
+ });
150
+ dispatch({ type: "SET_CRON_JOBS", cronJobs: merged });
151
+ }).catch(() => {});
152
+ }, 3000);
153
+ return () => clearInterval(timer);
154
+ }, [state.cronJobs, task?.id]);
155
+
156
+
157
+ const handleToggleEnabled = useCallback(async () => {
158
+ if (!task) return;
159
+ const newEnabled = !task.enabled;
160
+ dlog.info("Cron", `Toggle task "${task.name}": ${task.enabled ? "enabled → disabled" : "disabled → enabled"}`);
161
+ // Optimistic update
162
+ dispatch({ type: "UPDATE_CRON_TASK", taskId: task.id, updates: { enabled: newEnabled } });
163
+ try {
164
+ // Send ALL OpenClaw-owned fields (schedule, instructions, enabled) together
165
+ // since they are not stored in D1 — the API just passes them through to OpenClaw.
166
+ await tasksApi.update(task.channelId, task.id, {
167
+ schedule: task.schedule ?? "",
168
+ instructions: task.instructions ?? "",
169
+ enabled: newEnabled,
170
+ model: task.model ?? "",
171
+ });
172
+ } catch (err) {
173
+ dlog.error("Cron", `Failed to toggle task: ${err}`);
174
+ // Revert optimistic update
175
+ dispatch({ type: "UPDATE_CRON_TASK", taskId: task.id, updates: { enabled: task.enabled } });
176
+ }
177
+ }, [task]);
178
+
179
+ const handleSelectJob = useCallback((jobId: string) => {
180
+ const job = state.cronJobs.find((j) => j.id === jobId);
181
+ if (job) {
182
+ dlog.info("Cron", `Selected job #${job.number || jobId} (status=${job.status})`);
183
+ dispatch({
184
+ type: "SELECT_CRON_JOB",
185
+ jobId: job.id,
186
+ sessionKey: job.sessionKey || undefined,
187
+ });
188
+ }
189
+ }, [state.cronJobs]);
190
+
191
+ const startEdit = (field: "name" | "schedule" | "instructions") => {
192
+ if (!task) return;
193
+ const current = field === "name" ? task.name
194
+ : field === "schedule" ? (task.schedule ?? "")
195
+ : (task.instructions ?? "");
196
+ setEditValue(current);
197
+ setEditingField(field);
198
+ };
199
+
200
+ const handleModelSelectChange = useCallback(async (modelId: string) => {
201
+ if (!task) return;
202
+ dlog.info("Cron", `Change model for "${task.name}": → ${modelId || "default"}`);
203
+ const oldModel = task.model;
204
+ // Optimistic update
205
+ dispatch({ type: "UPDATE_CRON_TASK", taskId: task.id, updates: { model: modelId || null } });
206
+ try {
207
+ // Send ALL OpenClaw-owned fields together
208
+ await tasksApi.update(task.channelId, task.id, {
209
+ schedule: task.schedule ?? "",
210
+ instructions: task.instructions ?? "",
211
+ enabled: task.enabled,
212
+ model: modelId,
213
+ });
214
+ } catch (err) {
215
+ dlog.error("Cron", `Failed to update task model: ${err}`);
216
+ // Revert optimistic update
217
+ dispatch({ type: "UPDATE_CRON_TASK", taskId: task.id, updates: { model: oldModel } });
218
+ }
219
+ }, [task]);
220
+
221
+ const handleRunNow = useCallback(async () => {
222
+ if (!task || running) return;
223
+ dlog.info("Cron", `Triggering immediate run for "${task.name}"`);
224
+ setRunning(true);
225
+ try {
226
+ await tasksApi.run(task.channelId, task.id);
227
+ dlog.info("Cron", `Task "${task.name}" triggered successfully`);
228
+ // The plugin will send job.update via WS with status "running"
229
+ // which will be handled by ADD_CRON_JOB in the store.
230
+ // Also do a one-time refresh after a short delay as fallback.
231
+ setTimeout(() => {
232
+ loadJobs();
233
+ }, 1500);
234
+ } catch (err) {
235
+ dlog.error("Cron", `Failed to trigger task: ${err}`);
236
+ } finally {
237
+ setRunning(false);
238
+ }
239
+ }, [task, running, loadJobs]);
240
+
241
+ const cancelEdit = () => {
242
+ setEditingField(null);
243
+ setEditValue("");
244
+ };
245
+
246
+ const saveEdit = async () => {
247
+ if (!task || !editingField) return;
248
+ dlog.info("Cron", `Save edit "${editingField}" for "${task.name}": ${editValue.length > 80 ? editValue.slice(0, 80) + "…" : editValue}`);
249
+ setSaving(true);
250
+ try {
251
+ if (editingField === "name") {
252
+ // Name is stored in D1 — update directly
253
+ dispatch({ type: "UPDATE_CRON_TASK", taskId: task.id, updates: { name: editValue } });
254
+ await tasksApi.update(task.channelId, task.id, { name: editValue });
255
+ } else {
256
+ // Schedule and instructions belong to OpenClaw — send ALL OpenClaw fields together.
257
+ const updates: Partial<typeof task> = {
258
+ [editingField]: editValue,
259
+ };
260
+ dispatch({ type: "UPDATE_CRON_TASK", taskId: task.id, updates });
261
+ await tasksApi.update(task.channelId, task.id, {
262
+ schedule: editingField === "schedule" ? editValue : (task.schedule ?? ""),
263
+ instructions: editingField === "instructions" ? editValue : (task.instructions ?? ""),
264
+ enabled: task.enabled,
265
+ model: task.model ?? "",
266
+ });
267
+ }
268
+ setEditingField(null);
269
+ } catch (err) {
270
+ dlog.error("Cron", `Failed to update task field "${editingField}": ${err}`);
271
+ } finally {
272
+ setSaving(false);
273
+ }
274
+ };
275
+
276
+ const handleDelete = async () => {
277
+ if (!task) return;
278
+ dlog.info("Cron", `Deleting task: "${task.name}" (${task.id})`);
279
+ setDeleting(true);
280
+ try {
281
+ await tasksApi.delete(task.channelId, task.id);
282
+ dlog.info("Cron", `Task deleted: "${task.name}"`);
283
+ // Reload task list after deletion (D1 record is removed)
284
+ const { tasks } = await tasksApi.listAll("background");
285
+ dispatch({ type: "SET_CRON_TASKS", cronTasks: tasks });
286
+ dispatch({ type: "SELECT_CRON_TASK", taskId: tasks.length > 0 ? tasks[0].id : null });
287
+ setShowDeleteConfirm(false);
288
+ } catch (err) {
289
+ dlog.error("Cron", `Failed to delete task: ${err}`);
290
+ } finally {
291
+ setDeleting(false);
292
+ }
293
+ };
294
+
295
+ const handleKeyDown = (e: React.KeyboardEvent) => {
296
+ if (e.key === "Escape") cancelEdit();
297
+ if (e.key === "Enter" && !e.shiftKey && editingField !== "instructions") {
298
+ e.preventDefault();
299
+ saveEdit();
300
+ }
301
+ };
302
+
303
+ if (!task) {
304
+ return (
305
+ <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
306
+ <div className="text-center">
307
+ <svg
308
+ className="w-16 h-16 mx-auto mb-4"
309
+ fill="none"
310
+ viewBox="0 0 24 24"
311
+ stroke="currentColor"
312
+ strokeWidth={1}
313
+ style={{ color: "var(--text-muted)" }}
314
+ >
315
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
316
+ </svg>
317
+ <p className="text-body font-bold" style={{ color: "var(--text-muted)" }}>
318
+ Select an automation
319
+ </p>
320
+ <p className="text-caption mt-1" style={{ color: "var(--text-muted)" }}>
321
+ Choose a cron job from the sidebar to view details
322
+ </p>
323
+ </div>
324
+ </div>
325
+ );
326
+ }
327
+
328
+ // Find the channel for this task
329
+ const channel = state.channels.find((c) => c.id === task.channelId);
330
+
331
+ return (
332
+ <div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--bg-surface)" }}>
333
+ {/* ---- Header ---- */}
334
+ <div
335
+ className="flex items-center justify-between px-5 py-3"
336
+ style={{ borderBottom: "1px solid var(--border)" }}
337
+ >
338
+ <div className="flex items-center gap-3 min-w-0">
339
+ <svg
340
+ className="w-5 h-5 flex-shrink-0"
341
+ fill="none"
342
+ viewBox="0 0 24 24"
343
+ stroke="currentColor"
344
+ strokeWidth={1.5}
345
+ style={{ color: "var(--text-secondary)" }}
346
+ >
347
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
348
+ </svg>
349
+
350
+ {editingField === "name" ? (
351
+ <div className="flex items-center gap-2">
352
+ <input
353
+ ref={editRef as React.RefObject<HTMLInputElement>}
354
+ value={editValue}
355
+ onChange={(e) => setEditValue(e.target.value)}
356
+ onKeyDown={handleKeyDown}
357
+ className="text-h2 font-bold px-2 py-0.5 rounded-sm focus:outline-none"
358
+ style={{
359
+ background: "var(--bg-hover)",
360
+ color: "var(--text-primary)",
361
+ border: "1px solid var(--bg-active)",
362
+ minWidth: 200,
363
+ }}
364
+ />
365
+ <SaveCancelButtons saving={saving} onSave={saveEdit} onCancel={cancelEdit} />
366
+ </div>
367
+ ) : (
368
+ <h2
369
+ className="text-h2 font-bold truncate cursor-pointer hover:underline"
370
+ style={{ color: "var(--text-primary)" }}
371
+ onClick={() => startEdit("name")}
372
+ title="Click to edit name"
373
+ >
374
+ {task.name}
375
+ </h2>
376
+ )}
377
+
378
+ {!task.enabled && (
379
+ <span
380
+ className="text-tiny px-2 py-0.5 rounded-sm font-bold flex-shrink-0"
381
+ style={{ background: "rgba(232,162,48,0.15)", color: "var(--accent-yellow)" }}
382
+ >
383
+ PAUSED
384
+ </span>
385
+ )}
386
+ </div>
387
+
388
+ <div className="flex items-center gap-2 flex-shrink-0">
389
+ {/* Run Now button */}
390
+ <button
391
+ onClick={handleRunNow}
392
+ disabled={running}
393
+ className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded-sm transition-colors disabled:opacity-50"
394
+ style={{
395
+ background: "rgba(29,155,209,0.15)",
396
+ color: "var(--text-link)",
397
+ }}
398
+ title="Run task now (one-time)"
399
+ >
400
+ <svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
401
+ <path d="M8 5v14l11-7z" />
402
+ </svg>
403
+ {running ? "Running..." : "Run Now"}
404
+ </button>
405
+
406
+ {/* Delete button */}
407
+ <button
408
+ onClick={() => setShowDeleteConfirm(true)}
409
+ className="p-1.5 rounded-sm transition-colors hover:bg-[--bg-hover]"
410
+ style={{ color: "var(--text-muted)" }}
411
+ title="Delete task"
412
+ >
413
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
414
+ <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
415
+ </svg>
416
+ </button>
417
+
418
+ {/* Enable/Disable toggle */}
419
+ <button
420
+ onClick={handleToggleEnabled}
421
+ className="flex items-center gap-2 px-3 py-1.5 text-caption rounded-sm transition-colors"
422
+ style={{
423
+ background: task.enabled ? "rgba(43,172,118,0.15)" : "rgba(107,111,118,0.15)",
424
+ color: task.enabled ? "var(--accent-green)" : "var(--text-muted)",
425
+ }}
426
+ >
427
+ <div
428
+ className="w-7 h-4 rounded-full relative transition-colors"
429
+ style={{ background: task.enabled ? "var(--accent-green)" : "var(--text-muted)" }}
430
+ >
431
+ <div
432
+ className="w-3 h-3 rounded-full bg-white absolute top-0.5 transition-all"
433
+ style={{ left: task.enabled ? 14 : 2 }}
434
+ />
435
+ </div>
436
+ {task.enabled ? "Enabled" : "Disabled"}
437
+ </button>
438
+ </div>
439
+ </div>
440
+
441
+ {/* ---- Delete confirmation ---- */}
442
+ {showDeleteConfirm && (
443
+ <div
444
+ className="px-5 py-3 flex items-center justify-between"
445
+ style={{ background: "rgba(224,30,90,0.08)", borderBottom: "1px solid var(--border)" }}
446
+ >
447
+ <div className="flex items-center gap-2">
448
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: "var(--accent-red)" }}>
449
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
450
+ </svg>
451
+ <span className="text-caption" style={{ color: "var(--accent-red)" }}>
452
+ Delete "{task.name}"? This will remove the task, all execution history, and the OpenClaw cron job.
453
+ </span>
454
+ </div>
455
+ <div className="flex items-center gap-2 flex-shrink-0">
456
+ <button
457
+ onClick={handleDelete}
458
+ disabled={deleting}
459
+ className="px-3 py-1.5 text-caption font-bold text-white rounded-sm disabled:opacity-50"
460
+ style={{ background: "var(--accent-red)" }}
461
+ >
462
+ {deleting ? "Deleting..." : "Delete"}
463
+ </button>
464
+ <button
465
+ onClick={() => setShowDeleteConfirm(false)}
466
+ className="px-3 py-1.5 text-caption rounded-sm"
467
+ style={{ color: "var(--text-muted)" }}
468
+ >
469
+ Cancel
470
+ </button>
471
+ </div>
472
+ </div>
473
+ )}
474
+
475
+ {/* ---- Info section (collapsible) ---- */}
476
+ <div style={{ borderBottom: "1px solid var(--border)" }}>
477
+ <button
478
+ className="w-full flex items-center gap-2 px-5 py-2 text-tiny uppercase tracking-wider hover:bg-[--bg-hover] transition-colors"
479
+ style={{ color: "var(--text-muted)" }}
480
+ onClick={() => setInfoExpanded(!infoExpanded)}
481
+ >
482
+ <svg
483
+ className={`w-3 h-3 transition-transform ${infoExpanded ? "rotate-0" : "-rotate-90"}`}
484
+ fill="none"
485
+ viewBox="0 0 24 24"
486
+ stroke="currentColor"
487
+ strokeWidth={2}
488
+ >
489
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
490
+ </svg>
491
+ Task Details
492
+ </button>
493
+
494
+ {infoExpanded && (
495
+ <div className="px-5 pb-4 space-y-4">
496
+ {/* Row 1: Schedule (wider) + Model + Channel + Status */}
497
+ {editingField === "schedule" ? (
498
+ /* Schedule editing takes full width */
499
+ <div>
500
+ <InfoField label="Schedule">
501
+ <ScheduleEditor
502
+ value={editValue}
503
+ onChange={setEditValue}
504
+ onSave={saveEdit}
505
+ onCancel={cancelEdit}
506
+ saving={saving}
507
+ />
508
+ </InfoField>
509
+ </div>
510
+ ) : (
511
+ <div className="grid grid-cols-4 gap-4">
512
+ <InfoField label="Schedule">
513
+ <ScheduleDisplay
514
+ schedule={task.schedule}
515
+ onClick={() => startEdit("schedule")}
516
+ />
517
+ </InfoField>
518
+
519
+ <InfoField label="Model">
520
+ <ModelSelect
521
+ value={task.model ?? ""}
522
+ onChange={handleModelSelectChange}
523
+ models={state.models}
524
+ placeholder="Default"
525
+ />
526
+ </InfoField>
527
+
528
+ <InfoField label="Channel">
529
+ <span className="text-body" style={{ color: "var(--text-primary)" }}>
530
+ {channel?.name ?? "Default"}
531
+ </span>
532
+ </InfoField>
533
+
534
+ <InfoField label="Status">
535
+ <div className="flex items-center gap-2">
536
+ <div
537
+ className="w-2 h-2 rounded-full"
538
+ style={{ background: task.enabled ? "var(--accent-green)" : "var(--accent-yellow)" }}
539
+ />
540
+ <span className="text-body" style={{ color: "var(--text-primary)" }}>
541
+ {task.enabled ? "Active" : "Paused"}
542
+ </span>
543
+ </div>
544
+ </InfoField>
545
+ </div>
546
+ )}
547
+
548
+ {/* Row 2: Cron Job ID + Created + Updated */}
549
+ <div className="grid grid-cols-3 gap-4">
550
+ <InfoField label="Cron Job ID">
551
+ <span className="text-caption font-mono" style={{ color: "var(--text-secondary)" }}>
552
+ {task.openclawCronJobId ?? "N/A"}
553
+ </span>
554
+ </InfoField>
555
+
556
+ <InfoField label="Created">
557
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
558
+ {task.createdAt ? formatTimestamp(task.createdAt) : "N/A"}
559
+ </span>
560
+ </InfoField>
561
+
562
+ <InfoField label="Updated">
563
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
564
+ {task.updatedAt ? formatTimestamp(task.updatedAt) : "N/A"}
565
+ </span>
566
+ </InfoField>
567
+ </div>
568
+
569
+ {/* Row 3: Prompt / Instructions (full width) */}
570
+ <div>
571
+ <div className="flex items-center justify-between mb-1">
572
+ <span className="text-tiny uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>
573
+ Prompt / Instructions
574
+ </span>
575
+ {editingField !== "instructions" && (
576
+ <button
577
+ onClick={() => startEdit("instructions")}
578
+ className="text-tiny px-2 py-0.5 rounded-sm transition-colors hover:bg-[--bg-hover]"
579
+ style={{ color: "var(--text-link)" }}
580
+ >
581
+ Edit
582
+ </button>
583
+ )}
584
+ </div>
585
+
586
+ {editingField === "instructions" ? (
587
+ <div>
588
+ <textarea
589
+ ref={editRef as React.RefObject<HTMLTextAreaElement>}
590
+ value={editValue}
591
+ onChange={(e) => setEditValue(e.target.value)}
592
+ onKeyDown={(e) => {
593
+ if (e.key === "Escape") cancelEdit();
594
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
595
+ e.preventDefault();
596
+ saveEdit();
597
+ }
598
+ }}
599
+ placeholder="Enter the prompt or instructions for this cron task..."
600
+ rows={6}
601
+ className="w-full text-caption p-3 rounded-md resize-y focus:outline-none"
602
+ style={{
603
+ background: "var(--bg-hover)",
604
+ color: "var(--text-primary)",
605
+ border: "1px solid var(--bg-active)",
606
+ minHeight: 80,
607
+ maxHeight: 300,
608
+ }}
609
+ />
610
+ <div className="flex items-center justify-between mt-2">
611
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>
612
+ Cmd/Ctrl+Enter to save, Esc to cancel
613
+ </span>
614
+ <SaveCancelButtons saving={saving} onSave={saveEdit} onCancel={cancelEdit} />
615
+ </div>
616
+ </div>
617
+ ) : (
618
+ <div
619
+ className="text-caption p-3 rounded-md whitespace-pre-wrap cursor-pointer hover:border-[--text-muted] transition-colors"
620
+ style={{
621
+ background: "var(--bg-hover)",
622
+ color: task.instructions ? "var(--text-primary)" : "var(--text-muted)",
623
+ border: "1px solid transparent",
624
+ minHeight: 48,
625
+ }}
626
+ onClick={() => startEdit("instructions")}
627
+ title="Click to edit"
628
+ >
629
+ {task.instructions || "No instructions set. Click to add a prompt for this cron task."}
630
+ </div>
631
+ )}
632
+ </div>
633
+ </div>
634
+ )}
635
+ </div>
636
+
637
+ {/* ---- Content area: job history + chat ---- */}
638
+ <div className="flex-1 flex min-h-0">
639
+ {/* Job list panel */}
640
+ <div
641
+ className="overflow-y-auto flex-shrink-0"
642
+ style={{
643
+ width: 220,
644
+ borderRight: "1px solid var(--border)",
645
+ }}
646
+ >
647
+ <div className="px-3 py-2" style={{ borderBottom: "1px solid var(--border)" }}>
648
+ <span className="text-tiny uppercase tracking-wider font-bold" style={{ color: "var(--text-muted)" }}>
649
+ Execution History
650
+ </span>
651
+ <span className="text-tiny ml-1" style={{ color: "var(--text-muted)" }}>
652
+ ({state.cronJobs.length})
653
+ </span>
654
+ </div>
655
+ {state.cronJobs.length === 0 ? (
656
+ <div className="px-4 py-6 text-center">
657
+ <p className="text-tiny" style={{ color: "var(--text-muted)" }}>
658
+ No runs yet.
659
+ </p>
660
+ <p className="text-tiny mt-1" style={{ color: "var(--text-muted)" }}>
661
+ Waiting for schedule...
662
+ </p>
663
+ </div>
664
+ ) : (
665
+ state.cronJobs.map((job, idx) => {
666
+ const colors = statusColors(job.status);
667
+ const displayNum = job.number || state.cronJobs.length - idx;
668
+ const isSelected = state.selectedCronJobId === job.id;
669
+ return (
670
+ <button
671
+ key={job.id}
672
+ onClick={() => handleSelectJob(job.id)}
673
+ className={`w-full text-left px-3 py-2 hover:bg-[--bg-hover] transition-colors ${
674
+ isSelected ? "bg-[--bg-hover]" : ""
675
+ }`}
676
+ style={{
677
+ borderBottom: "1px solid var(--border)",
678
+ ...(isSelected ? { borderLeft: "3px solid var(--bg-active)" } : {}),
679
+ }}
680
+ >
681
+ <div className="flex items-center justify-between">
682
+ <span className="text-tiny font-mono" style={{ color: "var(--text-muted)" }}>
683
+ #{displayNum}
684
+ </span>
685
+ <span
686
+ className="text-tiny px-1.5 py-0.5 rounded-sm font-bold flex items-center gap-1"
687
+ style={{ background: colors.bg, color: colors.fg }}
688
+ >
689
+ {job.status === "running" && (
690
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)" }} />
691
+ )}
692
+ {statusLabel(job.status)}
693
+ </span>
694
+ </div>
695
+ <div className="text-tiny mt-0.5" style={{ color: "var(--text-muted)" }}>
696
+ {job.time}
697
+ {job.durationMs != null && (
698
+ <span className="ml-1">({(job.durationMs / 1000).toFixed(1)}s)</span>
699
+ )}
700
+ </div>
701
+ {job.summary && (
702
+ <div className="text-caption mt-1 truncate" style={{ color: "var(--text-secondary)" }}>
703
+ {job.summary}
704
+ </div>
705
+ )}
706
+ </button>
707
+ );
708
+ })
709
+ )}
710
+ </div>
711
+
712
+ {/* Job output detail */}
713
+ <JobOutputPanel jobs={state.cronJobs} selectedJobId={state.selectedCronJobId} />
714
+ </div>
715
+ </div>
716
+ );
717
+ }
718
+
719
+ // ---- Job output panel ----
720
+
721
+ // Shared prose styles for markdown rendering
722
+ const PROSE_CLASSES = `prose prose-sm max-w-none
723
+ prose-p:my-1.5 prose-ul:my-1.5 prose-ol:my-1.5
724
+ prose-pre:my-2 prose-pre:rounded-md prose-pre:text-caption
725
+ prose-code:before:content-none prose-code:after:content-none
726
+ prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-caption
727
+ prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5
728
+ prose-h1:text-lg prose-h2:text-base prose-h3:text-sm
729
+ prose-li:my-0.5
730
+ prose-blockquote:border-l-2 prose-blockquote:pl-4 prose-blockquote:my-2
731
+ prose-hr:my-4`;
732
+
733
+ const PROSE_STYLE: React.CSSProperties = {
734
+ color: "var(--text-primary)",
735
+ "--tw-prose-headings": "var(--text-primary)",
736
+ "--tw-prose-bold": "var(--text-primary)",
737
+ "--tw-prose-links": "var(--text-link)",
738
+ "--tw-prose-code": "var(--text-primary)",
739
+ "--tw-prose-pre-code": "var(--text-primary)",
740
+ "--tw-prose-pre-bg": "var(--code-bg)",
741
+ "--tw-prose-th-borders": "var(--border)",
742
+ "--tw-prose-td-borders": "var(--border)",
743
+ "--tw-prose-quotes": "var(--text-secondary)",
744
+ "--tw-prose-quote-borders": "var(--border)",
745
+ "--tw-prose-bullets": "var(--text-muted)",
746
+ "--tw-prose-counters": "var(--text-muted)",
747
+ "--tw-prose-hr": "var(--border)",
748
+ } as React.CSSProperties;
749
+
750
+ /** Split streaming output by the --- separator into message blocks */
751
+ function parseMessageBlocks(summary: string): string[] {
752
+ return summary.split(/\n\n---\n\n/).filter((b) => b.trim());
753
+ }
754
+
755
+ function JobOutputPanel({
756
+ jobs,
757
+ selectedJobId,
758
+ }: {
759
+ jobs: Array<{ id: string; number: number; status: string; startedAt: number; finishedAt: number | null; durationMs: number | null; summary: string; time: string }>;
760
+ selectedJobId: string | null;
761
+ }) {
762
+ const job = selectedJobId ? jobs.find((j) => j.id === selectedJobId) : null;
763
+ const scrollRef = useRef<HTMLDivElement>(null);
764
+
765
+ // Auto-scroll to bottom when streaming output updates
766
+ useEffect(() => {
767
+ if (scrollRef.current && job?.status === "running") {
768
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
769
+ }
770
+ }, [job?.summary, job?.status]);
771
+
772
+ if (!job) {
773
+ return (
774
+ <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
775
+ <p className="text-caption" style={{ color: "var(--text-muted)" }}>
776
+ {jobs.length > 0 ? "Select a run to view output" : "No execution history yet"}
777
+ </p>
778
+ </div>
779
+ );
780
+ }
781
+
782
+ const colors = statusColors(job.status);
783
+ const isRunning = job.status === "running";
784
+ const blocks = job.summary ? parseMessageBlocks(job.summary) : [];
785
+
786
+ return (
787
+ <div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--bg-surface)" }}>
788
+ {/* Job header bar */}
789
+ <div
790
+ className="flex items-center gap-3 px-5 py-2.5 flex-shrink-0"
791
+ style={{ borderBottom: "1px solid var(--border)" }}
792
+ >
793
+ <span
794
+ className="text-tiny px-2 py-0.5 rounded-sm font-bold"
795
+ style={{ background: colors.bg, color: colors.fg }}
796
+ >
797
+ {statusLabel(job.status)}
798
+ </span>
799
+ {isRunning && (
800
+ <span className="inline-block w-2 h-2 rounded-full animate-pulse" style={{ background: "var(--text-link)" }} />
801
+ )}
802
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
803
+ {job.time}
804
+ </span>
805
+ {job.durationMs != null && (
806
+ <span className="text-caption" style={{ color: "var(--text-muted)" }}>
807
+ {job.durationMs >= 60000
808
+ ? `${(job.durationMs / 60000).toFixed(1)}m`
809
+ : `${(job.durationMs / 1000).toFixed(1)}s`}
810
+ </span>
811
+ )}
812
+ </div>
813
+
814
+ {/* Output content */}
815
+ <div className="flex-1 overflow-y-auto px-5 py-4" ref={scrollRef}>
816
+ {blocks.length > 0 ? (
817
+ /* Stacked message cards */
818
+ <div className="flex flex-col gap-3">
819
+ {blocks.map((block, idx) => {
820
+ const isLast = idx === blocks.length - 1;
821
+ const isStreaming = isRunning && isLast;
822
+ return (
823
+ <div
824
+ key={idx}
825
+ className="rounded-md px-4 py-3"
826
+ style={{
827
+ background: "var(--bg-primary)",
828
+ border: isStreaming
829
+ ? "1px solid var(--text-link)"
830
+ : "1px solid var(--border)",
831
+ }}
832
+ >
833
+ {/* Card header: message number + streaming indicator */}
834
+ <div className="flex items-center gap-2 mb-2">
835
+ <span
836
+ className="text-tiny font-bold px-1.5 py-0.5 rounded-sm"
837
+ style={{
838
+ background: isStreaming ? "var(--text-link)" : "var(--bg-surface)",
839
+ color: isStreaming ? "#fff" : "var(--text-muted)",
840
+ }}
841
+ >
842
+ {blocks.length > 1 ? `#${idx + 1}` : "Output"}
843
+ </span>
844
+ {isStreaming && (
845
+ <div className="flex items-center gap-1">
846
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)" }} />
847
+ <span className="text-tiny" style={{ color: "var(--text-link)" }}>streaming...</span>
848
+ </div>
849
+ )}
850
+ {!isStreaming && !isRunning && blocks.length > 1 && (
851
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>completed</span>
852
+ )}
853
+ </div>
854
+ {/* Card body: rendered markdown */}
855
+ <div className={PROSE_CLASSES} style={PROSE_STYLE}>
856
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{block}</ReactMarkdown>
857
+ </div>
858
+ </div>
859
+ );
860
+ })}
861
+ {/* Typing indicator after last card while running */}
862
+ {isRunning && (
863
+ <div className="flex items-center gap-1.5 pl-2">
864
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)" }} />
865
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)", animationDelay: "0.2s" }} />
866
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)", animationDelay: "0.4s" }} />
867
+ </div>
868
+ )}
869
+ </div>
870
+ ) : isRunning ? (
871
+ /* Running but no output yet — just typing dots, header already shows RUN status */
872
+ <div className="flex items-center gap-1.5 py-2 pl-1">
873
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)" }} />
874
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)", animationDelay: "0.2s" }} />
875
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)", animationDelay: "0.4s" }} />
876
+ </div>
877
+ ) : (
878
+ <div className="flex items-center justify-center h-full">
879
+ <p className="text-caption" style={{ color: "var(--text-muted)" }}>
880
+ No output recorded for this run.
881
+ </p>
882
+ </div>
883
+ )}
884
+ </div>
885
+ </div>
886
+ );
887
+ }
888
+
889
+ // ---- Reusable sub-components ----
890
+
891
+ function InfoField({ label, children }: { label: string; children: React.ReactNode }) {
892
+ return (
893
+ <div className="min-w-0">
894
+ <div className="text-tiny uppercase tracking-wider mb-1" style={{ color: "var(--text-muted)" }}>
895
+ {label}
896
+ </div>
897
+ {children}
898
+ </div>
899
+ );
900
+ }
901
+
902
+ function EditableValue({ value, empty, onClick }: { value: string; empty?: boolean; onClick: () => void }) {
903
+ return (
904
+ <span
905
+ className="text-body cursor-pointer hover:underline"
906
+ style={{ color: empty ? "var(--text-muted)" : "var(--text-primary)" }}
907
+ onClick={onClick}
908
+ title="Click to edit"
909
+ >
910
+ {value}
911
+ </span>
912
+ );
913
+ }
914
+
915
+ function SaveCancelButtons({
916
+ saving,
917
+ onSave,
918
+ onCancel,
919
+ }: {
920
+ saving: boolean;
921
+ onSave: () => void;
922
+ onCancel: () => void;
923
+ }) {
924
+ return (
925
+ <div className="flex items-center gap-1 flex-shrink-0">
926
+ <button
927
+ onClick={onSave}
928
+ disabled={saving}
929
+ className="px-2 py-1 text-tiny font-bold text-white rounded-sm disabled:opacity-50"
930
+ style={{ background: "var(--bg-active)" }}
931
+ >
932
+ {saving ? "..." : "Save"}
933
+ </button>
934
+ <button
935
+ onClick={onCancel}
936
+ className="px-2 py-1 text-tiny rounded-sm"
937
+ style={{ color: "var(--text-muted)" }}
938
+ >
939
+ Cancel
940
+ </button>
941
+ </div>
942
+ );
943
+ }