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,827 @@
1
+ import React, { useReducer, useEffect, useCallback, useRef, useState } from "react";
2
+ import {
3
+ appReducer,
4
+ initialState,
5
+ AppStateContext,
6
+ AppDispatchContext,
7
+ type ChatMessage,
8
+ type AppState,
9
+ type ActiveView,
10
+ } from "./store";
11
+ import { getToken, setToken, agentsApi, channelsApi, tasksApi, jobsApi, authApi, messagesApi, modelsApi, meApi, sessionsApi, type ModelInfo } from "./api";
12
+ import { ModelSelect } from "./components/ModelSelect";
13
+ import { BotsChatWSClient, type WSMessage } from "./ws";
14
+ import { IconRail } from "./components/IconRail";
15
+ import { Sidebar } from "./components/Sidebar";
16
+ import { ChatWindow } from "./components/ChatWindow";
17
+ import { ThreadPanel } from "./components/ThreadPanel";
18
+ import { JobList } from "./components/JobList";
19
+ import { LoginPage } from "./components/LoginPage";
20
+ import { DebugLogPanel } from "./components/DebugLogPanel";
21
+ import { CronSidebar } from "./components/CronSidebar";
22
+ import { CronDetail } from "./components/CronDetail";
23
+ import { dlog } from "./debug-log";
24
+
25
+ export default function App() {
26
+ const [state, dispatch] = useReducer(appReducer, initialState, (init): AppState => {
27
+ // Restore last active view from localStorage
28
+ try {
29
+ const savedView = localStorage.getItem("botschat_active_view");
30
+ if (savedView === "messages" || savedView === "automations") {
31
+ return { ...init, activeView: savedView as ActiveView };
32
+ }
33
+ } catch { /* ignore */ }
34
+ return init;
35
+ });
36
+ const wsClientRef = useRef<BotsChatWSClient | null>(null);
37
+ const handleWSMessageRef = useRef<(msg: WSMessage) => void>(() => {});
38
+
39
+ const [showSettings, setShowSettings] = useState(false);
40
+
41
+ // Theme state – default to system preference then dark
42
+ const [theme, setTheme] = useState<"dark" | "light">(() => {
43
+ const saved = localStorage.getItem("botschat_theme");
44
+ if (saved === "light" || saved === "dark") return saved;
45
+ return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
46
+ });
47
+
48
+ useEffect(() => {
49
+ document.documentElement.setAttribute("data-theme", theme);
50
+ localStorage.setItem("botschat_theme", theme);
51
+ }, [theme]);
52
+
53
+ // Persist active view (messages / automations)
54
+ useEffect(() => {
55
+ localStorage.setItem("botschat_active_view", state.activeView);
56
+ }, [state.activeView]);
57
+
58
+ // Persist selected cron task for automations view
59
+ useEffect(() => {
60
+ if (state.selectedCronTaskId) {
61
+ localStorage.setItem("botschat_last_cron_task", state.selectedCronTaskId);
62
+ }
63
+ }, [state.selectedCronTaskId]);
64
+
65
+ // Persist selected session per channel
66
+ useEffect(() => {
67
+ if (state.selectedSessionId) {
68
+ const agent = state.agents.find((a) => a.id === state.selectedAgentId);
69
+ if (agent?.channelId) {
70
+ localStorage.setItem(`botschat_last_session_${agent.channelId}`, state.selectedSessionId);
71
+ }
72
+ }
73
+ }, [state.selectedSessionId, state.selectedAgentId, state.agents]);
74
+
75
+ const toggleTheme = useCallback(() => {
76
+ setTheme((t) => (t === "dark" ? "light" : "dark"));
77
+ }, []);
78
+
79
+ // ---- Auto-login on mount ----
80
+ useEffect(() => {
81
+ const token = getToken();
82
+ if (token) {
83
+ dlog.api("Auth", "Auto-login with stored token");
84
+ authApi
85
+ .me()
86
+ .then((user) => {
87
+ dlog.info("Auth", `Logged in as ${user.email} (${user.id})`);
88
+ dispatch({ type: "SET_USER", user });
89
+ if (user.settings?.defaultModel) {
90
+ dispatch({ type: "SET_DEFAULT_MODEL", model: user.settings.defaultModel });
91
+ }
92
+ })
93
+ .catch((err) => {
94
+ dlog.warn("Auth", `Auto-login failed: ${err}`);
95
+ setToken(null);
96
+ });
97
+ }
98
+ }, []);
99
+
100
+ // Models are delivered via WS (connection.status) on browser auth.
101
+ // Fallback: fetch from REST if WS didn't deliver models within 2s.
102
+ useEffect(() => {
103
+ if (!state.user) return;
104
+ const timer = setTimeout(() => {
105
+ if (state.models.length === 0) {
106
+ modelsApi.list().then(({ models }) => {
107
+ if (models.length > 0) dispatch({ type: "SET_MODELS", models });
108
+ }).catch(() => {});
109
+ }
110
+ }, 2000);
111
+ return () => clearTimeout(timer);
112
+ }, [state.user, state.models.length]);
113
+
114
+ // ---- Load agents (default + channel agents) when user is set ----
115
+ useEffect(() => {
116
+ if (state.user) {
117
+ dlog.api("Agents", "Loading agents list");
118
+ agentsApi.list().then(({ agents }) => {
119
+ dlog.info("Agents", `Loaded ${agents.length} agents`, agents.map((a) => ({ id: a.id, name: a.name, channelId: a.channelId })));
120
+ dispatch({ type: "SET_AGENTS", agents });
121
+ if (agents.length > 0 && !state.selectedAgentId) {
122
+ // Restore last selected channel from localStorage if available
123
+ let target = agents[0];
124
+ try {
125
+ const lastId = localStorage.getItem("botschat_last_agent");
126
+ if (lastId) {
127
+ const found = agents.find((a) => a.id === lastId);
128
+ if (found) {
129
+ dlog.info("Agents", `Restoring last channel: ${found.name} (${found.id})`);
130
+ target = found;
131
+ }
132
+ }
133
+ } catch { /* ignore */ }
134
+ dispatch({
135
+ type: "SELECT_AGENT",
136
+ agentId: target.id,
137
+ sessionKey: target.sessionKey,
138
+ });
139
+ }
140
+ });
141
+ dlog.api("Channels", "Loading channels list");
142
+ channelsApi.list().then(({ channels }) => {
143
+ dlog.info("Channels", `Loaded ${channels.length} channels`, channels.map((c) => ({ id: c.id, name: c.name })));
144
+ dispatch({ type: "SET_CHANNELS", channels });
145
+ });
146
+ }
147
+ }, [state.user]);
148
+
149
+ // ---- Load cron tasks when switching to automations view ----
150
+ useEffect(() => {
151
+ if (state.user && state.activeView === "automations") {
152
+ dlog.api("Cron", "Loading all background tasks + scan data");
153
+ // Load D1 task metadata AND OpenClaw scan data in parallel
154
+ Promise.all([
155
+ tasksApi.listAll("background"),
156
+ tasksApi.scanData(),
157
+ ]).then(([{ tasks }, { tasks: scanTasks }]) => {
158
+ dlog.info("Cron", `Loaded ${tasks.length} cron tasks + ${scanTasks.length} scan entries`);
159
+ dispatch({ type: "SET_CRON_TASKS", cronTasks: tasks });
160
+ // Merge OpenClaw-owned fields (schedule/instructions/model)
161
+ dispatch({
162
+ type: "MERGE_SCAN_DATA",
163
+ scanTasks: scanTasks.map((t) => ({
164
+ cronJobId: t.cronJobId,
165
+ schedule: t.schedule,
166
+ instructions: t.instructions,
167
+ model: t.model || undefined,
168
+ enabled: t.enabled,
169
+ })),
170
+ });
171
+ if (tasks.length > 0 && !state.selectedCronTaskId) {
172
+ // Restore last selected cron task from localStorage if available
173
+ let targetTaskId = tasks[0].id;
174
+ try {
175
+ const lastId = localStorage.getItem("botschat_last_cron_task");
176
+ if (lastId) {
177
+ const found = tasks.find((t) => t.id === lastId);
178
+ if (found) {
179
+ dlog.info("Cron", `Restoring last cron task: ${found.name} (${found.id})`);
180
+ targetTaskId = found.id;
181
+ }
182
+ }
183
+ } catch { /* ignore */ }
184
+ dispatch({ type: "SELECT_CRON_TASK", taskId: targetTaskId });
185
+ }
186
+ });
187
+ }
188
+ }, [state.user, state.activeView]);
189
+
190
+ // ---- When agent changes (or switching back to messages view), load sessions ----
191
+ // Derive channelId so the effect re-runs when the default agent gets a channelId
192
+ const selectedAgentChannelId = state.agents.find((a) => a.id === state.selectedAgentId)?.channelId;
193
+ // Include activeView so sessions + sessionKey are restored after automations view
194
+ // (SELECT_CRON_TASK / SELECT_CRON_JOB overwrite the shared selectedSessionKey)
195
+ const isMessagesView = state.activeView === "messages";
196
+
197
+ useEffect(() => {
198
+ // Only load sessions when in messages view
199
+ if (!isMessagesView) return;
200
+
201
+ if (!state.selectedAgentId) {
202
+ dispatch({ type: "SET_TASKS", tasks: [] });
203
+ dispatch({ type: "SELECT_TASK", taskId: null });
204
+ dispatch({ type: "SET_SESSIONS", sessions: [] });
205
+ return;
206
+ }
207
+
208
+ const agent = state.agents.find((a) => a.id === state.selectedAgentId);
209
+ if (agent?.channelId) {
210
+ // Load tasks (for non-default channel agents)
211
+ if (!agent.isDefault) {
212
+ tasksApi.list(agent.channelId).then(({ tasks }) => {
213
+ dispatch({ type: "SET_TASKS", tasks });
214
+ if (tasks.length > 0) {
215
+ const first = tasks[0];
216
+ dispatch({
217
+ type: "SELECT_TASK",
218
+ taskId: first.id,
219
+ // Don't set sessionKey here — sessions will handle it
220
+ });
221
+ }
222
+ });
223
+ } else {
224
+ dispatch({ type: "SET_TASKS", tasks: [] });
225
+ dispatch({ type: "SELECT_TASK", taskId: null });
226
+ }
227
+
228
+ // Load sessions for this channel (all agents including default)
229
+ sessionsApi.list(agent.channelId).then(({ sessions }) => {
230
+ dlog.info("Sessions", `Loaded ${sessions.length} sessions for channel ${agent.channelId}`);
231
+ dispatch({ type: "SET_SESSIONS", sessions });
232
+ if (sessions.length > 0) {
233
+ // Restore last selected session from localStorage if available
234
+ let target = sessions[0];
235
+ try {
236
+ const lastId = localStorage.getItem(`botschat_last_session_${agent.channelId}`);
237
+ if (lastId) {
238
+ const found = sessions.find((s) => s.id === lastId);
239
+ if (found) {
240
+ dlog.info("Sessions", `Restoring last session: ${found.name} (${found.id})`);
241
+ target = found;
242
+ }
243
+ }
244
+ } catch { /* ignore */ }
245
+ dispatch({
246
+ type: "SELECT_SESSION",
247
+ sessionId: target.id,
248
+ sessionKey: target.sessionKey,
249
+ });
250
+ }
251
+ }).catch((err) => {
252
+ dlog.error("Sessions", `Failed to load sessions: ${err}`);
253
+ });
254
+ } else {
255
+ dispatch({ type: "SET_TASKS", tasks: [] });
256
+ dispatch({ type: "SELECT_TASK", taskId: null });
257
+ dispatch({ type: "SET_SESSIONS", sessions: [] });
258
+ }
259
+ }, [state.selectedAgentId, selectedAgentChannelId, isMessagesView]);
260
+
261
+ // ---- Load jobs when a background task is selected ----
262
+ useEffect(() => {
263
+ if (!state.selectedTaskId) return;
264
+ const task = state.tasks.find((t) => t.id === state.selectedTaskId);
265
+ if (!task || task.kind !== "background") {
266
+ dispatch({ type: "SET_JOBS", jobs: [] });
267
+ return;
268
+ }
269
+ const agent = state.agents.find((a) => a.id === state.selectedAgentId);
270
+ if (!agent?.channelId) return;
271
+
272
+ jobsApi
273
+ .list(agent.channelId, task.id)
274
+ .then(({ jobs }) => {
275
+ dispatch({ type: "SET_JOBS", jobs });
276
+ // Auto-select the most recent job
277
+ if (jobs.length > 0 && !state.selectedJobId) {
278
+ dispatch({
279
+ type: "SELECT_JOB",
280
+ jobId: jobs[0].id,
281
+ sessionKey: jobs[0].sessionKey,
282
+ });
283
+ }
284
+ })
285
+ .catch((err) => {
286
+ console.error("Failed to load jobs:", err);
287
+ });
288
+ }, [state.selectedTaskId, state.tasks]);
289
+
290
+ // ---- Load message history when session changes ----
291
+ useEffect(() => {
292
+ if (!state.user || !state.selectedSessionKey) return;
293
+ let stale = false;
294
+ messagesApi
295
+ .list(state.user.id, state.selectedSessionKey)
296
+ .then(({ messages, replyCounts }) => {
297
+ // Guard against stale responses when the user rapidly switches channels:
298
+ // the cleanup function sets `stale = true` before the new effect runs.
299
+ if (!stale) {
300
+ dispatch({ type: "SET_MESSAGES", messages, replyCounts });
301
+ }
302
+ })
303
+ .catch((err) => {
304
+ console.error("Failed to load message history:", err);
305
+ });
306
+ return () => { stale = true; };
307
+ }, [state.user, state.selectedSessionKey]);
308
+
309
+ // Keep a ref to state for use in WS handler (avoids stale closures)
310
+ const stateRef = useRef(state);
311
+ useEffect(() => {
312
+ stateRef.current = state;
313
+ }, [state]);
314
+
315
+ // ---- WS message handler ----
316
+ const handleWSMessage = useCallback(
317
+ (msg: WSMessage) => {
318
+ const sessionKey = msg.sessionKey as string | undefined;
319
+ const threadId = (msg.threadId ?? msg.replyToId) as string | undefined;
320
+
321
+ // Log every incoming WS message
322
+ dlog.wsIn("WS", `${msg.type}`, msg);
323
+
324
+ // Helper: extract base sessionKey (strip ":thread:*" suffix) for comparison
325
+ const getBaseSessionKey = (sk: string | undefined): string | undefined => {
326
+ if (!sk) return undefined;
327
+ return sk.replace(/:thread:.+$/, "");
328
+ };
329
+
330
+ // Helper: check if an incoming message's sessionKey matches the currently
331
+ // viewed session. This prevents cron-task or other background-session
332
+ // messages from being injected into the wrong chat view (and potentially
333
+ // replacing the user's streaming reply via ADD_MESSAGE).
334
+ const isCurrentSession = (sk: string | undefined): boolean => {
335
+ if (!sk) return true; // no sessionKey → allow (e.g. status messages)
336
+ const base = getBaseSessionKey(sk);
337
+ return base === stateRef.current.selectedSessionKey;
338
+ };
339
+
340
+ switch (msg.type) {
341
+ case "connection.status":
342
+ dlog.info("Connection", `OpenClaw ${msg.openclawConnected ? "connected" : "disconnected"}${msg.defaultModel ? ` (default: ${msg.defaultModel})` : ""}`);
343
+ dispatch({
344
+ type: "SET_OPENCLAW_CONNECTED",
345
+ connected: msg.openclawConnected as boolean,
346
+ defaultModel: (msg.defaultModel as string) || undefined,
347
+ });
348
+ // Models are delivered alongside connection.status
349
+ if (Array.isArray(msg.models) && msg.models.length > 0) {
350
+ dispatch({ type: "SET_MODELS", models: msg.models as ModelInfo[] });
351
+ }
352
+ break;
353
+
354
+ case "openclaw.disconnected":
355
+ dlog.warn("Connection", "OpenClaw disconnected");
356
+ dispatch({ type: "SET_OPENCLAW_CONNECTED", connected: false });
357
+ break;
358
+
359
+ case "model.changed":
360
+ if (msg.model && msg.sessionKey) {
361
+ dlog.info("Model", `Session model changed to: ${msg.model} (session: ${msg.sessionKey})`);
362
+ dispatch({ type: "SET_SESSION_MODEL", model: msg.model as string });
363
+ // Persist per-session model to localStorage
364
+ try {
365
+ const stored = JSON.parse(localStorage.getItem("botschat:sessionModels") || "{}");
366
+ stored[msg.sessionKey as string] = msg.model;
367
+ localStorage.setItem("botschat:sessionModels", JSON.stringify(stored));
368
+ } catch { /* ignore */ }
369
+ }
370
+ break;
371
+
372
+ case "agent.stream.start":
373
+ // Only start streaming in the currently viewed session — otherwise
374
+ // background cron-task streams would inject a placeholder into the
375
+ // wrong chat, and the subsequent agent.text would replace the user's
376
+ // last message.
377
+ if (sessionKey && isCurrentSession(sessionKey)) {
378
+ // Detect thread streaming: threadId from message or extracted from sessionKey
379
+ const streamThreadId = threadId ?? sessionKey.match(/:thread:(.+)$/)?.[1];
380
+ dispatch({
381
+ type: "STREAM_START",
382
+ runId: msg.runId as string,
383
+ sessionKey,
384
+ threadId: streamThreadId,
385
+ });
386
+ }
387
+ break;
388
+
389
+ case "agent.stream.chunk":
390
+ // Already guarded by streamingRunId match in the reducer — if
391
+ // STREAM_START was skipped (different session), chunks are no-ops.
392
+ dispatch({
393
+ type: "STREAM_CHUNK",
394
+ runId: msg.runId as string,
395
+ sessionKey: sessionKey ?? "",
396
+ text: msg.text as string,
397
+ });
398
+ break;
399
+
400
+ case "agent.stream.end":
401
+ dispatch({
402
+ type: "STREAM_END",
403
+ runId: msg.runId as string,
404
+ });
405
+ break;
406
+
407
+ case "agent.text": {
408
+ // Skip messages for sessions we're not viewing — they'll be loaded
409
+ // from the server when the user navigates to that session.
410
+ if (!isCurrentSession(sessionKey)) break;
411
+ const chatMsg: ChatMessage = {
412
+ id: crypto.randomUUID(),
413
+ sender: "agent",
414
+ text: msg.text as string,
415
+ timestamp: Date.now(),
416
+ threadId,
417
+ };
418
+ if (threadId && sessionKey) {
419
+ dispatch({ type: "ADD_THREAD_MESSAGE", message: chatMsg });
420
+ } else {
421
+ dispatch({ type: "ADD_MESSAGE", message: chatMsg });
422
+ }
423
+ break;
424
+ }
425
+
426
+ case "agent.media": {
427
+ if (!isCurrentSession(sessionKey)) break;
428
+ const mediaMsg: ChatMessage = {
429
+ id: crypto.randomUUID(),
430
+ sender: "agent",
431
+ text: (msg.caption as string) ?? "",
432
+ mediaUrl: msg.mediaUrl as string,
433
+ timestamp: Date.now(),
434
+ threadId,
435
+ };
436
+ if (threadId && sessionKey) {
437
+ dispatch({ type: "ADD_THREAD_MESSAGE", message: mediaMsg });
438
+ } else {
439
+ dispatch({ type: "ADD_MESSAGE", message: mediaMsg });
440
+ }
441
+ break;
442
+ }
443
+
444
+ case "agent.a2ui": {
445
+ if (!isCurrentSession(sessionKey)) break;
446
+ const a2uiMsg: ChatMessage = {
447
+ id: crypto.randomUUID(),
448
+ sender: "agent",
449
+ text: "",
450
+ a2ui: msg.jsonl as string,
451
+ timestamp: Date.now(),
452
+ threadId,
453
+ };
454
+ if (threadId && sessionKey) {
455
+ dispatch({ type: "ADD_THREAD_MESSAGE", message: a2uiMsg });
456
+ } else {
457
+ dispatch({ type: "ADD_MESSAGE", message: a2uiMsg });
458
+ }
459
+ break;
460
+ }
461
+
462
+ case "job.update": {
463
+ // A background task job completed/updated
464
+ const job = {
465
+ id: msg.jobId as string,
466
+ number: 0,
467
+ sessionKey: msg.sessionKey as string,
468
+ status: msg.status as "running" | "ok" | "error" | "skipped",
469
+ startedAt: msg.startedAt as number,
470
+ finishedAt: (msg.finishedAt as number) ?? null,
471
+ durationMs: (msg.durationMs as number) ?? null,
472
+ summary: (msg.summary as string) ?? "",
473
+ time: new Date(((msg.startedAt as number) ?? 0) * 1000).toLocaleString(),
474
+ };
475
+
476
+ // Update Messages view jobs
477
+ if (job.status === "running") {
478
+ dispatch({ type: "ADD_JOB", job });
479
+ } else {
480
+ // Check if we already have this job (was "running", now finished)
481
+ const s = stateRef.current;
482
+ const existsInJobs = s.jobs.some((j) => j.id === job.id);
483
+ if (existsInJobs) {
484
+ // Update in place
485
+ dispatch({ type: "SET_JOBS", jobs: s.jobs.map((j) => j.id === job.id ? { ...j, ...job } : j) });
486
+ } else {
487
+ dispatch({ type: "ADD_JOB", job });
488
+ }
489
+ }
490
+
491
+ // Update Automations view cronJobs
492
+ if (job.status === "running") {
493
+ dispatch({ type: "ADD_CRON_JOB", job });
494
+ } else {
495
+ dispatch({ type: "UPDATE_CRON_JOB", job });
496
+ }
497
+ break;
498
+ }
499
+
500
+ case "job.output": {
501
+ // Streaming output from a running job — update the job's summary in real-time
502
+ const outputJobId = msg.jobId as string;
503
+ const outputText = msg.text as string;
504
+ if (outputJobId && outputText) {
505
+ dispatch({ type: "APPEND_JOB_OUTPUT", jobId: outputJobId, text: outputText });
506
+ }
507
+ break;
508
+ }
509
+
510
+ case "task.scan.result": {
511
+ // Task scan completed — backend may have auto-created a default channel
512
+ // for orphan cron jobs. Reload agents, channels, and basic task metadata.
513
+ const scannedTasks = (msg.tasks as Array<{
514
+ cronJobId: string;
515
+ name: string;
516
+ schedule: string;
517
+ agentId: string;
518
+ enabled: boolean;
519
+ instructions?: string;
520
+ model?: string;
521
+ }>) ?? [];
522
+ dlog.info("TaskScan", `Scan result: ${scannedTasks.length} tasks reported`, scannedTasks);
523
+
524
+ // Reload agents and channels (backend may have created new ones)
525
+ agentsApi.list().then(({ agents }) => {
526
+ dlog.info("TaskScan", `Reloaded ${agents.length} agents`);
527
+ dispatch({ type: "SET_AGENTS", agents });
528
+ });
529
+ channelsApi.list().then(({ channels }) => {
530
+ dlog.info("TaskScan", `Reloaded ${channels.length} channels`);
531
+ dispatch({ type: "SET_CHANNELS", channels });
532
+ });
533
+
534
+ // Reload basic task metadata from D1, then merge OpenClaw-owned
535
+ // fields (schedule, instructions, model) from the scan results.
536
+ tasksApi.listAll("background").then(({ tasks: cronTasks }) => {
537
+ dlog.info("TaskScan", `Reloaded ${cronTasks.length} cron tasks, merging scan data`);
538
+ dispatch({ type: "SET_CRON_TASKS", cronTasks });
539
+ // Merge schedule/instructions/model from scan results
540
+ dispatch({
541
+ type: "MERGE_SCAN_DATA",
542
+ scanTasks: scannedTasks.map((t) => ({
543
+ cronJobId: t.cronJobId,
544
+ schedule: t.schedule,
545
+ instructions: t.instructions ?? "",
546
+ model: t.model,
547
+ enabled: t.enabled,
548
+ })),
549
+ });
550
+ });
551
+
552
+ const s = stateRef.current;
553
+ if (s.selectedAgentId) {
554
+ const currentAgent = s.agents.find((a) => a.id === s.selectedAgentId);
555
+ if (currentAgent?.channelId) {
556
+ tasksApi.list(currentAgent.channelId).then(({ tasks }) => {
557
+ dispatch({ type: "SET_TASKS", tasks });
558
+ });
559
+ }
560
+ }
561
+ break;
562
+ }
563
+
564
+ case "status":
565
+ // Status pings carry the gateway default model, not the per-session model.
566
+ // model.changed is the authoritative source, so we intentionally ignore status.model.
567
+ break;
568
+
569
+ case "models.list":
570
+ if (Array.isArray(msg.models)) {
571
+ dispatch({ type: "SET_MODELS", models: msg.models as ModelInfo[] });
572
+ }
573
+ break;
574
+
575
+ case "task.schedule.ack":
576
+ if (msg.ok as boolean) {
577
+ dlog.info("Task", `Schedule applied to OpenClaw: ${msg.cronJobId}`);
578
+ } else {
579
+ dlog.error("Task", `Schedule push to OpenClaw failed: ${msg.error}`, msg);
580
+ // TODO: could revert optimistic update here
581
+ }
582
+ break;
583
+
584
+ case "error":
585
+ dlog.error("Server", msg.message as string, msg);
586
+ break;
587
+
588
+ default:
589
+ break;
590
+ }
591
+ },
592
+ [],
593
+ );
594
+
595
+ useEffect(() => {
596
+ handleWSMessageRef.current = handleWSMessage;
597
+ }, [handleWSMessage]);
598
+
599
+ // ---- WebSocket connection ----
600
+ useEffect(() => {
601
+ if (!state.user) return;
602
+
603
+ const token = getToken();
604
+ if (!token) return;
605
+
606
+ const sessionId = crypto.randomUUID();
607
+ dlog.info("WS", `Connecting WebSocket (session=${sessionId.slice(0, 8)}...)`);
608
+ const client = new BotsChatWSClient({
609
+ userId: state.user.id,
610
+ sessionId,
611
+ token,
612
+ onMessage: (msg) => {
613
+ handleWSMessageRef.current(msg);
614
+ },
615
+ onStatusChange: (connected) => {
616
+ dlog.info("WS", connected ? "WebSocket connected" : "WebSocket disconnected");
617
+ dispatch({ type: "SET_WS_CONNECTED", connected });
618
+ },
619
+ });
620
+
621
+ client.connect();
622
+ wsClientRef.current = client;
623
+
624
+ return () => {
625
+ client.disconnect();
626
+ wsClientRef.current = null;
627
+ };
628
+ }, [state.user]);
629
+
630
+ const sendMessage = useCallback((msg: WSMessage) => {
631
+ dlog.wsOut("WS", `${msg.type}`, msg);
632
+ wsClientRef.current?.send(msg);
633
+ }, []);
634
+
635
+ const handleDefaultModelChange = useCallback(async (modelId: string) => {
636
+ dispatch({ type: "SET_DEFAULT_MODEL", model: modelId || null });
637
+ try {
638
+ await meApi.updateSettings({ defaultModel: modelId || undefined });
639
+ } catch (err) {
640
+ console.error("Failed to update default model:", err);
641
+ }
642
+ }, []);
643
+
644
+ const handleSelectJob = useCallback(
645
+ (jobId: string) => {
646
+ const job = state.jobs.find((j) => j.id === jobId);
647
+ if (job) {
648
+ dispatch({
649
+ type: "SELECT_JOB",
650
+ jobId: job.id,
651
+ sessionKey: job.sessionKey || undefined,
652
+ });
653
+ }
654
+ },
655
+ [state.jobs],
656
+ );
657
+
658
+ // ---- Render ----
659
+ if (!state.user) {
660
+ return (
661
+ <AppStateContext.Provider value={state}>
662
+ <AppDispatchContext.Provider value={dispatch}>
663
+ <LoginPage />
664
+ </AppDispatchContext.Provider>
665
+ </AppStateContext.Provider>
666
+ );
667
+ }
668
+
669
+ const selectedAgent = state.agents.find((a) => a.id === state.selectedAgentId);
670
+ const selectedTask = state.tasks.find((t) => t.id === state.selectedTaskId);
671
+ const isBackgroundTask = selectedTask?.kind === "background";
672
+ const hasSession = Boolean(state.selectedSessionKey);
673
+
674
+ const isAutomationsView = state.activeView === "automations";
675
+
676
+ return (
677
+ <AppStateContext.Provider value={state}>
678
+ <AppDispatchContext.Provider value={dispatch}>
679
+ <div className="flex flex-col h-screen">
680
+ <div className="flex flex-1 min-h-0">
681
+ {/* Icon Rail (68px fixed) */}
682
+ <IconRail onToggleTheme={toggleTheme} onOpenSettings={() => setShowSettings(true)} theme={theme} />
683
+
684
+ {/* Sidebar (220px) — switches based on active view */}
685
+ {isAutomationsView ? <CronSidebar /> : <Sidebar />}
686
+
687
+ {/* Main content area (flex) */}
688
+ {isAutomationsView ? (
689
+ <CronDetail />
690
+ ) : (
691
+ <div className="flex-1 flex flex-col min-w-0">
692
+ {hasSession ? (
693
+ <>
694
+ <div className="flex-1 flex min-h-0">
695
+ {isBackgroundTask && (
696
+ <JobList
697
+ jobs={state.jobs}
698
+ selectedJobId={state.selectedJobId}
699
+ onSelectJob={handleSelectJob}
700
+ />
701
+ )}
702
+
703
+ <ChatWindow sendMessage={sendMessage} />
704
+
705
+ {/* Detail Panel (right side, conditional) */}
706
+ <ThreadPanel sendMessage={sendMessage} />
707
+ </div>
708
+ </>
709
+ ) : (
710
+ <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
711
+ <div className="text-center">
712
+ <svg
713
+ className="w-20 h-20 mx-auto mb-4"
714
+ fill="none"
715
+ viewBox="0 0 24 24"
716
+ stroke="currentColor"
717
+ strokeWidth={1}
718
+ style={{ color: "var(--text-muted)" }}
719
+ >
720
+ <path
721
+ strokeLinecap="round"
722
+ strokeLinejoin="round"
723
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
724
+ />
725
+ </svg>
726
+ <p className="text-body font-bold" style={{ color: "var(--text-muted)" }}>
727
+ Select a channel to get started
728
+ </p>
729
+ <p className="text-caption mt-1" style={{ color: "var(--text-muted)" }}>
730
+ Choose a channel from the sidebar
731
+ </p>
732
+ </div>
733
+ </div>
734
+ )}
735
+ </div>
736
+ )}
737
+ </div>
738
+
739
+ {/* Global debug log panel — collapsible at bottom */}
740
+ <DebugLogPanel />
741
+ </div>
742
+
743
+ {/* Settings modal */}
744
+ {showSettings && (
745
+ <div
746
+ className="fixed inset-0 flex items-center justify-center z-50"
747
+ style={{ background: "rgba(0,0,0,0.5)" }}
748
+ onClick={() => setShowSettings(false)}
749
+ >
750
+ <div
751
+ className="rounded-lg p-6 w-[420px] max-w-[90vw]"
752
+ style={{ background: "var(--bg-surface)", boxShadow: "var(--shadow-lg)" }}
753
+ onClick={(e) => e.stopPropagation()}
754
+ >
755
+ <div className="flex items-center justify-between mb-5">
756
+ <h2 className="text-h1 font-bold" style={{ color: "var(--text-primary)" }}>
757
+ Settings
758
+ </h2>
759
+ <button
760
+ onClick={() => setShowSettings(false)}
761
+ className="p-1 hover:bg-[--bg-hover] rounded"
762
+ style={{ color: "var(--text-muted)" }}
763
+ >
764
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
765
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
766
+ </svg>
767
+ </button>
768
+ </div>
769
+
770
+ <div className="space-y-5">
771
+ {/* Default Model */}
772
+ <div>
773
+ <label
774
+ className="block text-caption font-bold mb-1.5"
775
+ style={{ color: "var(--text-secondary)" }}
776
+ >
777
+ Default Model
778
+ </label>
779
+ <ModelSelect
780
+ value={state.defaultModel ?? ""}
781
+ onChange={handleDefaultModelChange}
782
+ models={state.models}
783
+ placeholder="Not set (use agent default)"
784
+ />
785
+ <p className="text-tiny mt-1.5" style={{ color: "var(--text-muted)" }}>
786
+ Default model for new conversations. You can override per session using{" "}
787
+ <code>/model</code> or per automation in its settings.
788
+ </p>
789
+ </div>
790
+
791
+ {/* Connection info */}
792
+ <div>
793
+ <label
794
+ className="block text-caption font-bold mb-1.5"
795
+ style={{ color: "var(--text-secondary)" }}
796
+ >
797
+ Current Session Model
798
+ </label>
799
+ <span
800
+ className="text-body font-mono"
801
+ style={{ color: (state.sessionModel || state.defaultModel) ? "var(--text-primary)" : "var(--text-muted)" }}
802
+ >
803
+ {state.sessionModel ?? state.defaultModel ?? "Not connected"}
804
+ </span>
805
+ </div>
806
+ </div>
807
+
808
+ <div
809
+ className="mt-6 pt-4 flex justify-end"
810
+ style={{ borderTop: "1px solid var(--border)" }}
811
+ >
812
+ <button
813
+ onClick={() => setShowSettings(false)}
814
+ className="px-4 py-1.5 text-caption font-bold text-white rounded-sm"
815
+ style={{ background: "var(--bg-active)" }}
816
+ >
817
+ Done
818
+ </button>
819
+ </div>
820
+ </div>
821
+ </div>
822
+ )}
823
+
824
+ </AppDispatchContext.Provider>
825
+ </AppStateContext.Provider>
826
+ );
827
+ }