botschat 0.1.3 → 0.1.6

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 (46) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -1
  3. package/packages/api/package.json +2 -1
  4. package/packages/api/src/do/connection-do.ts +128 -33
  5. package/packages/api/src/index.ts +103 -6
  6. package/packages/api/src/routes/auth.ts +123 -29
  7. package/packages/api/src/routes/pairing.ts +14 -1
  8. package/packages/api/src/routes/setup.ts +70 -24
  9. package/packages/api/src/routes/upload.ts +12 -8
  10. package/packages/api/src/utils/auth.ts +212 -43
  11. package/packages/api/src/utils/id.ts +30 -14
  12. package/packages/api/src/utils/rate-limit.ts +73 -0
  13. package/packages/plugin/dist/src/channel.js +9 -3
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/package.json +2 -2
  16. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-BST9bfvT.css} +1 -1
  17. package/packages/web/dist/assets/index-Da18EnTa.js +851 -0
  18. package/packages/web/dist/botschat-icon.svg +4 -0
  19. package/packages/web/dist/index.html +23 -3
  20. package/packages/web/dist/manifest.json +24 -0
  21. package/packages/web/dist/sw.js +40 -0
  22. package/packages/web/index.html +21 -1
  23. package/packages/web/src/App.tsx +241 -96
  24. package/packages/web/src/api.ts +63 -3
  25. package/packages/web/src/components/ChatWindow.tsx +11 -11
  26. package/packages/web/src/components/ConnectionSettings.tsx +475 -0
  27. package/packages/web/src/components/CronDetail.tsx +475 -235
  28. package/packages/web/src/components/CronSidebar.tsx +1 -1
  29. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  30. package/packages/web/src/components/IconRail.tsx +56 -16
  31. package/packages/web/src/components/JobList.tsx +2 -6
  32. package/packages/web/src/components/LoginPage.tsx +126 -103
  33. package/packages/web/src/components/MobileLayout.tsx +480 -0
  34. package/packages/web/src/components/OnboardingPage.tsx +7 -16
  35. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  36. package/packages/web/src/components/Sidebar.tsx +1 -1
  37. package/packages/web/src/components/TaskBar.tsx +2 -2
  38. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  39. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  40. package/packages/web/src/index.css +59 -0
  41. package/packages/web/src/main.tsx +9 -0
  42. package/packages/web/src/store.ts +12 -5
  43. package/packages/web/src/ws.ts +2 -0
  44. package/scripts/dev.sh +13 -13
  45. package/wrangler.toml +3 -1
  46. package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
