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.
- package/README.md +2 -2
- package/package.json +4 -1
- package/packages/api/package.json +2 -1
- package/packages/api/src/do/connection-do.ts +128 -33
- package/packages/api/src/index.ts +103 -6
- package/packages/api/src/routes/auth.ts +123 -29
- package/packages/api/src/routes/pairing.ts +14 -1
- package/packages/api/src/routes/setup.ts +70 -24
- package/packages/api/src/routes/upload.ts +12 -8
- package/packages/api/src/utils/auth.ts +212 -43
- package/packages/api/src/utils/id.ts +30 -14
- package/packages/api/src/utils/rate-limit.ts +73 -0
- package/packages/plugin/dist/src/channel.js +9 -3
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +2 -2
- package/packages/web/dist/assets/{index-DuGeoFJT.css → index-BST9bfvT.css} +1 -1
- package/packages/web/dist/assets/index-Da18EnTa.js +851 -0
- package/packages/web/dist/botschat-icon.svg +4 -0
- package/packages/web/dist/index.html +23 -3
- package/packages/web/dist/manifest.json +24 -0
- package/packages/web/dist/sw.js +40 -0
- package/packages/web/index.html +21 -1
- package/packages/web/src/App.tsx +241 -96
- package/packages/web/src/api.ts +63 -3
- package/packages/web/src/components/ChatWindow.tsx +11 -11
- package/packages/web/src/components/ConnectionSettings.tsx +475 -0
- package/packages/web/src/components/CronDetail.tsx +475 -235
- package/packages/web/src/components/CronSidebar.tsx +1 -1
- package/packages/web/src/components/DebugLogPanel.tsx +116 -3
- package/packages/web/src/components/IconRail.tsx +56 -16
- package/packages/web/src/components/JobList.tsx +2 -6
- package/packages/web/src/components/LoginPage.tsx +126 -103
- package/packages/web/src/components/MobileLayout.tsx +480 -0
- package/packages/web/src/components/OnboardingPage.tsx +7 -16
- package/packages/web/src/components/ResizeHandle.tsx +34 -0
- package/packages/web/src/components/Sidebar.tsx +1 -1
- package/packages/web/src/components/TaskBar.tsx +2 -2
- package/packages/web/src/components/ThreadPanel.tsx +2 -5
- package/packages/web/src/hooks/useIsMobile.ts +27 -0
- package/packages/web/src/index.css +59 -0
- package/packages/web/src/main.tsx +9 -0
- package/packages/web/src/store.ts +12 -5
- package/packages/web/src/ws.ts +2 -0
- package/scripts/dev.sh +13 -13
- package/wrangler.toml +3 -1
- 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
|
-
//
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
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/
|
|
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={{
|
|
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-[
|
|
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-[
|
|
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
|
+
}
|