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,413 @@
1
+ import React, { useState } from "react";
2
+ import { useAppState, useAppDispatch } from "../store";
3
+ import { tasksApi, type Task } from "../api";
4
+
5
+ const SCHEDULE_PRESETS = [
6
+ { label: "Every 30 min", value: "every 30m" },
7
+ { label: "Every hour", value: "every 1h" },
8
+ { label: "Every 6 hours", value: "every 6h" },
9
+ { label: "Daily at 9am", value: "cron 0 9 * * *" },
10
+ { label: "Daily at 6pm", value: "cron 0 18 * * *" },
11
+ { label: "Twice daily", value: "cron 0 9,18 * * *" },
12
+ { label: "Weekly Monday 9am", value: "cron 0 9 * * 1" },
13
+ { label: "Custom", value: "" },
14
+ ];
15
+
16
+ /** Task bar for channel-based agents – sits below the channel header */
17
+ export function TaskBar() {
18
+ const state = useAppState();
19
+ const dispatch = useAppDispatch();
20
+ const [showCreate, setShowCreate] = useState(false);
21
+ const [newTaskName, setNewTaskName] = useState("");
22
+ const [newTaskKind, setNewTaskKind] = useState<"adhoc" | "background">("adhoc");
23
+ const [newSchedule, setNewSchedule] = useState("");
24
+ const [newInstructions, setNewInstructions] = useState("");
25
+ const [editingTask, setEditingTask] = useState<Task | null>(null);
26
+ const [editSchedule, setEditSchedule] = useState("");
27
+ const [editInstructions, setEditInstructions] = useState("");
28
+ const [editEnabled, setEditEnabled] = useState(true);
29
+
30
+ const agent = state.agents.find((a) => a.id === state.selectedAgentId);
31
+ const channel = agent?.channelId
32
+ ? state.channels.find((ch) => ch.id === agent.channelId)
33
+ : null;
34
+ if (!channel) return null;
35
+
36
+ const handleCreate = async () => {
37
+ if (!newTaskName.trim()) return;
38
+ try {
39
+ await tasksApi.create(channel.id, {
40
+ name: newTaskName,
41
+ kind: newTaskKind,
42
+ schedule: newTaskKind === "background" ? newSchedule : undefined,
43
+ instructions: newTaskKind === "background" ? newInstructions : undefined,
44
+ });
45
+ const { tasks } = await tasksApi.list(channel.id);
46
+ dispatch({ type: "SET_TASKS", tasks });
47
+ setShowCreate(false);
48
+ setNewTaskName("");
49
+ setNewSchedule("");
50
+ setNewInstructions("");
51
+ } catch (err) {
52
+ console.error("Failed to create task:", err);
53
+ }
54
+ };
55
+
56
+ const handleOpenConfig = (t: Task, e: React.MouseEvent) => {
57
+ e.stopPropagation();
58
+ setEditingTask(t);
59
+ setEditSchedule(t.schedule ?? "");
60
+ setEditInstructions(t.instructions ?? "");
61
+ setEditEnabled(t.enabled);
62
+ };
63
+
64
+ const handleSaveConfig = async () => {
65
+ if (!editingTask) return;
66
+ try {
67
+ await tasksApi.update(channel.id, editingTask.id, {
68
+ schedule: editSchedule,
69
+ instructions: editInstructions,
70
+ enabled: editEnabled,
71
+ });
72
+ const { tasks } = await tasksApi.list(channel.id);
73
+ dispatch({ type: "SET_TASKS", tasks });
74
+ setEditingTask(null);
75
+ } catch (err) {
76
+ console.error("Failed to update task:", err);
77
+ }
78
+ };
79
+
80
+ const handleToggleEnabled = async (t: Task, e: React.MouseEvent) => {
81
+ e.stopPropagation();
82
+ try {
83
+ await tasksApi.update(channel.id, t.id, { enabled: !t.enabled });
84
+ const { tasks } = await tasksApi.list(channel.id);
85
+ dispatch({ type: "SET_TASKS", tasks });
86
+ } catch (err) {
87
+ console.error("Failed to toggle task:", err);
88
+ }
89
+ };
90
+
91
+ const handleDeleteTask = async (t: Task, e: React.MouseEvent) => {
92
+ e.stopPropagation();
93
+ if (!confirm(`Delete task "${t.name}"?`)) return;
94
+ try {
95
+ await tasksApi.delete(channel.id, t.id);
96
+ const { tasks } = await tasksApi.list(channel.id);
97
+ dispatch({ type: "SET_TASKS", tasks });
98
+ if (state.selectedTaskId === t.id && tasks.length > 0) {
99
+ dispatch({ type: "SELECT_TASK", taskId: tasks[0].id, sessionKey: tasks[0].sessionKey });
100
+ }
101
+ } catch (err) {
102
+ console.error("Failed to delete task:", err);
103
+ }
104
+ };
105
+
106
+ return (
107
+ <>
108
+ <div
109
+ className="flex items-center gap-1 px-4 py-1.5 overflow-x-auto"
110
+ style={{
111
+ background: "var(--bg-surface)",
112
+ borderBottom: "1px solid var(--border)",
113
+ }}
114
+ >
115
+ {state.tasks.map((t) => (
116
+ <div key={t.id} className="flex items-center group">
117
+ <button
118
+ onClick={() =>
119
+ dispatch({
120
+ type: "SELECT_TASK",
121
+ taskId: t.id,
122
+ sessionKey: t.sessionKey ?? undefined,
123
+ })
124
+ }
125
+ className={`flex items-center gap-1.5 px-3 py-1 text-caption rounded-lg whitespace-nowrap transition-colors ${
126
+ state.selectedTaskId === t.id
127
+ ? "font-bold"
128
+ : "hover:bg-[--bg-hover]"
129
+ }`}
130
+ style={
131
+ state.selectedTaskId === t.id
132
+ ? {
133
+ background: "var(--bg-hover)",
134
+ color: "var(--text-primary)",
135
+ boxShadow: "inset 0 -2px 0 var(--bg-active)",
136
+ }
137
+ : { color: "var(--text-secondary)" }
138
+ }
139
+ >
140
+ {t.kind === "background" ? (
141
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
142
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
143
+ </svg>
144
+ ) : (
145
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
146
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
147
+ </svg>
148
+ )}
149
+ {t.name}
150
+ {t.kind === "background" && !t.enabled && (
151
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>(paused)</span>
152
+ )}
153
+ {t.kind === "background" && t.schedule && t.enabled && (
154
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>
155
+ {t.schedule}
156
+ </span>
157
+ )}
158
+ </button>
159
+ {/* Config and toggle buttons for background tasks */}
160
+ {t.kind === "background" && state.selectedTaskId === t.id && (
161
+ <div className="flex items-center gap-0.5 ml-0.5">
162
+ <button
163
+ onClick={(e) => handleOpenConfig(t, e)}
164
+ title="Configure schedule"
165
+ className="p-1 rounded hover:bg-[--bg-hover] transition-colors"
166
+ style={{ color: "var(--text-muted)" }}
167
+ >
168
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
169
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
170
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
171
+ </svg>
172
+ </button>
173
+ <button
174
+ onClick={(e) => handleToggleEnabled(t, e)}
175
+ title={t.enabled ? "Pause task" : "Resume task"}
176
+ className="p-1 rounded hover:bg-[--bg-hover] transition-colors"
177
+ style={{ color: t.enabled ? "var(--accent-green)" : "var(--accent-yellow)" }}
178
+ >
179
+ {t.enabled ? (
180
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
181
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
182
+ </svg>
183
+ ) : (
184
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
185
+ <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
186
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
187
+ </svg>
188
+ )}
189
+ </button>
190
+ </div>
191
+ )}
192
+ </div>
193
+ ))}
194
+
195
+ {/* Add task */}
196
+ {showCreate ? (
197
+ <div className="flex items-center gap-1 ml-1">
198
+ <input
199
+ type="text"
200
+ placeholder="Task name"
201
+ value={newTaskName}
202
+ onChange={(e) => setNewTaskName(e.target.value)}
203
+ onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
204
+ className="px-2 py-1 text-caption rounded-sm focus:outline-none w-36 placeholder:text-[--text-muted]"
205
+ style={{
206
+ background: "var(--bg-hover)",
207
+ color: "var(--text-primary)",
208
+ border: "1px solid var(--border)",
209
+ }}
210
+ autoFocus
211
+ />
212
+ <select
213
+ value={newTaskKind}
214
+ onChange={(e) => setNewTaskKind(e.target.value as "adhoc" | "background")}
215
+ className="px-1 py-1 text-tiny rounded-sm"
216
+ style={{
217
+ background: "var(--bg-hover)",
218
+ color: "var(--text-primary)",
219
+ border: "1px solid var(--border)",
220
+ }}
221
+ >
222
+ <option value="adhoc">Ad Hoc</option>
223
+ <option value="background">Background</option>
224
+ </select>
225
+ <button
226
+ onClick={handleCreate}
227
+ className="px-2 py-1 text-tiny font-bold text-white rounded-sm"
228
+ style={{ background: "var(--bg-active)" }}
229
+ >
230
+ Add
231
+ </button>
232
+ <button
233
+ onClick={() => { setShowCreate(false); setNewTaskName(""); setNewSchedule(""); setNewInstructions(""); }}
234
+ className="px-1 py-1 text-tiny"
235
+ style={{ color: "var(--text-muted)" }}
236
+ >
237
+ x
238
+ </button>
239
+ </div>
240
+ ) : (
241
+ <button
242
+ onClick={() => setShowCreate(true)}
243
+ className="flex items-center gap-1 px-3 py-1 text-caption hover:bg-[--bg-hover] rounded-lg whitespace-nowrap transition-colors"
244
+ style={{ color: "var(--text-muted)" }}
245
+ >
246
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
247
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
248
+ </svg>
249
+ Add
250
+ </button>
251
+ )}
252
+ </div>
253
+
254
+ {/* Schedule config strip for new background task */}
255
+ {showCreate && newTaskKind === "background" && (
256
+ <div
257
+ className="px-4 py-2 flex flex-wrap items-center gap-2"
258
+ style={{ background: "var(--bg-surface)", borderBottom: "1px solid var(--border)" }}
259
+ >
260
+ <select
261
+ value={SCHEDULE_PRESETS.find((p) => p.value === newSchedule) ? newSchedule : ""}
262
+ onChange={(e) => setNewSchedule(e.target.value)}
263
+ className="px-2 py-1 text-caption rounded-sm"
264
+ style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
265
+ >
266
+ <option value="" disabled>Schedule...</option>
267
+ {SCHEDULE_PRESETS.map((p) => (
268
+ <option key={p.value || "custom"} value={p.value}>{p.label}</option>
269
+ ))}
270
+ </select>
271
+ {(!SCHEDULE_PRESETS.find((p) => p.value === newSchedule) || newSchedule === "") && (
272
+ <input
273
+ type="text"
274
+ placeholder="e.g., every 6h or cron 0 */6 * * *"
275
+ value={newSchedule}
276
+ onChange={(e) => setNewSchedule(e.target.value)}
277
+ className="px-2 py-1 text-caption rounded-sm focus:outline-none flex-1 min-w-[200px] placeholder:text-[--text-muted]"
278
+ style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
279
+ />
280
+ )}
281
+ <input
282
+ type="text"
283
+ placeholder="Agent instructions per run..."
284
+ value={newInstructions}
285
+ onChange={(e) => setNewInstructions(e.target.value)}
286
+ className="px-2 py-1 text-caption rounded-sm focus:outline-none flex-1 min-w-[200px] placeholder:text-[--text-muted]"
287
+ style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
288
+ />
289
+ </div>
290
+ )}
291
+
292
+ {/* Task config editor modal */}
293
+ {editingTask && (
294
+ <div
295
+ className="fixed inset-0 flex items-center justify-center z-50"
296
+ style={{ background: "rgba(0,0,0,0.5)" }}
297
+ onClick={() => setEditingTask(null)}
298
+ >
299
+ <div
300
+ className="rounded-lg p-5 w-[480px] max-w-[90vw]"
301
+ style={{ background: "var(--bg-surface)", boxShadow: "var(--shadow-lg)" }}
302
+ onClick={(e) => e.stopPropagation()}
303
+ >
304
+ <div className="flex items-center justify-between mb-4">
305
+ <h2 className="text-h2 font-bold" style={{ color: "var(--text-primary)" }}>
306
+ Configure: {editingTask.name}
307
+ </h2>
308
+ <button
309
+ onClick={() => setEditingTask(null)}
310
+ className="p-1 hover:bg-[--bg-hover] rounded"
311
+ style={{ color: "var(--text-muted)" }}
312
+ >
313
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
314
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
315
+ </svg>
316
+ </button>
317
+ </div>
318
+
319
+ <div className="space-y-3">
320
+ {/* Schedule */}
321
+ <div>
322
+ <label className="block text-caption font-bold mb-1" style={{ color: "var(--text-secondary)" }}>
323
+ Schedule
324
+ </label>
325
+ <select
326
+ value={SCHEDULE_PRESETS.find((p) => p.value === editSchedule) ? editSchedule : ""}
327
+ onChange={(e) => setEditSchedule(e.target.value)}
328
+ className="w-full px-3 py-2 text-body rounded-md mb-1"
329
+ style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
330
+ >
331
+ <option value="">Custom</option>
332
+ {SCHEDULE_PRESETS.filter((p) => p.value).map((p) => (
333
+ <option key={p.value} value={p.value}>{p.label}</option>
334
+ ))}
335
+ </select>
336
+ <input
337
+ type="text"
338
+ placeholder="e.g., every 6h or cron 0 */6 * * *"
339
+ value={editSchedule}
340
+ onChange={(e) => setEditSchedule(e.target.value)}
341
+ className="w-full px-3 py-2 text-body rounded-md focus:outline-none placeholder:text-[--text-muted]"
342
+ style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
343
+ />
344
+ </div>
345
+
346
+ {/* Instructions */}
347
+ <div>
348
+ <label className="block text-caption font-bold mb-1" style={{ color: "var(--text-secondary)" }}>
349
+ Agent Instructions (per run)
350
+ </label>
351
+ <textarea
352
+ placeholder="What should the agent do each time this task runs?"
353
+ value={editInstructions}
354
+ onChange={(e) => setEditInstructions(e.target.value)}
355
+ rows={4}
356
+ className="w-full px-3 py-2 text-body rounded-md focus:outline-none resize-y placeholder:text-[--text-muted]"
357
+ style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
358
+ />
359
+ </div>
360
+
361
+ {/* Enabled toggle */}
362
+ <div className="flex items-center gap-3">
363
+ <label className="text-caption font-bold" style={{ color: "var(--text-secondary)" }}>
364
+ Enabled
365
+ </label>
366
+ <button
367
+ onClick={() => setEditEnabled(!editEnabled)}
368
+ className="relative w-10 h-5 rounded-full transition-colors"
369
+ style={{ background: editEnabled ? "var(--accent-green)" : "var(--border)" }}
370
+ >
371
+ <div
372
+ className="absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform"
373
+ style={{ left: editEnabled ? 20 : 2 }}
374
+ />
375
+ </button>
376
+ <span className="text-caption" style={{ color: "var(--text-muted)" }}>
377
+ {editEnabled ? "Running on schedule" : "Paused"}
378
+ </span>
379
+ </div>
380
+ </div>
381
+
382
+ {/* Actions */}
383
+ <div className="flex items-center justify-between mt-5 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
384
+ <button
385
+ onClick={(e) => { handleDeleteTask(editingTask, e); setEditingTask(null); }}
386
+ className="px-3 py-1.5 text-caption rounded-md hover:bg-[--bg-hover] transition-colors"
387
+ style={{ color: "var(--accent-red)" }}
388
+ >
389
+ Delete task
390
+ </button>
391
+ <div className="flex gap-2">
392
+ <button
393
+ onClick={() => setEditingTask(null)}
394
+ className="px-4 py-1.5 text-caption rounded-md hover:bg-[--bg-hover] transition-colors"
395
+ style={{ color: "var(--text-secondary)" }}
396
+ >
397
+ Cancel
398
+ </button>
399
+ <button
400
+ onClick={handleSaveConfig}
401
+ className="px-4 py-1.5 text-caption font-bold text-white rounded-md"
402
+ style={{ background: "var(--bg-active)" }}
403
+ >
404
+ Save
405
+ </button>
406
+ </div>
407
+ </div>
408
+ </div>
409
+ </div>
410
+ )}
411
+ </>
412
+ );
413
+ }
@@ -0,0 +1,212 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { useAppState, useAppDispatch, type ChatMessage } from "../store";
3
+ import { messagesApi } from "../api";
4
+ import type { WSMessage } from "../ws";
5
+ import { MessageContent } from "./MessageContent";
6
+ import { dlog } from "../debug-log";
7
+
8
+ type ThreadPanelProps = {
9
+ sendMessage: (msg: WSMessage) => void;
10
+ };
11
+
12
+ /** Detail Panel (section 5.5) – slides in from right */
13
+ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
14
+ const state = useAppState();
15
+ const dispatch = useAppDispatch();
16
+ const [input, setInput] = useState("");
17
+
18
+ // Load thread message history when a thread is opened
19
+ useEffect(() => {
20
+ if (!state.activeThreadId || !state.selectedSessionKey || !state.user) return;
21
+ const threadSessionKey = `${state.selectedSessionKey}:thread:${state.activeThreadId}`;
22
+ dlog.info("Thread", `Loading history for thread ${state.activeThreadId}`);
23
+ messagesApi
24
+ .list(state.user.id, threadSessionKey, state.activeThreadId)
25
+ .then(({ messages }) => {
26
+ dlog.info("Thread", `Loaded ${messages.length} thread messages`);
27
+ if (messages.length > 0) {
28
+ dispatch({ type: "OPEN_THREAD", threadId: state.activeThreadId!, messages });
29
+ }
30
+ })
31
+ .catch((err) => {
32
+ dlog.error("Thread", `Failed to load thread history: ${err}`);
33
+ });
34
+ }, [state.activeThreadId]);
35
+
36
+ if (!state.activeThreadId) return null;
37
+
38
+ const parentMessage = state.messages.find(
39
+ (m) => m.id === state.activeThreadId,
40
+ );
41
+
42
+ const handleSend = () => {
43
+ if (!input.trim() || !state.selectedSessionKey) return;
44
+
45
+ const trimmed = input.trim();
46
+ const threadSessionKey = `${state.selectedSessionKey}:thread:${state.activeThreadId}`;
47
+ dlog.info("Thread", `Send reply: ${trimmed.length > 120 ? trimmed.slice(0, 120) + "…" : trimmed}`, { threadId: state.activeThreadId });
48
+
49
+ const msg: ChatMessage = {
50
+ id: crypto.randomUUID(),
51
+ sender: "user",
52
+ text: trimmed,
53
+ timestamp: Date.now(),
54
+ threadId: state.activeThreadId ?? undefined,
55
+ };
56
+
57
+ dispatch({ type: "ADD_THREAD_MESSAGE", message: msg });
58
+
59
+ sendMessage({
60
+ type: "user.message",
61
+ sessionKey: threadSessionKey,
62
+ text: trimmed,
63
+ userId: state.user?.id ?? "",
64
+ messageId: msg.id,
65
+ });
66
+
67
+ setInput("");
68
+ };
69
+
70
+ return (
71
+ <div
72
+ className="flex flex-col h-full"
73
+ style={{
74
+ width: 420,
75
+ minWidth: 320,
76
+ background: "var(--bg-surface)",
77
+ borderLeft: "1px solid var(--border)",
78
+ }}
79
+ >
80
+ {/* Header */}
81
+ <div
82
+ className="flex items-center justify-between px-4"
83
+ style={{ height: 44, borderBottom: "1px solid var(--border)" }}
84
+ >
85
+ <h3 className="text-h1" style={{ color: "var(--text-primary)" }}>Thread</h3>
86
+ <button
87
+ onClick={() => dispatch({ type: "CLOSE_THREAD" })}
88
+ className="p-1 rounded hover:bg-[--bg-hover] transition-colors"
89
+ style={{ color: "var(--text-secondary)" }}
90
+ aria-label="Close thread"
91
+ >
92
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
93
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
94
+ </svg>
95
+ </button>
96
+ </div>
97
+
98
+ {/* Scrollable area: parent message + replies */}
99
+ <div className="flex-1 overflow-y-auto">
100
+ {/* Parent message */}
101
+ {parentMessage && (
102
+ <div className="px-5 py-3" style={{ borderBottom: "1px solid var(--border)" }}>
103
+ <div className="flex gap-2">
104
+ <div
105
+ className="w-9 h-9 rounded flex-shrink-0 flex items-center justify-center text-white text-caption font-bold"
106
+ style={{ background: parentMessage.sender === "user" ? "#9B59B6" : "#2BAC76" }}
107
+ >
108
+ {parentMessage.sender === "user" ? "U" : "A"}
109
+ </div>
110
+ <div className="flex-1 min-w-0">
111
+ <div className="flex items-baseline gap-2 mb-0.5">
112
+ <span className="text-h2" style={{ color: "var(--text-primary)" }}>
113
+ {parentMessage.sender === "user" ? "You" : "OpenClaw Agent"}
114
+ </span>
115
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
116
+ {new Date(parentMessage.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
117
+ </span>
118
+ </div>
119
+ <MessageContent text={parentMessage.text} mediaUrl={parentMessage.mediaUrl} />
120
+ </div>
121
+ </div>
122
+ </div>
123
+ )}
124
+
125
+ {/* Reply count divider */}
126
+ <div className="px-5 py-2" style={{ borderBottom: "1px solid var(--border)" }}>
127
+ <span className="text-caption font-bold" style={{ color: "var(--text-link)" }}>
128
+ {state.threadMessages.length} {state.threadMessages.length === 1 ? "reply" : "replies"}
129
+ </span>
130
+ </div>
131
+
132
+ {/* Thread replies – flat rows like main content */}
133
+ {state.threadMessages.map((msg, i) => {
134
+ const prevMsg = i > 0 ? state.threadMessages[i - 1] : null;
135
+ const isGrouped = prevMsg?.sender === msg.sender
136
+ && (msg.timestamp - prevMsg.timestamp) < 300000;
137
+
138
+ return (
139
+ <div
140
+ key={msg.id}
141
+ className="px-5 hover:bg-[--bg-hover] transition-colors"
142
+ style={{ paddingTop: isGrouped ? 2 : 8, paddingBottom: 2 }}
143
+ >
144
+ <div className="flex gap-2">
145
+ <div className="flex-shrink-0" style={{ width: 36 }}>
146
+ {!isGrouped && (
147
+ <div
148
+ className="w-9 h-9 rounded flex items-center justify-center text-white text-caption font-bold"
149
+ style={{ background: msg.sender === "user" ? "#9B59B6" : "#2BAC76" }}
150
+ >
151
+ {msg.sender === "user" ? "U" : "A"}
152
+ </div>
153
+ )}
154
+ </div>
155
+ <div className="flex-1 min-w-0">
156
+ {!isGrouped && (
157
+ <div className="flex items-baseline gap-2 mb-0.5">
158
+ <span className="text-h2" style={{ color: "var(--text-primary)" }}>
159
+ {msg.sender === "user" ? "You" : "OpenClaw Agent"}
160
+ </span>
161
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
162
+ {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
163
+ </span>
164
+ </div>
165
+ )}
166
+ <MessageContent
167
+ text={msg.text}
168
+ mediaUrl={msg.mediaUrl}
169
+ a2ui={msg.a2ui}
170
+ />
171
+ </div>
172
+ </div>
173
+ </div>
174
+ );
175
+ })}
176
+ </div> {/* end scrollable area */}
177
+
178
+ {/* Thread composer */}
179
+ <div className="px-4 pb-3 pt-2">
180
+ <div
181
+ className="rounded-md"
182
+ style={{ border: "1px solid var(--border)", background: "var(--bg-surface)" }}
183
+ >
184
+ <textarea
185
+ value={input}
186
+ onChange={(e) => setInput(e.target.value)}
187
+ onKeyDown={(e) => {
188
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
189
+ e.preventDefault();
190
+ handleSend();
191
+ }
192
+ }}
193
+ placeholder="Reply…"
194
+ rows={1}
195
+ className="w-full px-3 py-2 text-body bg-transparent resize-none focus:outline-none placeholder:text-[--text-muted]"
196
+ style={{ color: "var(--text-primary)", minHeight: 36 }}
197
+ />
198
+ <div className="flex justify-end px-3 pb-2">
199
+ <button
200
+ onClick={handleSend}
201
+ disabled={!input.trim()}
202
+ className="px-3 py-1 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
203
+ style={{ background: "var(--bg-active)" }}
204
+ >
205
+ Send
206
+ </button>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Global debug log — captures WS messages, API calls, state changes, errors.
3
+ * Subscribers are notified on every new entry so React can re-render.
4
+ */
5
+
6
+ export type LogLevel = "info" | "warn" | "error" | "ws-in" | "ws-out" | "api";
7
+
8
+ export type LogEntry = {
9
+ id: number;
10
+ ts: number;
11
+ level: LogLevel;
12
+ tag: string;
13
+ message: string;
14
+ detail?: string; // collapsed JSON / extra info
15
+ };
16
+
17
+ const MAX_ENTRIES = 500;
18
+ let _nextId = 1;
19
+ const _entries: LogEntry[] = [];
20
+ const _listeners = new Set<() => void>();
21
+
22
+ export function addLog(level: LogLevel, tag: string, message: string, detail?: unknown): void {
23
+ const entry: LogEntry = {
24
+ id: _nextId++,
25
+ ts: Date.now(),
26
+ level,
27
+ tag,
28
+ message,
29
+ detail: detail !== undefined ? (typeof detail === "string" ? detail : JSON.stringify(detail, null, 2)) : undefined,
30
+ };
31
+ _entries.push(entry);
32
+ if (_entries.length > MAX_ENTRIES) _entries.splice(0, _entries.length - MAX_ENTRIES);
33
+ for (const fn of _listeners) fn();
34
+ }
35
+
36
+ export function getLogEntries(): readonly LogEntry[] {
37
+ return _entries;
38
+ }
39
+
40
+ export function clearLog(): void {
41
+ _entries.length = 0;
42
+ for (const fn of _listeners) fn();
43
+ }
44
+
45
+ export function subscribeLog(fn: () => void): () => void {
46
+ _listeners.add(fn);
47
+ return () => _listeners.delete(fn);
48
+ }
49
+
50
+ // Convenience helpers
51
+ export const dlog = {
52
+ info: (tag: string, msg: string, detail?: unknown) => addLog("info", tag, msg, detail),
53
+ warn: (tag: string, msg: string, detail?: unknown) => addLog("warn", tag, msg, detail),
54
+ error: (tag: string, msg: string, detail?: unknown) => addLog("error", tag, msg, detail),
55
+ wsIn: (tag: string, msg: string, detail?: unknown) => addLog("ws-in", tag, msg, detail),
56
+ wsOut: (tag: string, msg: string, detail?: unknown) => addLog("ws-out", tag, msg, detail),
57
+ api: (tag: string, msg: string, detail?: unknown) => addLog("api", tag, msg, detail),
58
+ };