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.
Files changed (66) hide show
  1. package/README.md +64 -24
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +7 -2
  4. package/packages/api/package.json +2 -1
  5. package/packages/api/src/do/connection-do.ts +162 -42
  6. package/packages/api/src/index.ts +132 -13
  7. package/packages/api/src/routes/auth.ts +127 -30
  8. package/packages/api/src/routes/pairing.ts +14 -1
  9. package/packages/api/src/routes/setup.ts +72 -24
  10. package/packages/api/src/routes/upload.ts +12 -8
  11. package/packages/api/src/utils/auth.ts +212 -43
  12. package/packages/api/src/utils/id.ts +30 -14
  13. package/packages/api/src/utils/rate-limit.ts +73 -0
  14. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  15. package/packages/plugin/dist/src/accounts.js +1 -0
  16. package/packages/plugin/dist/src/accounts.js.map +1 -1
  17. package/packages/plugin/dist/src/channel.d.ts +1 -0
  18. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/channel.js +151 -9
  20. package/packages/plugin/dist/src/channel.js.map +1 -1
  21. package/packages/plugin/dist/src/types.d.ts +16 -0
  22. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  23. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  24. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  25. package/packages/plugin/dist/src/ws-client.js +14 -3
  26. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  27. package/packages/plugin/package.json +4 -3
  28. package/packages/web/dist/architecture.png +0 -0
  29. package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
  30. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
  31. package/packages/web/dist/botschat-icon.svg +4 -0
  32. package/packages/web/dist/index.html +23 -3
  33. package/packages/web/dist/manifest.json +24 -0
  34. package/packages/web/dist/sw.js +40 -0
  35. package/packages/web/index.html +21 -1
  36. package/packages/web/package.json +1 -0
  37. package/packages/web/src/App.tsx +286 -103
  38. package/packages/web/src/analytics.ts +57 -0
  39. package/packages/web/src/api.ts +67 -3
  40. package/packages/web/src/components/ChatWindow.tsx +11 -11
  41. package/packages/web/src/components/ConnectionSettings.tsx +477 -0
  42. package/packages/web/src/components/CronDetail.tsx +475 -235
  43. package/packages/web/src/components/CronSidebar.tsx +1 -1
  44. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  45. package/packages/web/src/components/E2ESettings.tsx +122 -0
  46. package/packages/web/src/components/IconRail.tsx +56 -27
  47. package/packages/web/src/components/JobList.tsx +2 -6
  48. package/packages/web/src/components/LoginPage.tsx +143 -104
  49. package/packages/web/src/components/MobileLayout.tsx +480 -0
  50. package/packages/web/src/components/OnboardingPage.tsx +159 -21
  51. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  52. package/packages/web/src/components/Sidebar.tsx +1 -1
  53. package/packages/web/src/components/TaskBar.tsx +2 -2
  54. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  55. package/packages/web/src/e2e.ts +133 -0
  56. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  57. package/packages/web/src/index.css +59 -0
  58. package/packages/web/src/main.tsx +12 -0
  59. package/packages/web/src/store.ts +16 -8
  60. package/packages/web/src/ws.ts +78 -4
  61. package/scripts/dev.sh +16 -16
  62. package/scripts/test-e2e-live.ts +194 -0
  63. package/scripts/verify-e2e-db.ts +48 -0
  64. package/scripts/verify-e2e.ts +56 -0
  65. package/wrangler.toml +3 -1
  66. 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
+ }