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,864 @@
1
+ import React, { useRef, useEffect, useState, useMemo, useCallback } from "react";
2
+ import { useAppState, useAppDispatch, type ChatMessage } from "../store";
3
+ import type { WSMessage } from "../ws";
4
+ import { MessageContent } from "./MessageContent";
5
+ import { ModelSelect } from "./ModelSelect";
6
+ import { SessionTabs } from "./SessionTabs";
7
+ import { dlog } from "../debug-log";
8
+
9
+ type ChatWindowProps = {
10
+ sendMessage: (msg: WSMessage) => void;
11
+ };
12
+
13
+ /** Simple string hash for action prompt keys (matches MessageContent) */
14
+ function simpleHash(str: string): string {
15
+ let hash = 0;
16
+ for (let i = 0; i < str.length; i++) {
17
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
18
+ }
19
+ return hash.toString(36);
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Skill definitions & frequency tracking (v2 – recency-aware, daily buckets)
24
+ // ---------------------------------------------------------------------------
25
+ // Storage: localStorage (per-origin). Data persists across page reloads but
26
+ // lives separately per origin (localhost:8787 vs localhost:3000). The v2 format
27
+ // stores daily usage buckets so we can weight recent activity higher.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ type Skill = { cmd: string; label: string; icon: string };
31
+
32
+ /** Default skills — shown even before any usage */
33
+ const DEFAULT_SKILLS: Skill[] = [
34
+ { cmd: "/help", label: "Help", icon: "?" },
35
+ { cmd: "/status", label: "Status", icon: "i" },
36
+ { cmd: "/model", label: "Model", icon: "M" },
37
+ { cmd: "/clear", label: "Clear", icon: "C" },
38
+ { cmd: "/think", label: "Think", icon: "T" },
39
+ { cmd: "/image", label: "Image", icon: "I" },
40
+ { cmd: "/search", label: "Search", icon: "S" },
41
+ { cmd: "/summarize", label: "Summarize", icon: "Σ" },
42
+ { cmd: "/translate", label: "Translate", icon: "翻" },
43
+ { cmd: "/reset", label: "Reset", icon: "R" },
44
+ ];
45
+
46
+ // --- v2 storage types ---
47
+ type SkillEntry = { total: number; daily: Record<string, number> };
48
+ type SkillStore = Record<string, SkillEntry>;
49
+
50
+ const STORE_KEY = "botschat_skill_freq_v2";
51
+ const V1_KEY = "botschat_skill_freq";
52
+ const RECENCY_DAYS = 2; // window for "recent" weighting
53
+ const PRUNE_DAYS = 14; // drop daily buckets older than this
54
+
55
+ function dateKey(d = new Date()): string {
56
+ return d.toISOString().slice(0, 10); // "YYYY-MM-DD"
57
+ }
58
+
59
+ /** Prune daily buckets older than PRUNE_DAYS in-place. */
60
+ function pruneStore(store: SkillStore) {
61
+ const cutoff = Date.now() - PRUNE_DAYS * 86_400_000;
62
+ for (const entry of Object.values(store)) {
63
+ for (const ds of Object.keys(entry.daily)) {
64
+ if (new Date(ds).getTime() < cutoff) delete entry.daily[ds];
65
+ }
66
+ }
67
+ }
68
+
69
+ /** Load skill frequency store, migrating from v1 if needed. */
70
+ function loadSkillStore(): SkillStore {
71
+ try {
72
+ // Try v2 first
73
+ const raw = localStorage.getItem(STORE_KEY);
74
+ if (raw) {
75
+ const store: SkillStore = JSON.parse(raw);
76
+ pruneStore(store);
77
+ return store;
78
+ }
79
+ // Auto-migrate from v1 (plain Record<string, number>)
80
+ const v1 = localStorage.getItem(V1_KEY);
81
+ if (v1) {
82
+ const old: Record<string, number> = JSON.parse(v1);
83
+ const today = dateKey();
84
+ const store: SkillStore = {};
85
+ for (const [cmd, count] of Object.entries(old)) {
86
+ store[cmd] = { total: count, daily: { [today]: count } };
87
+ }
88
+ saveSkillStore(store);
89
+ localStorage.removeItem(V1_KEY);
90
+ return store;
91
+ }
92
+ return {};
93
+ } catch { return {}; }
94
+ }
95
+
96
+ function saveSkillStore(store: SkillStore) {
97
+ pruneStore(store);
98
+ localStorage.setItem(STORE_KEY, JSON.stringify(store));
99
+ }
100
+
101
+ /** Detect and record a /command from the message text. Returns the command if found. */
102
+ function recordSkillUsage(text: string): string | null {
103
+ const match = text.match(/^\/(\S+)/);
104
+ if (!match) return null;
105
+ const cmd = `/${match[1]}`;
106
+ const store = loadSkillStore();
107
+ const today = dateKey();
108
+ if (!store[cmd]) store[cmd] = { total: 0, daily: {} };
109
+ store[cmd].total += 1;
110
+ store[cmd].daily[today] = (store[cmd].daily[today] ?? 0) + 1;
111
+ saveSkillStore(store);
112
+ return cmd;
113
+ }
114
+
115
+ /** Count usages within the last RECENCY_DAYS. */
116
+ function recentCount(entry: SkillEntry): number {
117
+ const cutoff = Date.now() - RECENCY_DAYS * 86_400_000;
118
+ let sum = 0;
119
+ for (const [ds, cnt] of Object.entries(entry.daily)) {
120
+ if (new Date(ds).getTime() >= cutoff) sum += cnt;
121
+ }
122
+ return sum;
123
+ }
124
+
125
+ /** Composite score: recent usage * 5 + all-time total. */
126
+ function skillScore(entry: SkillEntry): number {
127
+ return recentCount(entry) * 5 + entry.total;
128
+ }
129
+
130
+ /**
131
+ * Return default skills + user-typed custom skills, sorted by a composite
132
+ * recency score (skills used more in the last 2 days float to the front).
133
+ */
134
+ function getSortedSkills(): { skills: Skill[]; store: SkillStore } {
135
+ const store = loadSkillStore();
136
+ const defaultCmds = new Set(DEFAULT_SKILLS.map((s) => s.cmd));
137
+ // Build entries for user-typed skills that aren't in the default list
138
+ const customSkills: Skill[] = Object.keys(store)
139
+ .filter((cmd) => !defaultCmds.has(cmd) && cmd.startsWith("/"))
140
+ .map((cmd) => ({
141
+ cmd,
142
+ label: cmd.slice(1).charAt(0).toUpperCase() + cmd.slice(2),
143
+ icon: cmd.slice(1).charAt(0).toUpperCase(),
144
+ }));
145
+ const skills = [...DEFAULT_SKILLS, ...customSkills].sort((a, b) => {
146
+ const sa = store[a.cmd] ? skillScore(store[a.cmd]) : 0;
147
+ const sb = store[b.cmd] ? skillScore(store[b.cmd]) : 0;
148
+ return sb - sa;
149
+ });
150
+ return { skills, store };
151
+ }
152
+
153
+ /** Flat-row message display + composer, per design guideline section 5.2/5.6 */
154
+ export function ChatWindow({ sendMessage }: ChatWindowProps) {
155
+ const state = useAppState();
156
+ const dispatch = useAppDispatch();
157
+ const [input, setInput] = useState("");
158
+ const [skillVersion, setSkillVersion] = useState(0); // bump to re-sort skills
159
+ const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null);
160
+ const [imageUploading, setImageUploading] = useState(false);
161
+ const [dragOver, setDragOver] = useState(false);
162
+ const messagesEndRef = useRef<HTMLDivElement>(null);
163
+ const inputRef = useRef<HTMLTextAreaElement>(null);
164
+ const fileInputRef = useRef<HTMLInputElement>(null);
165
+ const dropZoneRef = useRef<HTMLDivElement>(null);
166
+
167
+ const sessionKey = state.selectedSessionKey;
168
+
169
+ const { skills: sortedSkills, store: skillStore } = useMemo(
170
+ () => getSortedSkills(),
171
+ [skillVersion],
172
+ );
173
+
174
+ useEffect(() => {
175
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
176
+ }, [state.messages]);
177
+
178
+ // Auto-resize textarea
179
+ useEffect(() => {
180
+ if (inputRef.current) {
181
+ inputRef.current.style.height = "auto";
182
+ inputRef.current.style.height = Math.min(inputRef.current.scrollHeight, 160) + "px";
183
+ }
184
+ }, [input]);
185
+
186
+ // Auto-focus the input when a session is active (page load or channel switch)
187
+ useEffect(() => {
188
+ if (sessionKey && inputRef.current) {
189
+ // Small delay to ensure DOM is ready after render
190
+ requestAnimationFrame(() => {
191
+ inputRef.current?.focus();
192
+ });
193
+ }
194
+ }, [sessionKey]);
195
+
196
+ // Restore per-session model from localStorage when session changes
197
+ useEffect(() => {
198
+ if (!sessionKey) return;
199
+ try {
200
+ const stored = JSON.parse(localStorage.getItem("botschat:sessionModels") || "{}");
201
+ const saved = stored[sessionKey];
202
+ if (saved && saved !== state.sessionModel) {
203
+ dispatch({ type: "SET_SESSION_MODEL", model: saved });
204
+ } else if (!saved && state.sessionModel) {
205
+ // New session with no override — clear sessionModel so defaultModel shows
206
+ dispatch({ type: "SET_SESSION_MODEL", model: null });
207
+ }
208
+ } catch { /* ignore */ }
209
+ }, [sessionKey]); // eslint-disable-line react-hooks/exhaustive-deps
210
+
211
+ const currentModel = state.sessionModel ?? state.defaultModel;
212
+
213
+ const handleModelChange = useCallback((modelId: string) => {
214
+ if (!modelId || !sessionKey || modelId === currentModel) return;
215
+
216
+ dlog.info("Chat", `Model change: ${currentModel ?? "none"} → ${modelId}`);
217
+
218
+ // Optimistically update the dropdown immediately
219
+ dispatch({ type: "SET_SESSION_MODEL", model: modelId });
220
+
221
+ // Persist per-session model to localStorage
222
+ try {
223
+ const stored = JSON.parse(localStorage.getItem("botschat:sessionModels") || "{}");
224
+ stored[sessionKey] = modelId;
225
+ localStorage.setItem("botschat:sessionModels", JSON.stringify(stored));
226
+ } catch { /* ignore */ }
227
+
228
+ recordSkillUsage("/model");
229
+ setSkillVersion((v) => v + 1);
230
+
231
+ const msg: ChatMessage = {
232
+ id: crypto.randomUUID(),
233
+ sender: "user",
234
+ text: `/model ${modelId}`,
235
+ timestamp: Date.now(),
236
+ };
237
+ dispatch({ type: "ADD_MESSAGE", message: msg });
238
+ sendMessage({
239
+ type: "user.message",
240
+ sessionKey,
241
+ text: `/model ${modelId}`,
242
+ userId: state.user?.id ?? "",
243
+ messageId: msg.id,
244
+ });
245
+
246
+ requestAnimationFrame(() => {
247
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
248
+ });
249
+ }, [sessionKey, currentModel, state.user?.id, sendMessage, dispatch]);
250
+
251
+ const handleSkillClick = useCallback((cmd: string) => {
252
+ dlog.info("Skill", `Skill button clicked: ${cmd}`);
253
+ setInput((prev) => {
254
+ // If input already starts with this command, don't duplicate
255
+ if (prev.startsWith(cmd + " ") || prev === cmd) return prev;
256
+ return cmd + " ";
257
+ });
258
+ inputRef.current?.focus();
259
+ }, []);
260
+
261
+ // Image upload helpers
262
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
263
+ const file = e.target.files?.[0];
264
+ if (!file || !file.type.startsWith("image/")) return;
265
+ const preview = URL.createObjectURL(file);
266
+ setPendingImage({ file, preview });
267
+ e.target.value = "";
268
+ inputRef.current?.focus();
269
+ }, []);
270
+
271
+ const clearPendingImage = useCallback(() => {
272
+ if (pendingImage) {
273
+ URL.revokeObjectURL(pendingImage.preview);
274
+ setPendingImage(null);
275
+ }
276
+ }, [pendingImage]);
277
+
278
+ const uploadImage = useCallback(async (file: File): Promise<string | null> => {
279
+ const formData = new FormData();
280
+ formData.append("file", file);
281
+ const token = localStorage.getItem("botschat_token");
282
+ try {
283
+ const res = await fetch("/api/upload", {
284
+ method: "POST",
285
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
286
+ body: formData,
287
+ });
288
+ if (!res.ok) {
289
+ const err = await res.json().catch(() => ({ error: "Upload failed" }));
290
+ throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
291
+ }
292
+ const data = await res.json() as { url: string };
293
+ // Return absolute URL so OpenClaw on mini.local can fetch the image
294
+ const absoluteUrl = data.url.startsWith("/")
295
+ ? `${window.location.origin}${data.url}`
296
+ : data.url;
297
+ return absoluteUrl;
298
+ } catch (err) {
299
+ dlog.error("Upload", `Image upload failed: ${err}`);
300
+ return null;
301
+ }
302
+ }, []);
303
+
304
+ // Drag & drop handlers
305
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
306
+ e.preventDefault();
307
+ e.stopPropagation();
308
+ if (e.dataTransfer.types.includes("Files")) {
309
+ setDragOver(true);
310
+ }
311
+ }, []);
312
+
313
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
314
+ e.preventDefault();
315
+ e.stopPropagation();
316
+ const rect = dropZoneRef.current?.getBoundingClientRect();
317
+ if (rect) {
318
+ const { clientX, clientY } = e;
319
+ if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
320
+ setDragOver(false);
321
+ }
322
+ }
323
+ }, []);
324
+
325
+ const handleDragOver = useCallback((e: React.DragEvent) => {
326
+ e.preventDefault();
327
+ e.stopPropagation();
328
+ }, []);
329
+
330
+ const handleDrop = useCallback((e: React.DragEvent) => {
331
+ e.preventDefault();
332
+ e.stopPropagation();
333
+ setDragOver(false);
334
+ const file = e.dataTransfer.files?.[0];
335
+ if (file && file.type.startsWith("image/")) {
336
+ const preview = URL.createObjectURL(file);
337
+ setPendingImage({ file, preview });
338
+ inputRef.current?.focus();
339
+ }
340
+ }, []);
341
+
342
+ // Paste handler for images
343
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
344
+ const items = e.clipboardData?.items;
345
+ if (!items) return;
346
+ for (let i = 0; i < items.length; i++) {
347
+ if (items[i].type.startsWith("image/")) {
348
+ e.preventDefault();
349
+ const file = items[i].getAsFile();
350
+ if (file) {
351
+ const preview = URL.createObjectURL(file);
352
+ setPendingImage({ file, preview });
353
+ }
354
+ return;
355
+ }
356
+ }
357
+ }, []);
358
+
359
+ const handleSend = async () => {
360
+ if ((!input.trim() && !pendingImage) || !sessionKey) return;
361
+
362
+ const trimmed = input.trim();
363
+ const hasText = trimmed.length > 0;
364
+ const isSkill = hasText && trimmed.startsWith("/");
365
+ dlog.info("Chat", `Send message${isSkill ? " (skill)" : ""}${pendingImage ? " +image" : ""}: ${trimmed.length > 120 ? trimmed.slice(0, 120) + "…" : trimmed}`, { sessionKey, isSkill });
366
+
367
+ if (hasText) {
368
+ recordSkillUsage(trimmed);
369
+ setSkillVersion((v) => v + 1);
370
+ }
371
+
372
+ // Upload image if present
373
+ let mediaUrl: string | undefined;
374
+ if (pendingImage) {
375
+ setImageUploading(true);
376
+ const url = await uploadImage(pendingImage.file);
377
+ setImageUploading(false);
378
+ if (!url) return; // Upload failed
379
+ mediaUrl = url;
380
+ clearPendingImage();
381
+ }
382
+
383
+ const msg: ChatMessage = {
384
+ id: crypto.randomUUID(),
385
+ sender: "user",
386
+ text: trimmed,
387
+ timestamp: Date.now(),
388
+ mediaUrl,
389
+ };
390
+
391
+ dispatch({ type: "ADD_MESSAGE", message: msg });
392
+
393
+ sendMessage({
394
+ type: "user.message",
395
+ sessionKey,
396
+ text: trimmed,
397
+ userId: state.user?.id ?? "",
398
+ messageId: msg.id,
399
+ ...(mediaUrl ? { mediaUrl } : {}),
400
+ });
401
+
402
+ setInput("");
403
+
404
+ requestAnimationFrame(() => {
405
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
406
+ });
407
+ };
408
+
409
+ const openThread = (messageId: string) => {
410
+ dlog.info("Thread", `Open thread for message: ${messageId}`);
411
+ dispatch({ type: "OPEN_THREAD", threadId: messageId, messages: [] });
412
+ };
413
+
414
+ /** Handle A2UI action button clicks — sends the action text as a user message */
415
+ const handleA2UIAction = useCallback((action: string) => {
416
+ if (!sessionKey) return;
417
+ dlog.info("A2UI", `Action triggered: ${action}`);
418
+ const msg: ChatMessage = {
419
+ id: crypto.randomUUID(),
420
+ sender: "user",
421
+ text: action,
422
+ timestamp: Date.now(),
423
+ };
424
+ dispatch({ type: "ADD_MESSAGE", message: msg });
425
+ sendMessage({
426
+ type: "user.message",
427
+ sessionKey,
428
+ text: action,
429
+ userId: state.user?.id ?? "",
430
+ messageId: msg.id,
431
+ });
432
+ requestAnimationFrame(() => {
433
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
434
+ });
435
+ }, [sessionKey, state.user?.id, sendMessage, dispatch]);
436
+
437
+ /** Handle ActionCard resolve — marks widget done + sends the choice as user message */
438
+ const handleResolveAction = useCallback((messageId: string, value: string, label: string) => {
439
+ if (!sessionKey) return;
440
+ dlog.info("ActionCard", `Resolved: "${label}" (value="${value}")`);
441
+
442
+ // Compute a simple hash from value+label for the prompt key
443
+ const promptHash = simpleHash(label + value);
444
+
445
+ // Mark the action as resolved in the store
446
+ dispatch({ type: "RESOLVE_ACTION", messageId, promptHash, value, label });
447
+
448
+ // Send the chosen label as a user message (show the readable label, not the
449
+ // technical value, so the chat history reads naturally)
450
+ const msg: ChatMessage = {
451
+ id: crypto.randomUUID(),
452
+ sender: "user",
453
+ text: label,
454
+ timestamp: Date.now(),
455
+ };
456
+ dispatch({ type: "ADD_MESSAGE", message: msg });
457
+ sendMessage({
458
+ type: "user.message",
459
+ sessionKey,
460
+ text: label,
461
+ userId: state.user?.id ?? "",
462
+ messageId: msg.id,
463
+ });
464
+ requestAnimationFrame(() => {
465
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
466
+ });
467
+ }, [sessionKey, state.user?.id, sendMessage, dispatch]);
468
+
469
+ const selectedAgent = state.agents.find((a) => a.id === state.selectedAgentId);
470
+ const channelName = selectedAgent?.name ?? "channel";
471
+ const channelId = selectedAgent?.channelId ?? null;
472
+ // Always show session tabs — for all channels including default (General)
473
+ const showSessionTabs = !!selectedAgent;
474
+
475
+ if (!sessionKey) {
476
+ return (
477
+ <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
478
+ <div className="text-center">
479
+ <svg className="w-16 h-16 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1} style={{ color: "var(--text-muted)" }}>
480
+ <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
481
+ </svg>
482
+ <p className="text-body font-bold" style={{ color: "var(--text-muted)" }}>
483
+ Select a channel to get started
484
+ </p>
485
+ <p className="text-caption mt-1" style={{ color: "var(--text-muted)" }}>
486
+ Choose a channel from the sidebar
487
+ </p>
488
+ </div>
489
+ </div>
490
+ );
491
+ }
492
+
493
+ return (
494
+ <div
495
+ ref={dropZoneRef}
496
+ className="flex-1 flex flex-col min-w-0 relative"
497
+ style={{ background: "var(--bg-surface)" }}
498
+ onDragEnter={handleDragEnter}
499
+ onDragLeave={handleDragLeave}
500
+ onDragOver={handleDragOver}
501
+ onDrop={handleDrop}
502
+ >
503
+ {/* Drag overlay */}
504
+ {dragOver && (
505
+ <div
506
+ className="absolute inset-0 z-50 flex items-center justify-center"
507
+ style={{ background: "rgba(0,0,0,0.4)", pointerEvents: "none" }}
508
+ >
509
+ <div className="flex flex-col items-center gap-3 p-8 rounded-lg" style={{ background: "var(--bg-surface)", border: "2px dashed var(--text-link)" }}>
510
+ <svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-link)" }}>
511
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v13.5A1.5 1.5 0 003.75 21z" />
512
+ </svg>
513
+ <span className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
514
+ Drop image here
515
+ </span>
516
+ </div>
517
+ </div>
518
+ )}
519
+
520
+ {/* Channel header */}
521
+ <div
522
+ className="flex items-center justify-between px-5"
523
+ style={{
524
+ height: 44,
525
+ borderBottom: "1px solid var(--border)",
526
+ }}
527
+ >
528
+ <div className="flex items-center gap-2">
529
+ <span className="text-h1" style={{ color: "var(--text-primary)" }}>
530
+ # {channelName}
531
+ </span>
532
+ {selectedAgent && !selectedAgent.isDefault && (
533
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
534
+ — custom channel
535
+ </span>
536
+ )}
537
+ </div>
538
+ <div className="flex items-center gap-1.5">
539
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
540
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
541
+ </svg>
542
+ <ModelSelect
543
+ value={currentModel ?? ""}
544
+ onChange={handleModelChange}
545
+ models={state.models}
546
+ disabled={!state.openclawConnected}
547
+ placeholder="No model"
548
+ compact
549
+ />
550
+ </div>
551
+ </div>
552
+
553
+ {/* Session tabs — shown for all agents (including default/General) */}
554
+ {showSessionTabs && <SessionTabs channelId={channelId} />}
555
+
556
+ {/* Messages – flat-row layout */}
557
+ <div className="flex-1 overflow-y-auto">
558
+ {state.messages.length === 0 && (
559
+ <div className="py-12 px-5 text-center">
560
+ <p className="text-caption" style={{ color: "var(--text-muted)" }}>
561
+ No messages yet. Start a conversation.
562
+ </p>
563
+ </div>
564
+ )}
565
+ {state.messages.map((msg, i) => {
566
+ const prevMsg = i > 0 ? state.messages[i - 1] : null;
567
+ const isGrouped = prevMsg?.sender === msg.sender
568
+ && (msg.timestamp - prevMsg.timestamp) < 300000; // 5 min
569
+
570
+ return (
571
+ <MessageRow
572
+ key={msg.id}
573
+ msg={msg}
574
+ grouped={isGrouped}
575
+ onOpenThread={() => openThread(msg.id)}
576
+ onAction={handleA2UIAction}
577
+ onResolveAction={(value, label) => handleResolveAction(msg.id, value, label)}
578
+ />
579
+ );
580
+ })}
581
+ <div ref={messagesEndRef} />
582
+ </div>
583
+
584
+ {/* Composer (section 5.6) */}
585
+ <div className="px-5 pb-4 pt-2">
586
+ {/* Skill buttons — sorted by recency-weighted score */}
587
+ <div className="flex items-center gap-1.5 pb-1.5 overflow-x-auto no-scrollbar">
588
+ {sortedSkills.map((skill) => {
589
+ const entry = skillStore[skill.cmd];
590
+ const count = entry?.total ?? 0;
591
+ const recent = entry ? recentCount(entry) : 0;
592
+ const isActive = input.startsWith(skill.cmd + " ") || input === skill.cmd;
593
+ return (
594
+ <button
595
+ key={skill.cmd}
596
+ onClick={() => handleSkillClick(skill.cmd)}
597
+ className="flex items-center gap-1 px-2 py-1 rounded-md text-xs whitespace-nowrap transition-colors shrink-0"
598
+ style={{
599
+ background: isActive ? "var(--bg-active)" : "var(--bg-hover)",
600
+ color: isActive ? "#fff" : "var(--text-secondary)",
601
+ border: "1px solid transparent",
602
+ }}
603
+ title={`${skill.cmd}${count > 0 ? ` (total ${count}x${recent > 0 ? `, recent ${recent}x` : ""})` : ""}`}
604
+ >
605
+ <span className="font-mono text-[10px] opacity-70">{skill.cmd}</span>
606
+ {count > 0 && (
607
+ <span
608
+ className="ml-0.5 px-1 rounded-sm text-[10px] font-bold"
609
+ style={{
610
+ background: isActive ? "rgba(255,255,255,0.2)" : "var(--bg-surface)",
611
+ color: isActive ? "#fff" : "var(--text-muted)",
612
+ }}
613
+ >
614
+ {count}
615
+ </span>
616
+ )}
617
+ </button>
618
+ );
619
+ })}
620
+ </div>
621
+
622
+ <div
623
+ className="rounded-md"
624
+ style={{
625
+ border: "1px solid var(--border)",
626
+ background: "var(--bg-surface)",
627
+ }}
628
+ >
629
+ {/* Image preview */}
630
+ {pendingImage && (
631
+ <div className="px-3 pt-2 flex items-start gap-2">
632
+ <div className="relative">
633
+ <img
634
+ src={pendingImage.preview}
635
+ alt="Preview"
636
+ className="max-w-[120px] max-h-[80px] rounded-md object-contain"
637
+ style={{ border: "1px solid var(--border)" }}
638
+ />
639
+ <button
640
+ onClick={clearPendingImage}
641
+ className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center text-white opacity-80 hover:opacity-100 transition-opacity"
642
+ style={{ background: "#e74c3c", fontSize: 11 }}
643
+ title="Remove image"
644
+ >
645
+
646
+ </button>
647
+ </div>
648
+ {imageUploading && (
649
+ <span className="text-caption" style={{ color: "var(--text-muted)" }}>
650
+ Uploading…
651
+ </span>
652
+ )}
653
+ </div>
654
+ )}
655
+
656
+ {/* Textarea */}
657
+ <textarea
658
+ ref={inputRef}
659
+ value={input}
660
+ onChange={(e) => setInput(e.target.value)}
661
+ onKeyDown={(e) => {
662
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
663
+ e.preventDefault();
664
+ handleSend();
665
+ }
666
+ }}
667
+ onPaste={handlePaste}
668
+ placeholder={
669
+ state.openclawConnected
670
+ ? `Message #${channelName}`
671
+ : "OpenClaw is offline…"
672
+ }
673
+ disabled={!state.openclawConnected}
674
+ rows={1}
675
+ className="w-full px-3 py-2.5 text-body bg-transparent resize-none focus:outline-none disabled:opacity-50 placeholder:text-[--text-muted]"
676
+ style={{ color: "var(--text-primary)", minHeight: 40 }}
677
+ />
678
+
679
+ {/* Bottom toolbar */}
680
+ <div className="flex items-center justify-between px-3 pb-2">
681
+ <div className="flex items-center gap-1">
682
+ {/* Image upload button */}
683
+ <input
684
+ ref={fileInputRef}
685
+ type="file"
686
+ accept="image/*"
687
+ className="hidden"
688
+ onChange={handleFileSelect}
689
+ />
690
+ <button
691
+ onClick={() => fileInputRef.current?.click()}
692
+ className="p-1.5 rounded hover:bg-[--bg-hover] transition-colors"
693
+ style={{ color: "var(--text-muted)" }}
694
+ title="Upload image"
695
+ aria-label="Upload image"
696
+ disabled={!state.openclawConnected}
697
+ >
698
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
699
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v13.5A1.5 1.5 0 003.75 21zm14.25-15.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
700
+ </svg>
701
+ </button>
702
+ </div>
703
+
704
+ {/* Send button */}
705
+ <button
706
+ onClick={handleSend}
707
+ disabled={(!input.trim() && !pendingImage) || !state.openclawConnected}
708
+ className="px-3 py-1.5 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
709
+ style={{ background: "var(--bg-active)" }}
710
+ >
711
+ <div className="flex items-center gap-1.5">
712
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
713
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
714
+ </svg>
715
+ Send
716
+ </div>
717
+ </button>
718
+ </div>
719
+ </div>
720
+ </div>
721
+ </div>
722
+ );
723
+ }
724
+
725
+ /** Flat-row message item (section 5.2) */
726
+ function MessageRow({
727
+ msg,
728
+ grouped,
729
+ onOpenThread,
730
+ onAction,
731
+ onResolveAction,
732
+ }: {
733
+ msg: ChatMessage;
734
+ grouped: boolean;
735
+ onOpenThread: () => void;
736
+ onAction?: (action: string) => void;
737
+ onResolveAction?: (value: string, label: string) => void;
738
+ }) {
739
+ const state = useAppState();
740
+ const senderLabel = msg.sender === "user" ? "You" : "OpenClaw Agent";
741
+ const avatarColor = msg.sender === "user" ? "#9B59B6" : "#2BAC76";
742
+ const initial = msg.sender === "user" ? "U" : "A";
743
+ const replyCount = state.threadReplyCounts[msg.id] ?? 0;
744
+
745
+ return (
746
+ <div
747
+ className="group relative px-5 hover:bg-[--bg-hover] transition-colors"
748
+ style={{ paddingTop: grouped ? 2 : 8, paddingBottom: 2 }}
749
+ >
750
+ <div className="flex gap-2 max-w-message">
751
+ {/* Avatar column */}
752
+ <div className="flex-shrink-0" style={{ width: 36 }}>
753
+ {!grouped && (
754
+ <div
755
+ className="w-9 h-9 rounded flex items-center justify-center text-white text-caption font-bold"
756
+ style={{ background: avatarColor }}
757
+ >
758
+ {initial}
759
+ </div>
760
+ )}
761
+ </div>
762
+
763
+ {/* Content column */}
764
+ <div className="flex-1 min-w-0">
765
+ {!grouped && (
766
+ <div className="flex items-baseline gap-2 mb-0.5">
767
+ <span className="text-h2" style={{ color: "var(--text-primary)" }}>
768
+ {senderLabel}
769
+ </span>
770
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
771
+ {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
772
+ </span>
773
+ </div>
774
+ )}
775
+ <MessageContent
776
+ text={msg.text}
777
+ mediaUrl={msg.mediaUrl}
778
+ a2ui={msg.a2ui}
779
+ isStreaming={msg.isStreaming}
780
+ onAction={onAction}
781
+ onResolveAction={onResolveAction}
782
+ resolvedActions={msg.resolvedActions}
783
+ />
784
+ {msg.isStreaming && (
785
+ <span
786
+ className="inline-block w-1.5 h-4 ml-0.5 rounded-sm animate-pulse"
787
+ style={{ background: "var(--text-link)", verticalAlign: "text-bottom" }}
788
+ />
789
+ )}
790
+
791
+ {/* Thread bar – shown when this message has thread replies */}
792
+ {replyCount > 0 && (
793
+ <button
794
+ onClick={onOpenThread}
795
+ className="flex items-center gap-2 mt-1 py-1 px-1 -ml-1 rounded hover:bg-[--bg-hover] transition-colors cursor-pointer group/thread"
796
+ >
797
+ <span
798
+ className="text-caption font-bold"
799
+ style={{ color: "var(--text-link)" }}
800
+ >
801
+ {replyCount} {replyCount === 1 ? "reply" : "replies"}
802
+ </span>
803
+ <span
804
+ className="text-caption opacity-0 group-hover/thread:opacity-100 transition-opacity"
805
+ style={{ color: "var(--text-secondary)" }}
806
+ >
807
+ View thread
808
+ </span>
809
+ <svg
810
+ className="w-4 h-4 opacity-0 group-hover/thread:opacity-100 transition-opacity"
811
+ fill="none"
812
+ viewBox="0 0 24 24"
813
+ stroke="currentColor"
814
+ strokeWidth={1.5}
815
+ style={{ color: "var(--text-secondary)" }}
816
+ >
817
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
818
+ </svg>
819
+ </button>
820
+ )}
821
+ </div>
822
+ </div>
823
+
824
+ {/* Action bar (section 5.3) – appears on hover */}
825
+ <div
826
+ className="absolute top-0 right-5 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 px-1 py-0.5 rounded"
827
+ style={{
828
+ background: "var(--bg-surface)",
829
+ border: "1px solid var(--border)",
830
+ boxShadow: "var(--shadow-sm)",
831
+ }}
832
+ >
833
+ <ActionButton label="Reply in thread" icon={
834
+ <svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
835
+ <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
836
+ </svg>
837
+ } onClick={onOpenThread} />
838
+ </div>
839
+ </div>
840
+ );
841
+ }
842
+
843
+ function ActionButton({
844
+ label,
845
+ icon,
846
+ onClick,
847
+ }: {
848
+ label: string;
849
+ icon: React.ReactNode;
850
+ onClick?: () => void;
851
+ }) {
852
+ return (
853
+ <button
854
+ onClick={onClick}
855
+ className="p-1 rounded hover:bg-[--bg-hover] transition-colors"
856
+ style={{ color: "var(--text-secondary)" }}
857
+ title={label}
858
+ aria-label={label}
859
+ >
860
+ {icon}
861
+ </button>
862
+ );
863
+ }
864
+