@@ -0,0 +1,480 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import { useAppState, useAppDispatch, type ActiveView } from "../store";
3
+ import { setToken, setRefreshToken } from "../api";
4
+ import type { WSMessage } from "../ws";
5
+ import { Sidebar } from "./Sidebar";
6
+ import { ChatWindow } from "./ChatWindow";
7
+ import { ThreadPanel } from "./ThreadPanel";
8
+ import { JobList } from "./JobList";
9
+ import { CronSidebar } from "./CronSidebar";
10
+ import { CronDetail } from "./CronDetail";
11
+ import { ModelSelect } from "./ModelSelect";
12
+ import { ConnectionSettings } from "./ConnectionSettings";
13
+ import { dlog } from "../debug-log";
14
+
15
+ type MobileScreen =
16
+ | "channel-list"
17
+ | "chat"
18
+ | "thread"
19
+ | "cron-list"
20
+ | "cron-detail";
21
+
22
+ type MobileLayoutProps = {
23
+ sendMessage: (msg: WSMessage) => void;
24
+ theme: "dark" | "light";
25
+ onToggleTheme: () => void;
26
+ showSettings: boolean;
27
+ onOpenSettings: () => void;
28
+ onCloseSettings: () => void;
29
+ handleDefaultModelChange: (modelId: string) => Promise<void>;
30
+ handleSelectJob: (jobId: string) => void;
31
+ };
32
+
33
+ export function MobileLayout({
34
+ sendMessage,
35
+ theme,
36
+ onToggleTheme,
37
+ showSettings,
38
+ onOpenSettings,
39
+ onCloseSettings,
40
+ handleDefaultModelChange,
41
+ handleSelectJob,
42
+ }: MobileLayoutProps) {
43
+ const state = useAppState();
44
+ const dispatch = useAppDispatch();
45
+
46
+ // Mobile navigation state — stack-based
47
+ const [screen, setScreen] = useState<MobileScreen>(() => {
48
+ if (state.activeView === "automations") return "cron-list";
49
+ if (state.selectedAgentId && state.selectedSessionKey) return "chat";
50
+ return "channel-list";
51
+ });
52
+
53
+ const [showUserMenu, setShowUserMenu] = useState(false);
54
+
55
+ const activeTab = state.activeView;
56
+
57
+ const setActiveTab = useCallback((view: ActiveView) => {
58
+ dispatch({ type: "SET_ACTIVE_VIEW", view });
59
+ if (view === "messages") {
60
+ if (state.selectedAgentId && state.selectedSessionKey) {
61
+ setScreen("chat");
62
+ } else {
63
+ setScreen("channel-list");
64
+ }
65
+ } else {
66
+ if (state.selectedCronTaskId) {
67
+ setScreen("cron-detail");
68
+ } else {
69
+ setScreen("cron-list");
70
+ }
71
+ }
72
+ }, [dispatch, state.selectedAgentId, state.selectedSessionKey, state.selectedCronTaskId]);
73
+
74
+ // Navigate to chat when a channel is selected (via Sidebar's internal dispatch)
75
+ // We listen for selectedAgentId changes to auto-navigate
76
+ const prevAgentIdRef = React.useRef(state.selectedAgentId);
77
+ React.useEffect(() => {
78
+ if (state.selectedAgentId && state.selectedAgentId !== prevAgentIdRef.current && screen === "channel-list") {
79
+ setScreen("chat");
80
+ }
81
+ prevAgentIdRef.current = state.selectedAgentId;
82
+ }, [state.selectedAgentId, screen]);
83
+
84
+ // Navigate to cron detail when a cron task is selected
85
+ const prevCronTaskIdRef = React.useRef(state.selectedCronTaskId);
86
+ React.useEffect(() => {
87
+ if (state.selectedCronTaskId && state.selectedCronTaskId !== prevCronTaskIdRef.current && screen === "cron-list") {
88
+ setScreen("cron-detail");
89
+ }
90
+ prevCronTaskIdRef.current = state.selectedCronTaskId;
91
+ }, [state.selectedCronTaskId, screen]);
92
+
93
+ // Navigate to thread when thread opens
94
+ React.useEffect(() => {
95
+ if (state.activeThreadId && screen === "chat") {
96
+ setScreen("thread");
97
+ }
98
+ }, [state.activeThreadId, screen]);
99
+
100
+ // Navigate back from thread when it closes
101
+ React.useEffect(() => {
102
+ if (!state.activeThreadId && screen === "thread") {
103
+ setScreen("chat");
104
+ }
105
+ }, [state.activeThreadId, screen]);
106
+
107
+ const handleLogout = () => {
108
+ dlog.info("Auth", `Mobile logout — user ${state.user?.email}`);
109
+ setToken(null);
110
+ setRefreshToken(null);
111
+ dispatch({ type: "LOGOUT" });
112
+ };
113
+
114
+ const userInitial = state.user?.displayName?.[0]?.toUpperCase()
115
+ ?? state.user?.email?.[0]?.toUpperCase()
116
+ ?? "?";
117
+
118
+ const goBack = useCallback(() => {
119
+ switch (screen) {
120
+ case "chat":
121
+ setScreen("channel-list");
122
+ break;
123
+ case "thread":
124
+ dispatch({ type: "CLOSE_THREAD" });
125
+ setScreen("chat");
126
+ break;
127
+ case "cron-detail":
128
+ setScreen("cron-list");
129
+ break;
130
+ default:
131
+ break;
132
+ }
133
+ }, [screen, dispatch]);
134
+
135
+ // Determine the header title
136
+ const getHeaderTitle = (): string => {
137
+ switch (screen) {
138
+ case "channel-list":
139
+ return "BotsChat";
140
+ case "chat": {
141
+ const agent = state.agents.find((a) => a.id === state.selectedAgentId);
142
+ return `# ${agent?.name ?? "Chat"}`;
143
+ }
144
+ case "thread":
145
+ return "Thread";
146
+ case "cron-list":
147
+ return "Automations";
148
+ case "cron-detail": {
149
+ const task = state.cronTasks.find((t) => t.id === state.selectedCronTaskId);
150
+ return task?.name ?? "Task Detail";
151
+ }
152
+ default:
153
+ return "BotsChat";
154
+ }
155
+ };
156
+
157
+ const showBackButton = screen !== "channel-list" && screen !== "cron-list";
158
+
159
+ return (
160
+ <div
161
+ className="flex flex-col h-screen"
162
+ style={{ background: "var(--bg-surface)" }}
163
+ >
164
+ {/* ---- Top nav bar (44px + safe area for standalone PWA) ---- */}
165
+ <div
166
+ className="flex items-center justify-between px-4 flex-shrink-0"
167
+ style={{
168
+ height: 44,
169
+ paddingTop: "env(safe-area-inset-top, 0px)",
170
+ background: "var(--bg-primary)",
171
+ borderBottom: "1px solid var(--border)",
172
+ }}
173
+ >
174
+ <div className="flex items-center gap-2 min-w-0">
175
+ {showBackButton && (
176
+ <button
177
+ onClick={goBack}
178
+ className="p-1 -ml-1 rounded"
179
+ style={{ color: "var(--text-link)" }}
180
+ aria-label="Back"
181
+ >
182
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
183
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
184
+ </svg>
185
+ </button>
186
+ )}
187
+ <span
188
+ className="text-h2 font-bold truncate"
189
+ style={{ color: "var(--text-primary)" }}
190
+ >
191
+ {getHeaderTitle()}
192
+ </span>
193
+ </div>
194
+
195
+ <div className="flex items-center gap-1">
196
+ {/* Connection indicator */}
197
+ <div
198
+ className="w-2 h-2 rounded-full mr-1"
199
+ style={{ background: state.openclawConnected ? "var(--accent-green)" : "var(--accent-red)" }}
200
+ />
201
+ {/* Settings */}
202
+ <button
203
+ onClick={onOpenSettings}
204
+ className="p-1.5 rounded"
205
+ style={{ color: "var(--text-muted)" }}
206
+ >
207
+ <svg className="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
208
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
209
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
210
+ </svg>
211
+ </button>
212
+ {/* User avatar */}
213
+ <button
214
+ onClick={() => setShowUserMenu(!showUserMenu)}
215
+ className="w-6 h-6 rounded flex items-center justify-center text-[10px] font-bold text-white"
216
+ style={{ background: "#9B59B6" }}
217
+ title={state.user?.displayName ?? state.user?.email ?? "User"}
218
+ >
219
+ {userInitial}
220
+ </button>
221
+ </div>
222
+ </div>
223
+
224
+ {/* ---- User menu dropdown ---- */}
225
+ {showUserMenu && (
226
+ <div className="fixed inset-0 z-50" onClick={() => setShowUserMenu(false)}>
227
+ <div
228
+ className="absolute right-4 rounded-lg py-1 min-w-[200px]"
229
+ style={{
230
+ top: `calc(44px + env(safe-area-inset-top, 0px) + 4px)`,
231
+ background: "var(--bg-surface)",
232
+ border: "1px solid var(--border)",
233
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
234
+ }}
235
+ onClick={(e) => e.stopPropagation()}
236
+ >
237
+ <div className="px-4 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
238
+ <div className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
239
+ {state.user?.displayName ?? "User"}
240
+ </div>
241
+ <div className="text-caption" style={{ color: "var(--text-muted)" }}>
242
+ {state.user?.email}
243
+ </div>
244
+ </div>
245
+ <button
246
+ className="w-full text-left px-4 py-2.5 text-body flex items-center gap-2.5"
247
+ style={{ color: "var(--text-primary)" }}
248
+ onClick={() => { onToggleTheme(); setShowUserMenu(false); }}
249
+ >
250
+ {theme === "dark" ? (
251
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
252
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
253
+ </svg>
254
+ ) : (
255
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
256
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
257
+ </svg>
258
+ )}
259
+ {theme === "dark" ? "Light Mode" : "Dark Mode"}
260
+ </button>
261
+ <div style={{ borderTop: "1px solid var(--border)" }} />
262
+ <button
263
+ className="w-full text-left px-4 py-2.5 text-body flex items-center gap-2.5"
264
+ style={{ color: "var(--accent-red)" }}
265
+ onClick={() => { handleLogout(); setShowUserMenu(false); }}
266
+ >
267
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
268
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
269
+ </svg>
270
+ Logout
271
+ </button>
272
+ </div>
273
+ </div>
274
+ )}
275
+
276
+ {/* ---- Screen content ---- */}
277
+ <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
278
+ {screen === "channel-list" && (
279
+ <div className="flex-1 min-h-0 overflow-y-auto" style={{ background: "var(--bg-secondary)" }}>
280
+ <Sidebar />
281
+ </div>
282
+ )}
283
+
284
+ {screen === "chat" && (
285
+ <div className="flex-1 min-h-0 flex flex-col">
286
+ <ChatWindow sendMessage={sendMessage} />
287
+ </div>
288
+ )}
289
+
290
+ {screen === "thread" && (
291
+ <div className="flex-1 min-h-0 flex flex-col">
292
+ <ThreadPanel sendMessage={sendMessage} />
293
+ </div>
294
+ )}
295
+
296
+ {screen === "cron-list" && (
297
+ <div className="flex-1 min-h-0 overflow-y-auto" style={{ background: "var(--bg-secondary)" }}>
298
+ <CronSidebar />
299
+ </div>
300
+ )}
301
+
302
+ {screen === "cron-detail" && (
303
+ <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
304
+ <CronDetail />
305
+ </div>
306
+ )}
307
+ </div>
308
+
309
+ {/* ---- Bottom tab bar (56px) ---- */}
310
+ <div
311
+ className="flex items-stretch flex-shrink-0"
312
+ style={{
313
+ height: 56,
314
+ background: "var(--bg-primary)",
315
+ borderTop: "1px solid var(--border)",
316
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
317
+ }}
318
+ >
319
+ <TabButton
320
+ label="Messages"
321
+ active={activeTab === "messages"}
322
+ onClick={() => setActiveTab("messages")}
323
+ icon={
324
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
325
+ <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" />
326
+ </svg>
327
+ }
328
+ />
329
+ <TabButton
330
+ label="Automations"
331
+ active={activeTab === "automations"}
332
+ onClick={() => setActiveTab("automations")}
333
+ icon={
334
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
335
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
336
+ </svg>
337
+ }
338
+ />
339
+ </div>
340
+
341
+ {/* Settings modal */}
342
+ {showSettings && (
343
+ <MobileSettingsModal
344
+ state={state}
345
+ onClose={onCloseSettings}
346
+ handleDefaultModelChange={handleDefaultModelChange}
347
+ />
348
+ )}
349
+ </div>
350
+ );
351
+ }
352
+
353
+ /** Mobile Settings modal — extracted to keep layout clean */
354
+ function MobileSettingsModal({
355
+ state,
356
+ onClose,
357
+ handleDefaultModelChange,
358
+ }: {
359
+ state: ReturnType<typeof useAppState>;
360
+ onClose: () => void;
361
+ handleDefaultModelChange: (modelId: string) => Promise<void>;
362
+ }) {
363
+ const [tab, setTab] = useState<"general" | "connection">("general");
364
+
365
+ return (
366
+ <div
367
+ className="fixed inset-0 flex items-end justify-center z-50"
368
+ style={{ background: "rgba(0,0,0,0.5)" }}
369
+ onClick={onClose}
370
+ >
371
+ <div
372
+ className="w-full rounded-t-xl p-5 max-h-[85vh] flex flex-col"
373
+ style={{
374
+ background: "var(--bg-surface)",
375
+ paddingBottom: "calc(20px + env(safe-area-inset-bottom, 0px))",
376
+ }}
377
+ onClick={(e) => e.stopPropagation()}
378
+ >
379
+ <div className="w-10 h-1 rounded-full mx-auto mb-4" style={{ background: "var(--text-muted)" }} />
380
+ <h2 className="text-h1 font-bold mb-3" style={{ color: "var(--text-primary)" }}>
381
+ Settings
382
+ </h2>
383
+
384
+ {/* Tab bar */}
385
+ <div className="flex gap-4 mb-4" style={{ borderBottom: "1px solid var(--border)" }}>
386
+ <button
387
+ className="pb-2 text-caption font-bold transition-colors"
388
+ style={{
389
+ color: tab === "general" ? "var(--text-primary)" : "var(--text-muted)",
390
+ borderBottom: tab === "general" ? "2px solid var(--bg-active)" : "2px solid transparent",
391
+ marginBottom: "-1px",
392
+ }}
393
+ onClick={() => setTab("general")}
394
+ >
395
+ General
396
+ </button>
397
+ <button
398
+ className="pb-2 text-caption font-bold transition-colors"
399
+ style={{
400
+ color: tab === "connection" ? "var(--text-primary)" : "var(--text-muted)",
401
+ borderBottom: tab === "connection" ? "2px solid var(--bg-active)" : "2px solid transparent",
402
+ marginBottom: "-1px",
403
+ }}
404
+ onClick={() => setTab("connection")}
405
+ >
406
+ Connection
407
+ </button>
408
+ </div>
409
+
410
+ {/* Tab content — scrollable */}
411
+ <div className="flex-1 min-h-0 overflow-y-auto">
412
+ {tab === "general" && (
413
+ <div className="space-y-4">
414
+ <div>
415
+ <label className="block text-caption font-bold mb-1.5" style={{ color: "var(--text-secondary)" }}>
416
+ Default Model
417
+ </label>
418
+ <ModelSelect
419
+ value={state.defaultModel ?? ""}
420
+ onChange={handleDefaultModelChange}
421
+ models={state.models}
422
+ placeholder="Not set (use agent default)"
423
+ />
424
+ </div>
425
+
426
+ <div>
427
+ <label className="block text-caption font-bold mb-1.5" style={{ color: "var(--text-secondary)" }}>
428
+ Current Session Model
429
+ </label>
430
+ <span
431
+ className="text-body font-mono"
432
+ style={{ color: (state.sessionModel || state.defaultModel) ? "var(--text-primary)" : "var(--text-muted)" }}
433
+ >
434
+ {state.sessionModel ?? state.defaultModel ?? "Not connected"}
435
+ </span>
436
+ </div>
437
+ </div>
438
+ )}
439
+
440
+ {tab === "connection" && (
441
+ <ConnectionSettings />
442
+ )}
443
+ </div>
444
+
445
+ <button
446
+ onClick={onClose}
447
+ className="w-full mt-4 py-2.5 text-caption font-bold text-white rounded-md shrink-0"
448
+ style={{ background: "var(--bg-active)" }}
449
+ >
450
+ Done
451
+ </button>
452
+ </div>
453
+ </div>
454
+ );
455
+ }
456
+
457
+ function TabButton({
458
+ label,
459
+ active,
460
+ onClick,
461
+ icon,
462
+ }: {
463
+ label: string;
464
+ active: boolean;
465
+ onClick: () => void;
466
+ icon: React.ReactNode;
467
+ }) {
468
+ return (
469
+ <button
470
+ onClick={onClick}
471
+ className="flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors"
472
+ style={{
473
+ color: active ? "var(--text-link)" : "var(--text-muted)",
474
+ }}
475
+ >
476
+ {icon}
477
+ <span className="text-[10px] leading-tight">{label}</span>
478
+ </button>
479
+ );
480
+ }
@@ -116,28 +116,19 @@ export function OnboardingPage({ onSkip }: { onSkip: () => void }) {
116
116
  return () => { cancelled = true; };
117
117
  }, []);
