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
@@ -1,4 +1,5 @@
1
1
  import React, { useReducer, useEffect, useCallback, useRef, useState } from "react";
2
+ import { Group, Panel, useDefaultLayout } from "react-resizable-panels";
2
3
  import {
3
4
  appReducer,
4
5
  initialState,
@@ -8,7 +9,7 @@ import {
8
9
  type AppState,
9
10
  type ActiveView,
10
11
  } from "./store";
11
- import { getToken, setToken, agentsApi, channelsApi, tasksApi, jobsApi, authApi, messagesApi, modelsApi, meApi, sessionsApi, type ModelInfo } from "./api";
12
+ import { getToken, setToken, setRefreshToken, agentsApi, channelsApi, tasksApi, jobsApi, authApi, messagesApi, modelsApi, meApi, sessionsApi, type ModelInfo } from "./api";
12
13
  import { ModelSelect } from "./components/ModelSelect";
13
14
  import { BotsChatWSClient, type WSMessage } from "./ws";
14
15
  import { IconRail } from "./components/IconRail";
@@ -18,10 +19,17 @@ import { ThreadPanel } from "./components/ThreadPanel";
18
19
  import { JobList } from "./components/JobList";
19
20
  import { LoginPage } from "./components/LoginPage";
20
21
  import { OnboardingPage } from "./components/OnboardingPage";
22
+ import { ConnectionSettings } from "./components/ConnectionSettings";
23
+ import { E2ESettings } from "./components/E2ESettings";
21
24
  import { DebugLogPanel } from "./components/DebugLogPanel";
22
25
  import { CronSidebar } from "./components/CronSidebar";
23
26
  import { CronDetail } from "./components/CronDetail";
27
+ import { ResizeHandle } from "./components/ResizeHandle";
28
+ import { useIsMobile } from "./hooks/useIsMobile";
29
+ import { MobileLayout } from "./components/MobileLayout";
24
30
  import { dlog } from "./debug-log";
31
+ import { E2eService } from "./e2e";
32
+ import { gtagPageView } from "./analytics";
25
33
 
26
34
  export default function App() {
27
35
  const [state, dispatch] = useReducer(appReducer, initialState, (init): AppState => {
@@ -36,8 +44,18 @@ export default function App() {
36
44
  });
37
45
  const wsClientRef = useRef<BotsChatWSClient | null>(null);
38
46
  const handleWSMessageRef = useRef<(msg: WSMessage) => void>(() => {});
47
+ const creatingGeneralRef = useRef(false);
39
48
 
40
49
  const [showSettings, setShowSettings] = useState(false);
50
+ const [settingsTab, setSettingsTab] = useState<"general" | "connection" | "security">("general");
51
+
52
+ // Track whether the initial channels fetch has completed (prevents onboarding flash)
53
+ const [channelsLoadedOnce, setChannelsLoadedOnce] = useState(false);
54
+
55
+ // Responsive layout hooks (must be called unconditionally)
56
+ const isMobile = useIsMobile();
57
+ const mainLayout = useDefaultLayout({ id: "botschat-main" });
58
+ const contentLayout = useDefaultLayout({ id: "botschat-content" });
41
59
 
42
60
  // Onboarding: show setup page for new users who haven't connected OpenClaw yet.
43
61
  // Once dismissed (skip or connected), we remember it for this session.
@@ -60,6 +78,9 @@ export default function App() {
60
78
  useEffect(() => {
61
79
  document.documentElement.setAttribute("data-theme", theme);
62
80
  localStorage.setItem("botschat_theme", theme);
81
+ // Sync PWA theme-color meta tag with current theme
82
+ const meta = document.querySelector('meta[name="theme-color"]');
83
+ if (meta) meta.setAttribute("content", theme === "dark" ? "#1A1D21" : "#FFFFFF");
63
84
  }, [theme]);
64
85
 
65
86
  // Persist active view (messages / automations)
@@ -67,6 +88,11 @@ export default function App() {
67
88
  localStorage.setItem("botschat_active_view", state.activeView);
68
89
  }, [state.activeView]);
69
90
 
91
+ // Google Analytics: track virtual page views for SPA tabs
92
+ useEffect(() => {
93
+ gtagPageView(state.activeView);
94
+ }, [state.activeView]);
95
+
70
96
  // Persist selected cron task for automations view
71
97
  useEffect(() => {
72
98
  if (state.selectedCronTaskId) {
@@ -98,13 +124,12 @@ export default function App() {
98
124
  .then((user) => {
99
125
  dlog.info("Auth", `Logged in as ${user.email} (${user.id})`);
100
126
  dispatch({ type: "SET_USER", user });
101
- if (user.settings?.defaultModel) {
102
- dispatch({ type: "SET_DEFAULT_MODEL", model: user.settings.defaultModel });
103
- }
127
+ // defaultModel comes from plugin via connection.status, not from user.settings
104
128
  })
105
129
  .catch((err) => {
106
130
  dlog.warn("Auth", `Auto-login failed: ${err}`);
107
131
  setToken(null);
132
+ setRefreshToken(null);
108
133
  });
109
134
  }
110
135
  }, []);
@@ -154,6 +179,7 @@ export default function App() {
154
179
  channelsApi.list().then(({ channels }) => {
155
180
  dlog.info("Channels", `Loaded ${channels.length} channels`, channels.map((c) => ({ id: c.id, name: c.name })));
156
181
  dispatch({ type: "SET_CHANNELS", channels });
182
+ setChannelsLoadedOnce(true);
157
183
  });
158
184
  }
159
185
  }, [state.user]);
@@ -263,12 +289,48 @@ export default function App() {
263
289
  }).catch((err) => {
264
290
  dlog.error("Sessions", `Failed to load sessions: ${err}`);
265
291
  });
292
+ } else if (agent?.isDefault && !creatingGeneralRef.current && onboardingDismissed) {
293
+ // Default agent has no channelId yet — auto-create the "General" channel
294
+ // (which also creates "Session 1") so the user sees a chat immediately.
295
+ // Gated by onboardingDismissed to avoid flashing the onboarding page away.
296
+ dlog.info("Sessions", "Default agent has no channel — auto-creating General channel");
297
+ creatingGeneralRef.current = true;
298
+ dispatch({ type: "SET_TASKS", tasks: [] });
299
+ dispatch({ type: "SELECT_TASK", taskId: null });
300
+ dispatch({ type: "SET_SESSIONS", sessions: [] });
301
+
302
+ channelsApi
303
+ .create({ name: "General", openclawAgentId: "main" })
304
+ .then(async (channel) => {
305
+ dlog.info("Sessions", `General channel created: ${channel.id}`);
306
+ // Reload agents and channels so the default agent picks up the new channelId
307
+ const [{ agents: freshAgents }, { channels: freshChannels }] = await Promise.all([
308
+ agentsApi.list(),
309
+ channelsApi.list(),
310
+ ]);
311
+ dispatch({ type: "SET_AGENTS", agents: freshAgents });
312
+ dispatch({ type: "SET_CHANNELS", channels: freshChannels });
313
+ // Channel creation auto-creates "Session 1" — load and select it
314
+ const { sessions: newSessions } = await sessionsApi.list(channel.id);
315
+ dispatch({ type: "SET_SESSIONS", sessions: newSessions });
316
+ if (newSessions.length > 0) {
317
+ dispatch({
318
+ type: "SELECT_SESSION",
319
+ sessionId: newSessions[0].id,
320
+ sessionKey: newSessions[0].sessionKey,
321
+ });
322
+ }
323
+ })
324
+ .catch((err) => {
325
+ dlog.error("Sessions", `Failed to auto-create General channel: ${err}`);
326
+ creatingGeneralRef.current = false;
327
+ });
266
328
  } else {
267
329
  dispatch({ type: "SET_TASKS", tasks: [] });
268
330
  dispatch({ type: "SELECT_TASK", taskId: null });
269
331
  dispatch({ type: "SET_SESSIONS", sessions: [] });
270
332
  }
