botschat 0.1.4 → 0.1.7
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 +64 -24
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +7 -2
- package/packages/api/package.json +2 -1
- package/packages/api/src/do/connection-do.ts +162 -42
- package/packages/api/src/index.ts +132 -13
- package/packages/api/src/routes/auth.ts +127 -30
- package/packages/api/src/routes/pairing.ts +14 -1
- package/packages/api/src/routes/setup.ts +72 -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/accounts.d.ts.map +1 -1
- package/packages/plugin/dist/src/accounts.js +1 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +1 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +151 -9
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +16 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +14 -3
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +4 -3
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
- package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
- 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/package.json +1 -0
- package/packages/web/src/App.tsx +286 -103
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +67 -3
- package/packages/web/src/components/ChatWindow.tsx +11 -11
- package/packages/web/src/components/ConnectionSettings.tsx +477 -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/E2ESettings.tsx +122 -0
- package/packages/web/src/components/IconRail.tsx +56 -27
- package/packages/web/src/components/JobList.tsx +2 -6
- package/packages/web/src/components/LoginPage.tsx +143 -104
- package/packages/web/src/components/MobileLayout.tsx +480 -0
- package/packages/web/src/components/OnboardingPage.tsx +159 -21
- 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/e2e.ts +133 -0
- package/packages/web/src/hooks/useIsMobile.ts +27 -0
- package/packages/web/src/index.css +59 -0
- package/packages/web/src/main.tsx +12 -0
- package/packages/web/src/store.ts +16 -8
- package/packages/web/src/ws.ts +78 -4
- package/scripts/dev.sh +16 -16
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- 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
|
+
}
|