118
118
 
119
- // Load or create pairing token
119
+ // Create a pairing token for the setup command.
120
+ // Note: GET /pairing-tokens only returns masked tokenPreview (security),
121
+ // so we always create a fresh token for onboarding display.
120
122
  useEffect(() => {
121
123
  let cancelled = false;
122
124
 
123
125
  async function ensurePairingToken() {
124
126
  setLoadingToken(true);
125
127
  try {
126
- // Check existing tokens
127
- const { tokens } = await pairingApi.list();
128
- if (cancelled) return;
129
-
130
- if (tokens.length > 0) {
131
- dlog.info("Onboarding", `Found ${tokens.length} existing pairing tokens`);
132
- const { token } = await pairingApi.create("Onboarding setup");
133
- if (!cancelled) setPairingToken(token);
134
- } else {
135
- dlog.info("Onboarding", "No pairing tokens found, creating one");
136
- const { token } = await pairingApi.create("Default");
137
- if (!cancelled) setPairingToken(token);
138
- }
128
+ const { token } = await pairingApi.create("Default");
129
+ if (!cancelled) setPairingToken(token);
139
130
  } catch (err) {
140
- dlog.error("Onboarding", `Failed to get pairing token: ${err}`);
131
+ dlog.error("Onboarding", `Failed to create pairing token: ${err}`);
141
132
  } finally {
142
133
  if (!cancelled) setLoadingToken(false);
143
134
  }
@@ -148,7 +139,7 @@ export function OnboardingPage({ onSkip }: { onSkip: () => void }) {
148
139
  }, []);
149
140
 
150
141
  const setupCommand = pairingToken
151
- ? `openclaw plugins install @botschat/openclaw-plugin && \\
142
+ ? `openclaw plugins install @botschat/botschat && \\
152
143
  openclaw config set channels.botschat.cloudUrl ${cloudUrl} && \\
153
144
  openclaw config set channels.botschat.pairingToken ${pairingToken} && \\
154
145
  openclaw config set channels.botschat.enabled true && \\
@@ -0,0 +1,34 @@
1
+ import React from "react";
2
+ import { Separator } from "react-resizable-panels";
3
+
4
+ type ResizeHandleProps = {
5
+ direction?: "horizontal" | "vertical";
6
+ className?: string;
7
+ };
8
+
9
+ /**
10
+ * Custom drag handle for react-resizable-panels v4.
11
+ * Default (1px line) → Hover/Active (3px, blue highlight).
12
+ */
13
+ export function ResizeHandle({ direction = "horizontal", className = "" }: ResizeHandleProps) {
14
+ const isVertical = direction === "vertical";
15
+
16
+ return (
17
+ <Separator
18
+ className={`resize-handle group relative flex items-center justify-center ${
19
+ isVertical ? "h-[6px] cursor-row-resize" : "w-[6px] cursor-col-resize"
20
+ } ${className}`}
21
+ style={{ flexShrink: 0, flexGrow: 0 }}
22
+ >
23
+ {/* Visible line */}
24
+ <div
25
+ className={`resize-handle-line transition-all duration-150 ${
26
+ isVertical
27
+ ? "w-full h-px group-hover:h-[3px]"
28
+ : "h-full w-px group-hover:w-[3px]"
29
+ }`}
30
+ style={{ background: "var(--border)" }}
31
+ />
32
+ </Separator>
33
+ );
34
+ }
@@ -81,7 +81,7 @@ export function Sidebar() {
81
81
  return (
82
82
  <div
83
83
  className="flex flex-col h-full"
84
- style={{ width: 220, minWidth: 160, background: "var(--bg-secondary)", borderRight: "1px solid var(--border)" }}
84
+ style={{ background: "var(--bg-secondary)" }}
85
85
  >
86
86
  {/* Workspace Switcher */}
87
87
  <div className="px-4 py-3 flex items-center gap-2">
@@ -274,7 +274,7 @@ export function TaskBar() {
274
274
  placeholder="e.g., every 6h or cron 0 */6 * * *"
275
275
  value={newSchedule}
276
276
  onChange={(e) => setNewSchedule(e.target.value)}
277
- className="px-2 py-1 text-caption rounded-sm focus:outline-none flex-1 min-w-[200px] placeholder:text-[--text-muted]"
277
+ className="px-2 py-1 text-caption rounded-sm focus:outline-none flex-1 min-w-[100px] placeholder:text-[--text-muted]"
278
278
  style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
279
279
  />
280
280
  )}
@@ -283,7 +283,7 @@ export function TaskBar() {
283
283
  placeholder="Agent instructions per run..."
284
284
  value={newInstructions}
285
285
  onChange={(e) => setNewInstructions(e.target.value)}
286
- className="px-2 py-1 text-caption rounded-sm focus:outline-none flex-1 min-w-[200px] placeholder:text-[--text-muted]"
286
+ className="px-2 py-1 text-caption rounded-sm focus:outline-none flex-1 min-w-[100px] placeholder:text-[--text-muted]"
287
287
  style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
288
288
  />
289
289
  </div>
@@ -71,15 +71,12 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
71
71
  <div
72
72
  className="flex flex-col h-full"
73
73
  style={{
74
- width: 420,
75
- minWidth: 320,
76
74
  background: "var(--bg-surface)",
77
- borderLeft: "1px solid var(--border)",
78
75
  }}
79
76
  >
80
77
  {/* Header */}
81
78
  <div
82
- className="flex items-center justify-between px-4"
79
+ className="flex items-center justify-between px-4 flex-shrink-0"
83
80
  style={{ height: 44, borderBottom: "1px solid var(--border)" }}
84
81
  >
85
82
  <h3 className="text-h1" style={{ color: "var(--text-primary)" }}>Thread</h3>
@@ -96,7 +93,7 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
96
93
  </div>
97
94
 
98
95
  {/* Scrollable area: parent message + replies */}
99
- <div className="flex-1 overflow-y-auto">
96
+ <div className="flex-1 min-h-0 overflow-y-auto">
100
97
  {/* Parent message */}
101
98
  {parentMessage && (
102
99
  <div className="px-5 py-3" style={{ borderBottom: "1px solid var(--border)" }}>
@@ -0,0 +1,27 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ const MOBILE_BREAKPOINT = 768; // px — matches Tailwind's `md`
4
+
5
+ /**
6
+ * Returns true when the viewport is narrower than the mobile breakpoint.
7
+ * Listens to `matchMedia` changes so it updates live on window resize.
8
+ */
9
+ export function useIsMobile(): boolean {
10
+ const [isMobile, setIsMobile] = useState(() => {
11
+ if (typeof window === "undefined") return false;
12
+ return window.innerWidth < MOBILE_BREAKPOINT;
13
+ });
14
+
15
+ useEffect(() => {
16
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
17
+ const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
18
+
19
+ // Set initial value (SSR hydration guard)
20
+ setIsMobile(mql.matches);
21
+
22
+ mql.addEventListener("change", handler);
23
+ return () => mql.removeEventListener("change", handler);
24
+ }, []);
25
+
26
+ return isMobile;
27
+ }