271
- }, [state.selectedAgentId, selectedAgentChannelId, isMessagesView]);
333
+ }, [state.selectedAgentId, selectedAgentChannelId, isMessagesView, onboardingDismissed]);
272
334
 
273
335
  // ---- Load jobs when a background task is selected ----
274
336
  useEffect(() => {
@@ -305,12 +367,29 @@ export default function App() {
305
367
  let stale = false;
306
368
  messagesApi
307
369
  .list(state.user.id, state.selectedSessionKey)
308
- .then(({ messages, replyCounts }) => {
370
+ .then(async ({ messages, replyCounts }) => {
309
371
  // Guard against stale responses when the user rapidly switches channels:
310
372
  // the cleanup function sets `stale = true` before the new effect runs.
311
- if (!stale) {
312
- dispatch({ type: "SET_MESSAGES", messages, replyCounts });
313
- }
373
+ if (stale) return;
374
+
375
+ // Decrypt history if possible
376
+ const decryptedMessages = await Promise.all(messages.map(async (m) => {
377
+ if (m.encrypted && E2eService.hasKey()) {
378
+ try {
379
+ // Use message ID as context ID (nonce source)
380
+ const plaintext = await E2eService.decrypt(m.text, m.id);
381
+ return { ...m, text: plaintext, isEncryptedLocked: false };
382
+ } catch (err) {
383
+ console.warn(`Failed to decrypt message ${m.id}`, err);
384
+ return { ...m, isEncryptedLocked: true };
385
+ }
386
+ } else if (m.encrypted) {
387
+ return { ...m, isEncryptedLocked: true };
388
+ }
389
+ return m;
390
+ }));
391
+
392
+ dispatch({ type: "SET_MESSAGES", messages: decryptedMessages as ChatMessage[], replyCounts });
314
393
  })
315
394
  .catch((err) => {
316
395
  console.error("Failed to load message history:", err);
@@ -689,11 +768,10 @@ export default function App() {
689
768
  );
690
769
  }
691
770
 
692
- // Show onboarding for new users: no channels loaded yet AND not dismissed
693
- // Wait until channels have been fetched (they're loaded in the useEffect above)
694
- // to avoid flashing onboarding for returning users.
695
- const channelsLoaded = state.channels.length > 0;
696
- const showOnboarding = !onboardingDismissed && !channelsLoaded && !state.openclawConnected;
771
+ // Show onboarding for new users: channels have been fetched (first API call completed)
772
+ // and none exist. This prevents flashing onboarding for returning users whose
773
+ // channel list simply hasn't loaded yet.
774
+ const showOnboarding = !onboardingDismissed && channelsLoadedOnce && state.channels.length === 0 && !state.openclawConnected;
697
775
 
698
776
  if (showOnboarding) {
699
777
  return (
@@ -712,67 +790,122 @@ export default function App() {
712
790
 
713
791
  const isAutomationsView = state.activeView === "automations";
714
792
 
793
+ // ---- Mobile layout ----
794
+ if (isMobile) {
795
+ return (
796
+ <AppStateContext.Provider value={state}>
797
+ <AppDispatchContext.Provider value={dispatch}>
798
+ <MobileLayout
799
+ sendMessage={sendMessage}
800
+ theme={theme}
801
+ onToggleTheme={toggleTheme}
802
+ showSettings={showSettings}
803
+ onOpenSettings={() => setShowSettings(true)}
804
+ onCloseSettings={() => setShowSettings(false)}
805
+ handleDefaultModelChange={handleDefaultModelChange}
806
+ handleSelectJob={handleSelectJob}
807
+ />
808
+ </AppDispatchContext.Provider>
809
+ </AppStateContext.Provider>
810
+ );
811
+ }
812
+
813
+ // ---- Desktop layout with resizable panels ----
715
814
  return (
716
815
  <AppStateContext.Provider value={state}>
717
816
  <AppDispatchContext.Provider value={dispatch}>
718
817
  <div className="flex flex-col h-screen">
719
818
  <div className="flex flex-1 min-h-0">
720
- {/* Icon Rail (68px fixed) */}
819
+ {/* Icon Rail fixed 48px, outside Group */}
721
820
  <IconRail onToggleTheme={toggleTheme} onOpenSettings={() => setShowSettings(true)} theme={theme} />
722
821
 
723
- {/* Sidebar (220px) switches based on active view */}
724
- {isAutomationsView ? <CronSidebar /> : <Sidebar />}
725
-
726
- {/* Main content area (flex) */}
727
- {isAutomationsView ? (
728
- <CronDetail />
729
- ) : (
730
- <div className="flex-1 flex flex-col min-w-0">
731
- {hasSession ? (
732
- <>
733
- <div className="flex-1 flex min-h-0">
734
- {isBackgroundTask && (
735
- <JobList
736
- jobs={state.jobs}
737
- selectedJobId={state.selectedJobId}
738
- onSelectJob={handleSelectJob}
739
- />
740
- )}
741
-
742
- <ChatWindow sendMessage={sendMessage} />
743
-
744
- {/* Detail Panel (right side, conditional) */}
745
- <ThreadPanel sendMessage={sendMessage} />
746
- </div>
747
- </>
822
+ {/* Resizable panels: Sidebar + Content */}
823
+ <Group
824
+ orientation="horizontal"
825
+ defaultLayout={mainLayout.defaultLayout}
826
+ onLayoutChanged={mainLayout.onLayoutChanged}
827
+ id="botschat-main"
828
+ className="flex-1"
829
+ >
830
+ {/* Sidebar panel */}
831
+ <Panel id="sidebar" defaultSize="15%" minSize="5%" maxSize="30%">
832
+ {isAutomationsView ? <CronSidebar /> : <Sidebar />}
833
+ </Panel>
834
+
835
+ <ResizeHandle />
836
+
837
+ {/* Main content panel */}
838
+ <Panel id="content">
839
+ {isAutomationsView ? (
840
+ <CronDetail />
748
841
  ) : (
749
- <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
750
- <div className="text-center">
751
- <svg
752
- className="w-20 h-20 mx-auto mb-4"
753
- fill="none"
754
- viewBox="0 0 24 24"
755
- stroke="currentColor"
756
- strokeWidth={1}
757
- style={{ color: "var(--text-muted)" }}
842
+ <div className="flex-1 flex flex-col min-w-0 h-full">
843
+ {hasSession ? (
844
+ <Group
845
+ orientation="horizontal"
846
+ defaultLayout={contentLayout.defaultLayout}
847
+ onLayoutChanged={contentLayout.onLayoutChanged}
848
+ id="botschat-content"
758
849
  >
759
- <path
760
- strokeLinecap="round"
761
- strokeLinejoin="round"
762
- d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
763
- />
764
- </svg>
765
- <p className="text-body font-bold" style={{ color: "var(--text-muted)" }}>
766
- Select a channel to get started
767
- </p>
768
- <p className="text-caption mt-1" style={{ color: "var(--text-muted)" }}>
769
- Choose a channel from the sidebar
770
- </p>
771
- </div>
850
+ {/* JobList — conditional */}
851
+ {isBackgroundTask && (
852
+ <>
853
+ <Panel id="joblist" defaultSize="15%" minSize="5%" maxSize="30%">
854
+ <JobList
855
+ jobs={state.jobs}
856
+ selectedJobId={state.selectedJobId}
857
+ onSelectJob={handleSelectJob}
858
+ />
859
+ </Panel>
860
+ <ResizeHandle />
861
+ </>
862
+ )}
863
+
864
+ {/* ChatWindow — main area */}
865
+ <Panel id="chat">
866
+ <ChatWindow sendMessage={sendMessage} />
867
+ </Panel>
868
+
869
+ {/* ThreadPanel — conditional */}
870
+ {state.activeThreadId && (
871
+ <>
872
+ <ResizeHandle />
873
+ <Panel id="thread" defaultSize="28%" minSize="10%" maxSize="50%">
874
+ <ThreadPanel sendMessage={sendMessage} />
875
+ </Panel>
876
+ </>
877
+ )}
878
+ </Group>
879
+ ) : (
880
+ <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
881
+ <div className="text-center">
882
+ <svg
883
+ className="w-20 h-20 mx-auto mb-4"
884
+ fill="none"
885
+ viewBox="0 0 24 24"
886
+ stroke="currentColor"
887
+ strokeWidth={1}
888
+ style={{ color: "var(--text-muted)" }}
889
+ >
890
+ <path
891
+ strokeLinecap="round"
892
+ strokeLinejoin="round"
893
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
894
+ />
895
+ </svg>
896
+ <p className="text-body font-bold" style={{ color: "var(--text-muted)" }}>
897
+ Select a channel to get started
898
+ </p>
899
+ <p className="text-caption mt-1" style={{ color: "var(--text-muted)" }}>
900
+ Choose a channel from the sidebar
901
+ </p>
902
+ </div>
903
+ </div>
904
+ )}
772
905
  </div>
773
906
  )}
774
- </div>
775
- )}
907
+ </Panel>
908
+ </Group>
776
909
  </div>
777
910
 
778
911
  {/* Global debug log panel — collapsible at bottom */}
@@ -787,11 +920,11 @@ export default function App() {
787
920
  onClick={() => setShowSettings(false)}
788
921
  >
789
922
  <div
790
- className="rounded-lg p-6 w-[420px] max-w-[90vw]"
923
+ className="rounded-lg p-6 w-[540px] max-w-[90vw] max-h-[85vh] flex flex-col"
791
924
  style={{ background: "var(--bg-surface)", boxShadow: "var(--shadow-lg)" }}
792
925
  onClick={(e) => e.stopPropagation()}
793
926
  >
794
- <div className="flex items-center justify-between mb-5">
927
+ <div className="flex items-center justify-between mb-4">
795
928
  <h2 className="text-h1 font-bold" style={{ color: "var(--text-primary)" }}>
796
929
  Settings
797
930
  </h2>
@@ -806,46 +939,96 @@ export default function App() {
806
939
  </button>
807
940
  </div>
808
941
 
809
- <div className="space-y-5">
810
- {/* Default Model */}
811
- <div>
812
- <label
813
- className="block text-caption font-bold mb-1.5"
814
- style={{ color: "var(--text-secondary)" }}
815
- >
816
- Default Model
817
- </label>
818
- <ModelSelect
819
- value={state.defaultModel ?? ""}
820
- onChange={handleDefaultModelChange}
821
- models={state.models}
822
- placeholder="Not set (use agent default)"
823
- />
824
- <p className="text-tiny mt-1.5" style={{ color: "var(--text-muted)" }}>
825
- Default model for new conversations. You can override per session using{" "}
826
- <code>/model</code> or per automation in its settings.
827
- </p>
828
- </div>
829
-
830
- {/* Connection info */}
831
- <div>
832
- <label
833
- className="block text-caption font-bold mb-1.5"
834
- style={{ color: "var(--text-secondary)" }}
835
- >
836
- Current Session Model
837
- </label>
838
- <span
839
- className="text-body font-mono"
840
- style={{ color: (state.sessionModel || state.defaultModel) ? "var(--text-primary)" : "var(--text-muted)" }}
841
- >
842
- {state.sessionModel ?? state.defaultModel ?? "Not connected"}
843
- </span>
844
- </div>
942
+ {/* Tab bar */}
943
+ <div className="flex gap-4 mb-4" style={{ borderBottom: "1px solid var(--border)" }}>
944
+ <button
945
+ className="pb-2 text-caption font-bold transition-colors"
946
+ style={{
947
+ color: settingsTab === "general" ? "var(--text-primary)" : "var(--text-muted)",
948
+ borderBottom: settingsTab === "general" ? "2px solid var(--bg-active)" : "2px solid transparent",
949
+ marginBottom: "-1px",
950
+ }}
951
+ onClick={() => setSettingsTab("general")}
952
+ >
953
+ General
954
+ </button>
955
+ <button
956
+ className="pb-2 text-caption font-bold transition-colors"
957
+ style={{
958
+ color: settingsTab === "connection" ? "var(--text-primary)" : "var(--text-muted)",
959
+ borderBottom: settingsTab === "connection" ? "2px solid var(--bg-active)" : "2px solid transparent",
960
+ marginBottom: "-1px",
961
+ }}
962
+ onClick={() => setSettingsTab("connection")}
963
+ >
964
+ Connection
965
+ </button>
966
+ <button
967
+ className="pb-2 text-caption font-bold transition-colors"
968
+ style={{
969
+ color: settingsTab === "security" ? "var(--text-primary)" : "var(--text-muted)",
970
+ borderBottom: settingsTab === "security" ? "2px solid var(--bg-active)" : "2px solid transparent",
971
+ marginBottom: "-1px",
972
+ }}
973
+ onClick={() => setSettingsTab("security")}
974
+ >
975
+ Security
976
+ </button>
977
+ </div>
978
+
979
+ {/* Tab content — scrollable */}
980
+ <div className="flex-1 min-h-0 overflow-y-auto">
981
+ {settingsTab === "general" && (
982
+ <div className="space-y-5">
983
+ {/* Default Model */}
984
+ <div>
985
+ <label
986
+ className="block text-caption font-bold mb-1.5"
987
+ style={{ color: "var(--text-secondary)" }}
988
+ >
989
+ Default Model
990
+ </label>
991
+ <ModelSelect
992
+ value={state.defaultModel ?? ""}
993
+ onChange={handleDefaultModelChange}
994
+ models={state.models}
995
+ placeholder="Not set (use agent default)"
996
+ />
997
+ <p className="text-tiny mt-1.5" style={{ color: "var(--text-muted)" }}>
998
+ Default model for new conversations. You can override per session using{" "}
999
+ <code>/model</code> or per automation in its settings.
1000
+ </p>
1001
+ </div>
1002
+
1003
+ {/* Current Session Model */}
1004
+ <div>
1005
+ <label
1006
+ className="block text-caption font-bold mb-1.5"
1007
+ style={{ color: "var(--text-secondary)" }}
1008
+ >
1009
+ Current Session Model
1010
+ </label>
1011
+ <span
1012
+ className="text-body font-mono"
1013
+ style={{ color: (state.sessionModel || state.defaultModel) ? "var(--text-primary)" : "var(--text-muted)" }}
1014
+ >
1015
+ {state.sessionModel ?? state.defaultModel ?? "Not connected"}
1016
+ </span>
1017
+ </div>
1018
+ </div>
1019
+ )}
1020
+
1021
+ {settingsTab === "connection" && (
1022
+ <ConnectionSettings />
1023
+ )}
1024
+
1025
+ {settingsTab === "security" && (
1026
+ <E2ESettings />
1027
+ )}
845
1028
  </div>
846
1029
 
847
1030
  <div
848
- className="mt-6 pt-4 flex justify-end"
1031
+ className="mt-4 pt-4 flex justify-end"
849
1032
  style={{ borderTop: "1px solid var(--border)" }}
850
1033
  >
851
1034
  <button
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Google Analytics (GA4). Only loads when VITE_GA_MEASUREMENT_ID is set (e.g. in .env.production).
3
+ * Set VITE_GA_MEASUREMENT_ID to your GA4 Measurement ID (e.g. G-XXXXXXXXXX) to enable.
4
+ */
5
+
6
+ declare global {
7
+ interface Window {
8
+ dataLayer: unknown[];
9
+ gtag?: (...args: unknown[]) => void;
10
+ }
11
+ }
12
+
13
+ const MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
14
+
15
+ function loadGtag(): boolean {
16
+ if (!MEASUREMENT_ID || typeof window === "undefined") return false;
17
+ if (window.gtag) return true;
18
+
19
+ window.dataLayer = window.dataLayer || [];
20
+ window.gtag = function gtag() {
21
+ window.dataLayer.push(arguments);
22
+ };
23
+ window.gtag("js", new Date());
24
+ window.gtag("config", MEASUREMENT_ID);
25
+
26
+ const script = document.createElement("script");
27
+ script.async = true;
28
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${MEASUREMENT_ID}`;
29
+ document.head.appendChild(script);
30
+ return true;
31
+ }
32
+
33
+ let initialized = false;
34
+
35
+ export function initAnalytics(): void {
36
+ if (initialized) return;
37
+ initialized = loadGtag();
38
+ }
39
+
40
+ export function isAnalyticsEnabled(): boolean {
41
+ return initialized && !!window.gtag;
42
+ }
43
+
44
+ /**
45
+ * Send a page_view or custom event to GA4. Use after route/view changes in the SPA.
46
+ */
47
+ export function gtagEvent(name: string, params?: Record<string, string>): void {
48
+ if (!MEASUREMENT_ID || !window.gtag) return;
49
+ window.gtag("event", name, params);
50
+ }
51
+
52
+ /**
53
+ * Track a virtual page view (e.g. "Messages" / "Automations" tab).
54
+ */
55
+ export function gtagPageView(page: string): void {
56
+ gtagEvent("page_view", { page_path: `/${page}`, page_title: page });
57
+ }