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.
- package/LICENSE +201 -0
- package/README.md +213 -0
- package/migrations/0001_initial.sql +88 -0
- package/migrations/0002_rename_projects_to_channels.sql +53 -0
- package/migrations/0003_messages.sql +14 -0
- package/migrations/0004_jobs.sql +15 -0
- package/migrations/0005_deleted_cron_jobs.sql +6 -0
- package/migrations/0006_tasks_add_model.sql +2 -0
- package/migrations/0007_sessions.sql +25 -0
- package/migrations/0008_remove_openclaw_fields.sql +8 -0
- package/package.json +53 -0
- package/packages/api/package.json +17 -0
- package/packages/api/src/do/connection-do.ts +929 -0
- package/packages/api/src/env.ts +8 -0
- package/packages/api/src/index.ts +297 -0
- package/packages/api/src/routes/agents.ts +68 -0
- package/packages/api/src/routes/auth.ts +105 -0
- package/packages/api/src/routes/channels.ts +185 -0
- package/packages/api/src/routes/jobs.ts +65 -0
- package/packages/api/src/routes/models.ts +22 -0
- package/packages/api/src/routes/pairing.ts +76 -0
- package/packages/api/src/routes/projects.ts +177 -0
- package/packages/api/src/routes/sessions.ts +171 -0
- package/packages/api/src/routes/tasks.ts +375 -0
- package/packages/api/src/routes/upload.ts +52 -0
- package/packages/api/src/utils/auth.ts +101 -0
- package/packages/api/src/utils/id.ts +19 -0
- package/packages/api/tsconfig.json +18 -0
- package/packages/plugin/dist/index.d.ts +19 -0
- package/packages/plugin/dist/index.d.ts.map +1 -0
- package/packages/plugin/dist/index.js +17 -0
- package/packages/plugin/dist/index.js.map +1 -0
- package/packages/plugin/dist/src/accounts.d.ts +12 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
- package/packages/plugin/dist/src/accounts.js +103 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -0
- package/packages/plugin/dist/src/channel.d.ts +206 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -0
- package/packages/plugin/dist/src/channel.js +1248 -0
- package/packages/plugin/dist/src/channel.js.map +1 -0
- package/packages/plugin/dist/src/runtime.d.ts +3 -0
- package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
- package/packages/plugin/dist/src/runtime.js +18 -0
- package/packages/plugin/dist/src/runtime.js.map +1 -0
- package/packages/plugin/dist/src/types.d.ts +179 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -0
- package/packages/plugin/dist/src/types.js +6 -0
- package/packages/plugin/dist/src/types.js.map +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts +51 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
- package/packages/plugin/dist/src/ws-client.js +170 -0
- package/packages/plugin/dist/src/ws-client.js.map +1 -0
- package/packages/plugin/openclaw.plugin.json +11 -0
- package/packages/plugin/package.json +39 -0
- package/packages/plugin/tsconfig.json +20 -0
- package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
- package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
- package/packages/web/dist/index.html +17 -0
- package/packages/web/index.html +16 -0
- package/packages/web/package.json +29 -0
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/src/App.tsx +827 -0
- package/packages/web/src/api.ts +242 -0
- package/packages/web/src/components/ChatWindow.tsx +864 -0
- package/packages/web/src/components/CronDetail.tsx +943 -0
- package/packages/web/src/components/CronSidebar.tsx +123 -0
- package/packages/web/src/components/DebugLogPanel.tsx +258 -0
- package/packages/web/src/components/IconRail.tsx +163 -0
- package/packages/web/src/components/JobList.tsx +120 -0
- package/packages/web/src/components/LoginPage.tsx +178 -0
- package/packages/web/src/components/MessageContent.tsx +1082 -0
- package/packages/web/src/components/ModelSelect.tsx +87 -0
- package/packages/web/src/components/ScheduleEditor.tsx +403 -0
- package/packages/web/src/components/SessionTabs.tsx +246 -0
- package/packages/web/src/components/Sidebar.tsx +331 -0
- package/packages/web/src/components/TaskBar.tsx +413 -0
- package/packages/web/src/components/ThreadPanel.tsx +212 -0
- package/packages/web/src/debug-log.ts +58 -0
- package/packages/web/src/index.css +170 -0
- package/packages/web/src/main.tsx +10 -0
- package/packages/web/src/store.ts +492 -0
- package/packages/web/src/ws.ts +99 -0
- package/packages/web/tailwind.config.js +65 -0
- package/packages/web/tsconfig.json +18 -0
- package/packages/web/vite.config.ts +20 -0
- package/scripts/dev.sh +122 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|