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,170 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ========== Dark Theme ========== */
6
+ [data-theme="dark"] {
7
+ --bg-primary: #1A1D21;
8
+ --bg-secondary: #19171D;
9
+ --bg-surface: #222529;
10
+ --bg-hover: #2C2F33;
11
+ --bg-active: #1164A3;
12
+
13
+ --text-primary: #D1D2D3;
14
+ --text-secondary: #ABABAD;
15
+ --text-muted: #6B6F76;
16
+ --text-link: #1D9BD1;
17
+ --text-sidebar: #CFC3CF;
18
+ --text-sidebar-active: #FFFFFF;
19
+
20
+ --border: #35373B;
21
+ --code-bg: #2D2D2D;
22
+ --code-text: #E06C75;
23
+
24
+ --sidebar-border: rgba(255,255,255,0.1);
25
+ --sidebar-hover: rgba(255,255,255,0.1);
26
+ --sidebar-divider: rgba(255,255,255,0.2);
27
+
28
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
29
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.4);
30
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.5);
31
+ }
32
+
33
+ /* ========== Light Theme ========== */
34
+ [data-theme="light"] {
35
+ --bg-primary: #FFFFFF;
36
+ --bg-secondary: #F7F7F8;
37
+ --bg-surface: #FFFFFF;
38
+ --bg-hover: #F0F0F0;
39
+ --bg-active: #1264A3;
40
+
41
+ --text-primary: #1D1C1D;
42
+ --text-secondary: #616061;
43
+ --text-muted: #868686;
44
+ --text-link: #1264A3;
45
+ --text-sidebar: #616061;
46
+ --text-sidebar-active: #1D1C1D;
47
+
48
+ --border: #DDDDDD;
49
+ --sidebar-border: rgba(0,0,0,0.1);
50
+ --sidebar-hover: rgba(0,0,0,0.06);
51
+ --sidebar-divider: rgba(0,0,0,0.12);
52
+ --code-bg: #F0F0F0;
53
+ --code-text: #D72B3F;
54
+
55
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
56
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.15);
57
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.2);
58
+ }
59
+
60
+ /* ========== Cross-theme constants ========== */
61
+ :root {
62
+ --accent-green: #2BAC76;
63
+ --accent-yellow: #E8A230;
64
+ --accent-red: #E01E5A;
65
+
66
+ --space-unit: 4px;
67
+ --radius-sm: 4px;
68
+ --radius-md: 8px;
69
+ --radius-lg: 12px;
70
+
71
+ --font-sans: "Lato", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
72
+ --font-mono: "SF Mono", "Consolas", "Monaco", monospace;
73
+ --font-size-base: 15px;
74
+ --line-height-base: 1.46;
75
+ }
76
+
77
+ /* ========== Base styles ========== */
78
+ body {
79
+ margin: 0;
80
+ font-family: var(--font-sans);
81
+ font-size: var(--font-size-base);
82
+ line-height: var(--line-height-base);
83
+ background: var(--bg-surface);
84
+ color: var(--text-primary);
85
+ }
86
+
87
+ /* Scrollbar */
88
+ ::-webkit-scrollbar { width: 6px; }
89
+ ::-webkit-scrollbar-track { background: transparent; }
90
+ ::-webkit-scrollbar-thumb { border-radius: 3px; }
91
+
92
+ [data-theme="dark"] ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); }
93
+ [data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }
94
+ [data-theme="light"] ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); }
95
+ [data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
96
+
97
+ /* Icon Rail + Sidebar scrollbar – follows theme */
98
+ [data-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); }
99
+ [data-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }
100
+ [data-theme="light"] .sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); }
101
+ [data-theme="light"] .sidebar-scroll::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
102
+
103
+ /* Inline code */
104
+ code:not(pre code) {
105
+ background: var(--code-bg);
106
+ color: var(--code-text);
107
+ font-family: var(--font-mono);
108
+ padding: 2px 5px;
109
+ border-radius: 3px;
110
+ font-size: 0.85em;
111
+ }
112
+
113
+ /* ========== Syntax highlighting (highlight.js) ========== */
114
+ /* Adapted from GitHub Dark / GitHub Light for theme compatibility */
115
+ [data-theme="dark"] .hljs { color: #c9d1d9; }
116
+ [data-theme="dark"] .hljs-keyword,
117
+ [data-theme="dark"] .hljs-selector-tag { color: #ff7b72; }
118
+ [data-theme="dark"] .hljs-string,
119
+ [data-theme="dark"] .hljs-addition { color: #a5d6ff; }
120
+ [data-theme="dark"] .hljs-comment,
121
+ [data-theme="dark"] .hljs-quote { color: #8b949e; font-style: italic; }
122
+ [data-theme="dark"] .hljs-number,
123
+ [data-theme="dark"] .hljs-literal { color: #79c0ff; }
124
+ [data-theme="dark"] .hljs-built_in,
125
+ [data-theme="dark"] .hljs-type { color: #ffa657; }
126
+ [data-theme="dark"] .hljs-title,
127
+ [data-theme="dark"] .hljs-function { color: #d2a8ff; }
128
+ [data-theme="dark"] .hljs-attr,
129
+ [data-theme="dark"] .hljs-attribute { color: #79c0ff; }
130
+ [data-theme="dark"] .hljs-variable,
131
+ [data-theme="dark"] .hljs-template-variable { color: #ffa657; }
132
+ [data-theme="dark"] .hljs-selector-class { color: #7ee787; }
133
+ [data-theme="dark"] .hljs-deletion { color: #ffa198; background: rgba(248,81,73,0.1); }
134
+ [data-theme="dark"] .hljs-meta { color: #79c0ff; }
135
+
136
+ [data-theme="light"] .hljs { color: #24292f; }
137
+ [data-theme="light"] .hljs-keyword,
138
+ [data-theme="light"] .hljs-selector-tag { color: #cf222e; }
139
+ [data-theme="light"] .hljs-string,
140
+ [data-theme="light"] .hljs-addition { color: #0a3069; }
141
+ [data-theme="light"] .hljs-comment,
142
+ [data-theme="light"] .hljs-quote { color: #6e7781; font-style: italic; }
143
+ [data-theme="light"] .hljs-number,
144
+ [data-theme="light"] .hljs-literal { color: #0550ae; }
145
+ [data-theme="light"] .hljs-built_in,
146
+ [data-theme="light"] .hljs-type { color: #953800; }
147
+ [data-theme="light"] .hljs-title,
148
+ [data-theme="light"] .hljs-function { color: #8250df; }
149
+ [data-theme="light"] .hljs-attr,
150
+ [data-theme="light"] .hljs-attribute { color: #0550ae; }
151
+ [data-theme="light"] .hljs-variable,
152
+ [data-theme="light"] .hljs-template-variable { color: #953800; }
153
+ [data-theme="light"] .hljs-selector-class { color: #116329; }
154
+ [data-theme="light"] .hljs-deletion { color: #82071e; background: rgba(255,129,130,0.1); }
155
+ [data-theme="light"] .hljs-meta { color: #0550ae; }
156
+
157
+ /* Hide scrollbar for skill buttons row */
158
+ .no-scrollbar::-webkit-scrollbar { display: none; }
159
+ .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
160
+
161
+ /* Focus visible */
162
+ *:focus-visible {
163
+ outline: 2px solid var(--bg-active);
164
+ outline-offset: 1px;
165
+ }
166
+
167
+ /* Transitions for theme switching */
168
+ body, .theme-transition {
169
+ transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
170
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import App from "./App";
4
+ import "./index.css";
5
+
6
+ ReactDOM.createRoot(document.getElementById("root")!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
@@ -0,0 +1,492 @@
1
+ /** Minimal reactive store using React context + useState. */
2
+
3
+ import { createContext, useContext } from "react";
4
+ import type { Agent as ApiAgent, Channel, Task, TaskWithChannel, Job, ModelInfo, Session } from "./api";
5
+
6
+ export type ChatMessage = {
7
+ id: string;
8
+ sender: "user" | "agent";
9
+ text: string;
10
+ timestamp: number;
11
+ mediaUrl?: string;
12
+ a2ui?: string; // A2UI JSONL data
13
+ threadId?: string;
14
+ isStreaming?: boolean; // true while streaming is in progress
15
+ /** Tracks which action blocks have been resolved, keyed by prompt hash */
16
+ resolvedActions?: Record<string, { value: string; label: string }>;
17
+ };
18
+
19
+ export type ActiveView = "messages" | "automations";
20
+
21
+ export type AppState = {
22
+ user: { id: string; email: string; displayName?: string | null } | null;
23
+ activeView: ActiveView;
24
+ agents: ApiAgent[];
25
+ selectedAgentId: string | null;
26
+ selectedSessionKey: string | null;
27
+ channels: Channel[];
28
+ selectedChannelId: string | null;
29
+ sessions: Session[];
30
+ selectedSessionId: string | null;
31
+ tasks: Task[];
32
+ selectedTaskId: string | null;
33
+ jobs: Job[];
34
+ selectedJobId: string | null;
35
+ messages: ChatMessage[];
36
+ threadMessages: ChatMessage[];
37
+ activeThreadId: string | null;
38
+ threadReplyCounts: Record<string, number>;
39
+ openclawConnected: boolean;
40
+ /** Per-session model override (set via /model or dropdown). null = using defaultModel. */
41
+ sessionModel: string | null;
42
+ wsConnected: boolean;
43
+ models: ModelInfo[];
44
+ /** Global default model from OpenClaw config (gateway primary). */
45
+ defaultModel: string | null;
46
+ // Streaming state — tracks in-progress streaming reply
47
+ streamingRunId: string | null;
48
+ streamingSessionKey: string | null;
49
+ streamingThreadId: string | null; // non-null when streaming into a thread
50
+ // Automations view state
51
+ cronTasks: TaskWithChannel[];
52
+ selectedCronTaskId: string | null;
53
+ cronJobs: Job[];
54
+ selectedCronJobId: string | null;
55
+ };
56
+
57
+ export const initialState: AppState = {
58
+ user: null,
59
+ activeView: "messages",
60
+ agents: [],
61
+ selectedAgentId: null,
62
+ selectedSessionKey: null,
63
+ channels: [],
64
+ selectedChannelId: null,
65
+ sessions: [],
66
+ selectedSessionId: null,
67
+ tasks: [],
68
+ selectedTaskId: null,
69
+ jobs: [],
70
+ selectedJobId: null,
71
+ messages: [],
72
+ threadMessages: [],
73
+ activeThreadId: null,
74
+ threadReplyCounts: {},
75
+ openclawConnected: false,
76
+ sessionModel: null,
77
+ wsConnected: false,
78
+ models: [],
79
+ defaultModel: null,
80
+ streamingRunId: null,
81
+ streamingSessionKey: null,
82
+ streamingThreadId: null,
83
+ cronTasks: [],
84
+ selectedCronTaskId: null,
85
+ cronJobs: [],
86
+ selectedCronJobId: null,
87
+ };
88
+
89
+ export type AppAction =
90
+ | { type: "SET_USER"; user: AppState["user"] }
91
+ | { type: "SET_ACTIVE_VIEW"; view: ActiveView }
92
+ | { type: "SET_AGENTS"; agents: ApiAgent[] }
93
+ | { type: "SELECT_AGENT"; agentId: string | null; sessionKey: string | null }
94
+ | { type: "SET_CHANNELS"; channels: Channel[] }
95
+ | { type: "SELECT_CHANNEL"; channelId: string | null }
96
+ | { type: "SET_SESSIONS"; sessions: Session[] }
97
+ | { type: "SELECT_SESSION"; sessionId: string | null; sessionKey?: string | null }
98
+ | { type: "ADD_SESSION"; session: Session }
99
+ | { type: "REMOVE_SESSION"; sessionId: string }
100
+ | { type: "RENAME_SESSION"; sessionId: string; name: string }
101
+ | { type: "SET_TASKS"; tasks: Task[] }
102
+ | { type: "SELECT_TASK"; taskId: string | null; sessionKey?: string | null }
103
+ | { type: "SET_JOBS"; jobs: Job[] }
104
+ | { type: "SELECT_JOB"; jobId: string | null; sessionKey?: string | null }
105
+ | { type: "ADD_JOB"; job: Job }
106
+ | { type: "ADD_MESSAGE"; message: ChatMessage }
107
+ | { type: "SET_MESSAGES"; messages: ChatMessage[]; replyCounts?: Record<string, number> }
108
+ | { type: "OPEN_THREAD"; threadId: string; messages: ChatMessage[] }
109
+ | { type: "CLOSE_THREAD" }
110
+ | { type: "ADD_THREAD_MESSAGE"; message: ChatMessage }
111
+ | { type: "SET_OPENCLAW_CONNECTED"; connected: boolean; defaultModel?: string | null }
112
+ | { type: "SET_SESSION_MODEL"; model: string | null }
113
+ | { type: "SET_WS_CONNECTED"; connected: boolean }
114
+ | { type: "SET_MODELS"; models: ModelInfo[] }
115
+ | { type: "SET_DEFAULT_MODEL"; model: string | null }
116
+ | { type: "SET_CRON_TASKS"; cronTasks: TaskWithChannel[] }
117
+ | { type: "MERGE_SCAN_DATA"; scanTasks: Array<{ cronJobId: string; schedule: string; instructions: string; model?: string; enabled: boolean }> }
118
+ | { type: "UPDATE_CRON_TASK"; taskId: string; updates: Partial<TaskWithChannel> }
119
+ | { type: "SELECT_CRON_TASK"; taskId: string | null }
120
+ | { type: "RESOLVE_ACTION"; messageId: string; promptHash: string; value: string; label: string }
121
+ | { type: "STREAM_START"; runId: string; sessionKey: string; threadId?: string }
122
+ | { type: "STREAM_CHUNK"; runId: string; sessionKey: string; text: string }
123
+ | { type: "STREAM_END"; runId: string }
124
+ | { type: "SET_CRON_JOBS"; cronJobs: Job[] }
125
+ | { type: "SELECT_CRON_JOB"; jobId: string | null; sessionKey?: string | null }
126
+ | { type: "ADD_CRON_JOB"; job: Job }
127
+ | { type: "UPDATE_CRON_JOB"; job: Job }
128
+ | { type: "APPEND_JOB_OUTPUT"; jobId: string; text: string }
129
+ | { type: "LOGOUT" };
130
+
131
+ export function appReducer(state: AppState, action: AppAction): AppState {
132
+ switch (action.type) {
133
+ case "SET_USER":
134
+ return { ...state, user: action.user };
135
+ case "SET_ACTIVE_VIEW":
136
+ return { ...state, activeView: action.view };
137
+ case "SET_AGENTS":
138
+ return { ...state, agents: action.agents };
139
+ case "SELECT_AGENT":
140
+ return {
141
+ ...state,
142
+ selectedAgentId: action.agentId,
143
+ selectedSessionKey: action.sessionKey,
144
+ sessions: [],
145
+ selectedSessionId: null,
146
+ messages: [],
147
+ jobs: [],
148
+ selectedJobId: null,
149
+ activeThreadId: null,
150
+ threadMessages: [],
151
+ };
152
+ case "SET_CHANNELS":
153
+ return { ...state, channels: action.channels };
154
+ case "SELECT_CHANNEL":
155
+ return { ...state, selectedChannelId: action.channelId, sessions: [], selectedSessionId: null, tasks: [], selectedTaskId: null, jobs: [], selectedJobId: null, messages: [] };
156
+ case "SET_SESSIONS":
157
+ return { ...state, sessions: action.sessions };
158
+ case "SELECT_SESSION":
159
+ return {
160
+ ...state,
161
+ selectedSessionId: action.sessionId,
162
+ selectedSessionKey: action.sessionKey ?? state.selectedSessionKey,
163
+ messages: [],
164
+ activeThreadId: null,
165
+ threadMessages: [],
166
+ };
167
+ case "ADD_SESSION":
168
+ return { ...state, sessions: [...state.sessions, action.session] };
169
+ case "REMOVE_SESSION":
170
+ return {
171
+ ...state,
172
+ sessions: state.sessions.filter((s) => s.id !== action.sessionId),
173
+ // If the removed session was selected, clear selection
174
+ ...(state.selectedSessionId === action.sessionId
175
+ ? { selectedSessionId: null, selectedSessionKey: null, messages: [] }
176
+ : {}),
177
+ };
178
+ case "RENAME_SESSION":
179
+ return {
180
+ ...state,
181
+ sessions: state.sessions.map((s) =>
182
+ s.id === action.sessionId ? { ...s, name: action.name } : s,
183
+ ),
184
+ };
185
+ case "SET_TASKS":
186
+ return { ...state, tasks: action.tasks };
187
+ case "SELECT_TASK": {
188
+ const nextSessionKey = action.sessionKey ?? state.selectedSessionKey;
189
+ const sessionChanged = nextSessionKey !== state.selectedSessionKey;
190
+ return {
191
+ ...state,
192
+ selectedTaskId: action.taskId,
193
+ selectedSessionKey: nextSessionKey,
194
+ // Only clear messages when the session actually changes;
195
+ // otherwise keep whatever was already loaded to avoid the
196
+ // race where SELECT_TASK arrives *after* SET_MESSAGES.
197
+ messages: sessionChanged ? [] : state.messages,
198
+ jobs: [],
199
+ selectedJobId: null,
200
+ };
201
+ }
202
+ case "SET_JOBS":
203
+ return { ...state, jobs: action.jobs };
204
+ case "SELECT_JOB":
205
+ return {
206
+ ...state,
207
+ selectedJobId: action.jobId,
208
+ selectedSessionKey: action.sessionKey ?? state.selectedSessionKey,
209
+ messages: [],
210
+ };
211
+ case "ADD_JOB":
212
+ return { ...state, jobs: [action.job, ...state.jobs] };
213
+ case "ADD_MESSAGE": {
214
+ // If the last message is a streaming placeholder and a new agent
215
+ // message arrives, replace it with the final message. We also
216
+ // proactively clear streamingRunId here because agent.text may
217
+ // arrive *before* agent.stream.end (deliver() fires first inside
218
+ // dispatchReplyFromConfig, stream.end is sent after it returns).
219
+ const lastMsg = state.messages[state.messages.length - 1];
220
+ if (
221
+ action.message.sender === "agent" &&
222
+ lastMsg?.isStreaming
223
+ ) {
224
+ return {
225
+ ...state,
226
+ streamingRunId: null,
227
+ streamingSessionKey: null,
228
+ messages: [
229
+ ...state.messages.slice(0, -1),
230
+ { ...action.message, isStreaming: false },
231
+ ],
232
+ };
233
+ }
234
+ return { ...state, messages: [...state.messages, action.message] };
235
+ }
236
+ case "SET_MESSAGES":
237
+ return {
238
+ ...state,
239
+ messages: action.messages,
240
+ ...(action.replyCounts
241
+ ? { threadReplyCounts: { ...state.threadReplyCounts, ...action.replyCounts } }
242
+ : {}),
243
+ };
244
+ case "OPEN_THREAD":
245
+ return {
246
+ ...state,
247
+ activeThreadId: action.threadId,
248
+ threadMessages: action.messages,
249
+ threadReplyCounts: {
250
+ ...state.threadReplyCounts,
251
+ ...(action.messages.length > 0
252
+ ? { [action.threadId]: action.messages.length }
253
+ : {}),
254
+ },
255
+ };
256
+ case "CLOSE_THREAD":
257
+ return { ...state, activeThreadId: null, threadMessages: [] };
258
+ case "ADD_THREAD_MESSAGE": {
259
+ const msgThreadId = action.message.threadId ?? state.activeThreadId;
260
+ const isActiveThread = !!(msgThreadId && msgThreadId === state.activeThreadId);
261
+
262
+ let newThreadMessages = state.threadMessages;
263
+ let clearStreaming: Partial<AppState> = {};
264
+
265
+ if (isActiveThread) {
266
+ // If the last thread message is a streaming placeholder and a new
267
+ // agent message arrives, replace it (same logic as ADD_MESSAGE).
268
+ const lastMsg = state.threadMessages[state.threadMessages.length - 1];
269
+ if (action.message.sender === "agent" && lastMsg?.isStreaming) {
270
+ newThreadMessages = [
271
+ ...state.threadMessages.slice(0, -1),
272
+ { ...action.message, isStreaming: false },
273
+ ];
274
+ clearStreaming = { streamingRunId: null, streamingSessionKey: null, streamingThreadId: null };
275
+ } else {
276
+ newThreadMessages = [...state.threadMessages, action.message];
277
+ }
278
+ }
279
+
280
+ const updatedCounts = { ...state.threadReplyCounts };
281
+ if (msgThreadId) {
282
+ if (isActiveThread) {
283
+ updatedCounts[msgThreadId] = newThreadMessages.length;
284
+ } else {
285
+ // Thread not open — just increment
286
+ updatedCounts[msgThreadId] = (updatedCounts[msgThreadId] ?? 0) + 1;
287
+ }
288
+ }
289
+
290
+ return {
291
+ ...state,
292
+ ...clearStreaming,
293
+ threadMessages: newThreadMessages,
294
+ threadReplyCounts: updatedCounts,
295
+ };
296
+ }
297
+ case "SET_OPENCLAW_CONNECTED":
298
+ // connection.status carries the global defaultModel from OpenClaw config.
299
+ // It never touches sessionModel — that's per-session and managed separately.
300
+ return {
301
+ ...state,
302
+ openclawConnected: action.connected,
303
+ defaultModel: action.defaultModel ?? state.defaultModel,
304
+ };
305
+ case "SET_SESSION_MODEL":
306
+ return { ...state, sessionModel: action.model };
307
+ case "SET_WS_CONNECTED":
308
+ return { ...state, wsConnected: action.connected };
309
+ case "SET_MODELS":
310
+ return { ...state, models: action.models };
311
+ case "SET_DEFAULT_MODEL":
312
+ return { ...state, defaultModel: action.model };
313
+ case "RESOLVE_ACTION": {
314
+ // Mark an action block on a message as resolved (keyed by prompt hash)
315
+ return {
316
+ ...state,
317
+ messages: state.messages.map((m) =>
318
+ m.id === action.messageId
319
+ ? {
320
+ ...m,
321
+ resolvedActions: {
322
+ ...m.resolvedActions,
323
+ [action.promptHash]: { value: action.value, label: action.label },
324
+ },
325
+ }
326
+ : m,
327
+ ),
328
+ };
329
+ }
330
+ case "STREAM_START": {
331
+ // Add a streaming placeholder message
332
+ const streamMsg: ChatMessage = {
333
+ id: `stream_${action.runId}`,
334
+ sender: "agent",
335
+ text: "",
336
+ timestamp: Date.now(),
337
+ isStreaming: true,
338
+ };
339
+ const isThreadStream = !!action.threadId;
340
+ return {
341
+ ...state,
342
+ streamingRunId: action.runId,
343
+ streamingSessionKey: action.sessionKey,
344
+ streamingThreadId: action.threadId ?? null,
345
+ ...(isThreadStream
346
+ ? { threadMessages: [...state.threadMessages, streamMsg] }
347
+ : { messages: [...state.messages, streamMsg] }),
348
+ };
349
+ }
350
+ case "STREAM_CHUNK": {
351
+ if (state.streamingRunId !== action.runId) return state;
352
+ // Update the streaming message's text (onPartialReply sends accumulated text)
353
+ const streamId = `stream_${action.runId}`;
354
+ if (state.streamingThreadId) {
355
+ return {
356
+ ...state,
357
+ threadMessages: state.threadMessages.map((m) =>
358
+ m.id === streamId ? { ...m, text: action.text } : m,
359
+ ),
360
+ };
361
+ }
362
+ return {
363
+ ...state,
364
+ messages: state.messages.map((m) =>
365
+ m.id === streamId ? { ...m, text: action.text } : m,
366
+ ),
367
+ };
368
+ }
369
+ case "STREAM_END": {
370
+ // streamingRunId may already have been cleared by ADD_MESSAGE
371
+ // (agent.text can arrive before stream.end); handle gracefully.
372
+ if (state.streamingRunId && state.streamingRunId !== action.runId) return state;
373
+ return { ...state, streamingRunId: null, streamingSessionKey: null, streamingThreadId: null };
374
+ }
375
+ case "SET_CRON_TASKS":
376
+ return { ...state, cronTasks: action.cronTasks };
377
+ case "MERGE_SCAN_DATA": {
378
+ // Merge schedule/instructions/model from OpenClaw scan results into cronTasks.
379
+ // These fields belong to OpenClaw and are NOT stored in D1.
380
+ const scanMap = new Map(action.scanTasks.map((s) => [s.cronJobId, s]));
381
+ const merged = state.cronTasks.map((task) => {
382
+ const scan = task.openclawCronJobId ? scanMap.get(task.openclawCronJobId) : null;
383
+ if (!scan) return task;
384
+ return {
385
+ ...task,
386
+ schedule: scan.schedule || null,
387
+ instructions: scan.instructions || null,
388
+ model: scan.model || null,
389
+ enabled: scan.enabled,
390
+ };
391
+ });
392
+ // Also merge into the messages-view tasks list
393
+ const mergedTasks = state.tasks.map((task) => {
394
+ const scan = task.openclawCronJobId ? scanMap.get(task.openclawCronJobId) : null;
395
+ if (!scan) return task;
396
+ return {
397
+ ...task,
398
+ schedule: scan.schedule || null,
399
+ instructions: scan.instructions || null,
400
+ model: scan.model || null,
401
+ enabled: scan.enabled,
402
+ };
403
+ });
404
+ return { ...state, cronTasks: merged, tasks: mergedTasks };
405
+ }
406
+ case "UPDATE_CRON_TASK": {
407
+ // Optimistic update for a single cron task (after editing)
408
+ return {
409
+ ...state,
410
+ cronTasks: state.cronTasks.map((t) =>
411
+ t.id === action.taskId ? { ...t, ...action.updates } : t,
412
+ ),
413
+ tasks: state.tasks.map((t) =>
414
+ t.id === action.taskId ? { ...t, ...action.updates } : t,
415
+ ),
416
+ };
417
+ }
418
+ case "SELECT_CRON_TASK":
419
+ // If re-selecting the same task, keep existing jobs to avoid flicker
420
+ if (state.selectedCronTaskId === action.taskId) return state;
421
+ return {
422
+ ...state,
423
+ selectedCronTaskId: action.taskId,
424
+ cronJobs: [],
425
+ selectedCronJobId: null,
426
+ messages: [],
427
+ selectedSessionKey: null,
428
+ };
429
+ case "SET_CRON_JOBS":
430
+ return { ...state, cronJobs: action.cronJobs };
431
+ case "SELECT_CRON_JOB":
432
+ return {
433
+ ...state,
434
+ selectedCronJobId: action.jobId,
435
+ selectedSessionKey: action.sessionKey ?? state.selectedSessionKey,
436
+ messages: [],
437
+ };
438
+ case "ADD_CRON_JOB":
439
+ // Prepend new job to cronJobs (most recent first)
440
+ return { ...state, cronJobs: [action.job, ...state.cronJobs] };
441
+ case "UPDATE_CRON_JOB": {
442
+ // Update an existing job in cronJobs (e.g. running → ok/error)
443
+ const exists = state.cronJobs.some((j) => j.id === action.job.id);
444
+ if (exists) {
445
+ return {
446
+ ...state,
447
+ cronJobs: state.cronJobs.map((j) => {
448
+ if (j.id !== action.job.id) return j;
449
+ // When a job finishes, prefer the streaming summary (accumulated
450
+ // in-memory via job.output) over the server-side summary if the
451
+ // streaming version is longer — it preserves block separators and
452
+ // intermediate output that may not be persisted to D1.
453
+ const summary =
454
+ j.summary && j.summary.length > (action.job.summary?.length || 0)
455
+ ? j.summary
456
+ : action.job.summary;
457
+ return { ...j, ...action.job, summary };
458
+ }),
459
+ };
460
+ }
461
+ // If not found, prepend (handles race where running arrives after list)
462
+ return { ...state, cronJobs: [action.job, ...state.cronJobs] };
463
+ }
464
+ case "APPEND_JOB_OUTPUT": {
465
+ // Update streaming output text for a running job (both views)
466
+ const updateSummary = (list: Job[]) =>
467
+ list.map((j) =>
468
+ j.id === action.jobId ? { ...j, summary: action.text } : j,
469
+ );
470
+ return {
471
+ ...state,
472
+ jobs: updateSummary(state.jobs),
473
+ cronJobs: updateSummary(state.cronJobs),
474
+ };
475
+ }
476
+ case "LOGOUT":
477
+ return { ...initialState };
478
+ default:
479
+ return state;
480
+ }
481
+ }
482
+
483
+ export const AppStateContext = createContext<AppState>(initialState);
484
+ export const AppDispatchContext = createContext<React.Dispatch<AppAction>>(() => {});
485
+
486
+ export function useAppState() {
487
+ return useContext(AppStateContext);
488
+ }
489
+
490
+ export function useAppDispatch() {
491
+ return useContext(AppDispatchContext);
492
+ }