botschat 0.1.10 → 0.1.13

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 (56) hide show
  1. package/README.md +11 -15
  2. package/migrations/0012_push_tokens.sql +11 -0
  3. package/package.json +20 -1
  4. package/packages/api/src/do/connection-do.ts +142 -24
  5. package/packages/api/src/env.ts +6 -0
  6. package/packages/api/src/index.ts +7 -0
  7. package/packages/api/src/routes/auth.ts +85 -9
  8. package/packages/api/src/routes/channels.ts +3 -2
  9. package/packages/api/src/routes/dev-auth.ts +45 -0
  10. package/packages/api/src/routes/push.ts +52 -0
  11. package/packages/api/src/routes/upload.ts +73 -38
  12. package/packages/api/src/utils/fcm.ts +167 -0
  13. package/packages/api/src/utils/firebase.ts +218 -0
  14. package/packages/plugin/dist/src/channel.d.ts +6 -0
  15. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  16. package/packages/plugin/dist/src/channel.js +71 -15
  17. package/packages/plugin/dist/src/channel.js.map +1 -1
  18. package/packages/plugin/package.json +1 -1
  19. package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
  20. package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
  21. package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
  22. package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
  23. package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
  24. package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
  25. package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
  26. package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
  27. package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
  28. package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
  29. package/packages/web/dist/index.html +6 -4
  30. package/packages/web/dist/sw.js +158 -1
  31. package/packages/web/index.html +4 -2
  32. package/packages/web/package.json +4 -1
  33. package/packages/web/src/App.tsx +117 -1
  34. package/packages/web/src/api.ts +21 -1
  35. package/packages/web/src/components/AccountSettings.tsx +131 -0
  36. package/packages/web/src/components/ChatWindow.tsx +302 -70
  37. package/packages/web/src/components/CronSidebar.tsx +89 -24
  38. package/packages/web/src/components/DataConsentModal.tsx +249 -0
  39. package/packages/web/src/components/LoginPage.tsx +55 -7
  40. package/packages/web/src/components/MessageContent.tsx +71 -9
  41. package/packages/web/src/components/MobileLayout.tsx +28 -118
  42. package/packages/web/src/components/SessionTabs.tsx +41 -2
  43. package/packages/web/src/components/Sidebar.tsx +88 -66
  44. package/packages/web/src/e2e.ts +26 -5
  45. package/packages/web/src/firebase.ts +215 -3
  46. package/packages/web/src/foreground.ts +51 -0
  47. package/packages/web/src/index.css +10 -2
  48. package/packages/web/src/main.tsx +24 -2
  49. package/packages/web/src/push.ts +205 -0
  50. package/packages/web/src/ws.ts +20 -8
  51. package/scripts/dev.sh +158 -26
  52. package/scripts/mock-openclaw.mjs +382 -0
  53. package/scripts/test-e2e-chat.ts +2 -2
  54. package/scripts/test-e2e-live.ts +1 -1
  55. package/wrangler.toml +3 -0
  56. package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
@@ -1,11 +1,10 @@
1
1
  import React, { useState, useCallback } from "react";
2
- import { useAppState, useAppDispatch, type ActiveView } from "../store";
2
+ import { useAppState, useAppDispatch } from "../store";
3
3
  import { setToken, setRefreshToken } from "../api";
4
4
  import type { WSMessage } from "../ws";
5
5
  import { Sidebar } from "./Sidebar";
6
6
  import { ChatWindow } from "./ChatWindow";
7
7
  import { ThreadPanel } from "./ThreadPanel";
8
- import { JobList } from "./JobList";
9
8
  import { CronSidebar } from "./CronSidebar";
10
9
  import { CronDetail } from "./CronDetail";
11
10
  import { ModelSelect } from "./ModelSelect";
@@ -13,11 +12,14 @@ import { ConnectionSettings } from "./ConnectionSettings";
13
12
  import { E2ESettings } from "./E2ESettings";
14
13
  import { dlog } from "../debug-log";
15
14
 
15
+ /**
16
+ * Mobile screen stack — unified home replaces separate channel-list / cron-list.
17
+ * No bottom tab bar; Channels + Automations are both visible on the home screen.
18
+ */
16
19
  type MobileScreen =
17
- | "channel-list"
20
+ | "home"
18
21
  | "chat"
19
22
  | "thread"
20
- | "cron-list"
21
23
  | "cron-detail";
22
24
 
23
25
  type MobileLayoutProps = {
@@ -44,52 +46,19 @@ export function MobileLayout({
44
46
  const state = useAppState();
45
47
  const dispatch = useAppDispatch();
46
48
 
47
- // Mobile navigation state — stack-based
49
+ // Mobile navigation state — stack-based, unified home screen
48
50
  const [screen, setScreen] = useState<MobileScreen>(() => {
49
- if (state.activeView === "automations") return "cron-list";
50
51
  if (state.selectedAgentId && state.selectedSessionKey) return "chat";
51
- return "channel-list";
52
+ return "home";
52
53
  });
53
54
 
54
55
  const [showUserMenu, setShowUserMenu] = useState(false);
55
56
 
56
- const activeTab = state.activeView;
57
-
58
- const setActiveTab = useCallback((view: ActiveView) => {
59
- dispatch({ type: "SET_ACTIVE_VIEW", view });
60
- if (view === "messages") {
61
- if (state.selectedAgentId && state.selectedSessionKey) {
62
- setScreen("chat");
63
- } else {
64
- setScreen("channel-list");
65
- }
66
- } else {
67
- if (state.selectedCronTaskId) {
68
- setScreen("cron-detail");
69
- } else {
70
- setScreen("cron-list");
71
- }
72
- }
73
- }, [dispatch, state.selectedAgentId, state.selectedSessionKey, state.selectedCronTaskId]);
74
-
75
- // Navigate to chat when a channel is selected (via Sidebar's internal dispatch)
76
- // We listen for selectedAgentId changes to auto-navigate
77
- const prevAgentIdRef = React.useRef(state.selectedAgentId);
78
- React.useEffect(() => {
79
- if (state.selectedAgentId && state.selectedAgentId !== prevAgentIdRef.current && screen === "channel-list") {
80
- setScreen("chat");
81
- }
82
- prevAgentIdRef.current = state.selectedAgentId;
83
- }, [state.selectedAgentId, screen]);
84
-
85
- // Navigate to cron detail when a cron task is selected
86
- const prevCronTaskIdRef = React.useRef(state.selectedCronTaskId);
87
- React.useEffect(() => {
88
- if (state.selectedCronTaskId && state.selectedCronTaskId !== prevCronTaskIdRef.current && screen === "cron-list") {
89
- setScreen("cron-detail");
90
- }
91
- prevCronTaskIdRef.current = state.selectedCronTaskId;
92
- }, [state.selectedCronTaskId, screen]);
57
+ // Navigation to chat / cron-detail is handled explicitly by the onNavigate
58
+ // callbacks passed to Sidebar and CronSidebar. We intentionally do NOT
59
+ // auto-navigate when selectedAgentId / selectedCronTaskId change, because
60
+ // App.tsx auto-selects an agent on mount — that would navigate to an empty
61
+ // chat screen before sessions have loaded (issue #4a / #4b).
93
62
 
94
63
  // Navigate to thread when thread opens
95
64
  React.useEffect(() => {
@@ -119,14 +88,14 @@ export function MobileLayout({
119
88
  const goBack = useCallback(() => {
120
89
  switch (screen) {
121
90
  case "chat":
122
- setScreen("channel-list");
91
+ setScreen("home");
123
92
  break;
124
93
  case "thread":
125
94
  dispatch({ type: "CLOSE_THREAD" });
126
95
  setScreen("chat");
127
96
  break;
128
97
  case "cron-detail":
129
- setScreen("cron-list");
98
+ setScreen("home");
130
99
  break;
131
100
  default:
132
101
  break;
@@ -136,7 +105,7 @@ export function MobileLayout({
136
105
  // Determine the header title
137
106
  const getHeaderTitle = (): string => {
138
107
  switch (screen) {
139
- case "channel-list":
108
+ case "home":
140
109
  return "BotsChat";
141
110
  case "chat": {
142
111
  const agent = state.agents.find((a) => a.id === state.selectedAgentId);
@@ -144,8 +113,6 @@ export function MobileLayout({
144
113
  }
145
114
  case "thread":
146
115
  return "Thread";
147
- case "cron-list":
148
- return "Automations";
149
116
  case "cron-detail": {
150
117
  const task = state.cronTasks.find((t) => t.id === state.selectedCronTaskId);
151
118
  return task?.name ?? "Task Detail";
@@ -155,18 +122,22 @@ export function MobileLayout({
155
122
  }
156
123
  };
157
124
 
158
- const showBackButton = screen !== "channel-list" && screen !== "cron-list";
125
+ const showBackButton = screen !== "home";
159
126
 
160
127
  return (
161
128
  <div
162
- className="flex flex-col h-screen"
163
- style={{ background: "var(--bg-surface)" }}
129
+ className="flex flex-col"
130
+ style={{
131
+ height: "calc(100vh - var(--keyboard-height, 0px))",
132
+ background: "var(--bg-surface)",
133
+ transition: "height 0.2s ease-out",
134
+ }}
164
135
  >
165
136
  {/* ---- Top nav bar (44px + safe area for standalone PWA) ---- */}
166
137
  <div
167
138
  className="flex items-center justify-between px-4 flex-shrink-0"
168
139
  style={{
169
- height: 44,
140
+ minHeight: "calc(44px + env(safe-area-inset-top, 0px))",
170
141
  paddingTop: "env(safe-area-inset-top, 0px)",
171
142
  background: "var(--bg-primary)",
172
143
  borderBottom: "1px solid var(--border)",
@@ -287,9 +258,11 @@ export function MobileLayout({
287
258
 
288
259
  {/* ---- Screen content ---- */}
289
260
  <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
290
- {screen === "channel-list" && (
261
+ {/* Unified home: Channels + Automations in one scrollable list */}
262
+ {screen === "home" && (
291
263
  <div className="flex-1 min-h-0 overflow-y-auto" style={{ background: "var(--bg-secondary)" }}>
292
- <Sidebar onOpenSettings={onOpenSettings} />
264
+ <Sidebar onOpenSettings={onOpenSettings} onNavigate={() => setScreen("chat")} />
265
+ <CronSidebar onNavigate={() => setScreen("cron-detail")} />
293
266
  </div>
294
267
  )}
295
268
 
@@ -305,12 +278,6 @@ export function MobileLayout({
305
278
  </div>
306
279
  )}
307
280
 
308
- {screen === "cron-list" && (
309
- <div className="flex-1 min-h-0 overflow-y-auto" style={{ background: "var(--bg-secondary)" }}>
310
- <CronSidebar />
311
- </div>
312
- )}
313
-
314
281
  {screen === "cron-detail" && (
315
282
  <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
316
283
  <CronDetail />
@@ -318,38 +285,6 @@ export function MobileLayout({
318
285
  )}
319
286
  </div>
320
287
 
321
- {/* ---- Bottom tab bar (56px) ---- */}
322
- <div
323
- className="flex items-stretch flex-shrink-0"
324
- style={{
325
- height: 56,
326
- background: "var(--bg-primary)",
327
- borderTop: "1px solid var(--border)",
328
- paddingBottom: "env(safe-area-inset-bottom, 0px)",
329
- }}
330
- >
331
- <TabButton
332
- label="Messages"
333
- active={activeTab === "messages"}
334
- onClick={() => setActiveTab("messages")}
335
- icon={
336
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
337
- <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" />
338
- </svg>
339
- }
340
- />
341
- <TabButton
342
- label="Automations"
343
- active={activeTab === "automations"}
344
- onClick={() => setActiveTab("automations")}
345
- icon={
346
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
347
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
348
- </svg>
349
- }
350
- />
351
- </div>
352
-
353
288
  {/* Settings modal */}
354
289
  {showSettings && (
355
290
  <MobileSettingsModal
@@ -480,28 +415,3 @@ function MobileSettingsModal({
480
415
  </div>
481
416
  );
482
417
  }
483
-
484
- function TabButton({
485
- label,
486
- active,
487
- onClick,
488
- icon,
489
- }: {
490
- label: string;
491
- active: boolean;
492
- onClick: () => void;
493
- icon: React.ReactNode;
494
- }) {
495
- return (
496
- <button
497
- onClick={onClick}
498
- className="flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors"
499
- style={{
500
- color: active ? "var(--text-link)" : "var(--text-muted)",
501
- }}
502
- >
503
- {icon}
504
- <span className="text-[10px] leading-tight">{label}</span>
505
- </button>
506
- );
507
- }
@@ -7,6 +7,7 @@ import { dlog } from "../debug-log";
7
7
  // Session history — tracks per-channel usage order in localStorage
8
8
  // ---------------------------------------------------------------------------
9
9
  const SESSION_HISTORY_KEY = "botschat:sessionHistory";
10
+ const SESSION_COUNTER_KEY = "botschat:sessionCounter";
10
11
 
11
12
  /** Get the ordered list of recently-used session IDs for a channel. */
12
13
  function getSessionHistory(channelId: string): string[] {
@@ -32,6 +33,43 @@ function recordSessionUsage(channelId: string, sessionId: string) {
32
33
  } catch { /* ignore */ }
33
34
  }
34
35
 
36
+ // ---------------------------------------------------------------------------
37
+ // Session counter — per-channel high-water-mark for monotonic session naming
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Extract the numeric suffix from a "Session N" name, or return 0. */
41
+ function extractSessionNumber(name: string): number {
42
+ const match = name.match(/^Session\s+(\d+)$/);
43
+ return match ? parseInt(match[1], 10) : 0;
44
+ }
45
+
46
+ /** Get the next session number for a channel (monotonically increasing). */
47
+ function getNextSessionNumber(channelId: string, existingSessions: { name: string }[]): number {
48
+ // High-water-mark from localStorage
49
+ let hwm = 0;
50
+ try {
51
+ const all = JSON.parse(localStorage.getItem(SESSION_COUNTER_KEY) || "{}");
52
+ hwm = typeof all[channelId] === "number" ? all[channelId] : 0;
53
+ } catch { /* ignore */ }
54
+
55
+ // Max number from existing session names
56
+ const maxExisting = existingSessions.reduce(
57
+ (max, s) => Math.max(max, extractSessionNumber(s.name)),
58
+ 0,
59
+ );
60
+
61
+ const next = Math.max(hwm, maxExisting) + 1;
62
+
63
+ // Persist the new high-water-mark
64
+ try {
65
+ const all = JSON.parse(localStorage.getItem(SESSION_COUNTER_KEY) || "{}");
66
+ all[channelId] = next;
67
+ localStorage.setItem(SESSION_COUNTER_KEY, JSON.stringify(all));
68
+ } catch { /* ignore */ }
69
+
70
+ return next;
71
+ }
72
+
35
73
  /** Remove a session from the history for a channel. */
36
74
  function removeSessionFromHistory(channelId: string, sessionId: string) {
37
75
  try {
@@ -125,7 +163,8 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
125
163
  return;
126
164
  }
127
165
 
128
- const session = await sessionsApi.create(effectiveChannelId);
166
+ const nextNum = getNextSessionNumber(effectiveChannelId, sessions);
167
+ const session = await sessionsApi.create(effectiveChannelId, `Session ${nextNum}`);
129
168
  dlog.info("Session", `Created session: ${session.name} (${session.id})`);
130
169
  dispatch({ type: "ADD_SESSION", session });
131
170
  recordSessionUsage(effectiveChannelId, session.id);
@@ -143,7 +182,7 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
143
182
  } catch (err) {
144
183
  dlog.error("Session", `Failed to create session: ${err}`);
145
184
  }
146
- }, [channelId, dispatch]);
185
+ }, [channelId, sessions, dispatch]);
147
186
 
148
187
  const handleDelete = useCallback(
149
188
  async (sessionId: string) => {
@@ -3,7 +3,7 @@ import { useAppState, useAppDispatch } from "../store";
3
3
  import { agentsApi, channelsApi } from "../api";
4
4
  import { dlog } from "../debug-log";
5
5
 
6
- export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}) {
6
+ export function Sidebar({ onOpenSettings, onNavigate }: { onOpenSettings?: () => void; onNavigate?: () => void } = {}) {
7
7
  const state = useAppState();
8
8
  const dispatch = useAppDispatch();
9
9
  const [showCreate, setShowCreate] = useState(false);
@@ -29,6 +29,7 @@ export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}
29
29
  sessionKey: created.sessionKey,
30
30
  });
31
31
  try { localStorage.setItem("botschat_last_agent", created.id); } catch { /* ignore */ }
32
+ onNavigate?.();
32
33
  }
33
34
  setShowCreate(false);
34
35
  setNewName("");
@@ -41,11 +42,22 @@ export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}
41
42
  const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
42
43
 
43
44
  const handleSelectAgent = (agentId: string, sessionKey: string) => {
44
- // Skip if already selected avoids clearing messages for no reason
45
- if (state.selectedAgentId === agentId) return;
45
+ // Ensure activeView is "messages" on mobile there's no IconRail, so
46
+ // if the user was last viewing automations, session loading would be
47
+ // blocked by the `isMessagesView` guard in App.tsx.
48
+ if (state.activeView !== "messages") {
49
+ dispatch({ type: "SET_ACTIVE_VIEW", view: "messages" });
50
+ }
51
+ // On mobile, always call onNavigate so tapping the already-selected
52
+ // channel navigates back to the chat view.
53
+ if (state.selectedAgentId === agentId) {
54
+ onNavigate?.();
55
+ return;
56
+ }
46
57
  const agent = state.agents.find((a) => a.id === agentId);
47
58
  dlog.info("Channel", `Selected channel: ${agent?.name ?? agentId} (session=${sessionKey})`);
48
59
  dispatch({ type: "SELECT_AGENT", agentId, sessionKey });
60
+ onNavigate?.();
49
61
  // Persist last selected channel so it survives page refresh
50
62
  try { localStorage.setItem("botschat_last_agent", agentId); } catch { /* ignore */ }
51
63
  };
@@ -80,7 +92,7 @@ export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}
80
92
 
81
93
  return (
82
94
  <div
83
- className="flex flex-col h-full"
95
+ className="flex flex-col"
84
96
  style={{ background: "var(--bg-secondary)" }}
85
97
  >
86
98
  {/* Workspace Switcher */}
@@ -126,6 +138,11 @@ export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}
126
138
  label="Channels"
127
139
  expanded={channelsExpanded}
128
140
  onToggle={() => setChannelsExpanded(!channelsExpanded)}
141
+ onAdd={(e) => {
142
+ e.stopPropagation();
143
+ if (!channelsExpanded) setChannelsExpanded(true);
144
+ setShowCreate(!showCreate);
145
+ }}
129
146
  />
130
147
  {channelsExpanded && (
131
148
  <div>
@@ -164,57 +181,47 @@ export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}
164
181
  Loading channels…
165
182
  </div>
166
183
  )}
184
+ {/* Inline create channel form */}
185
+ {showCreate && (
186
+ <div className="px-4 py-2 space-y-2">
187
+ <input
188
+ type="text"
189
+ placeholder="Channel name"
190
+ value={newName}
191
+ onChange={(e) => setNewName(e.target.value)}
192
+ onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
193
+ className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
194
+ style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
195
+ autoFocus
196
+ />
197
+ <input
198
+ type="text"
199
+ placeholder="Description (optional)"
200
+ value={newDesc}
201
+ onChange={(e) => setNewDesc(e.target.value)}
202
+ onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
203
+ className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
204
+ style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
205
+ />
206
+ <div className="flex gap-2">
207
+ <button
208
+ onClick={handleCreate}
209
+ className="flex-1 px-3 py-1.5 text-caption bg-[--bg-active] text-white rounded-sm font-bold hover:brightness-110"
210
+ >
211
+ Create
212
+ </button>
213
+ <button
214
+ onClick={() => setShowCreate(false)}
215
+ className="px-3 py-1.5 text-caption text-[--text-muted] hover:text-[--text-sidebar]"
216
+ >
217
+ Cancel
218
+ </button>
219
+ </div>
220
+ </div>
221
+ )}
167
222
  </div>
168
223
  )}
169
224
  </div>
170
-
171
- {/* Create channel */}
172
- {showCreate ? (
173
- <div className="p-3 space-y-2" style={{ borderTop: "1px solid var(--sidebar-border)" }}>
174
- <input
175
- type="text"
176
- placeholder="Channel name"
177
- value={newName}
178
- onChange={(e) => setNewName(e.target.value)}
179
- onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
180
- className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
181
- style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
182
- autoFocus
183
- />
184
- <input
185
- type="text"
186
- placeholder="Description (optional)"
187
- value={newDesc}
188
- onChange={(e) => setNewDesc(e.target.value)}
189
- className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
190
- style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
191
- />
192
- <div className="flex gap-2">
193
- <button
194
- onClick={handleCreate}
195
- className="flex-1 px-3 py-1.5 text-caption bg-[--bg-active] text-white rounded-sm font-bold hover:brightness-110"
196
- >
197
- Create
198
- </button>
199
- <button
200
- onClick={() => setShowCreate(false)}
201
- className="px-3 py-1.5 text-caption text-[--text-muted] hover:text-[--text-sidebar]"
202
- >
203
- Cancel
204
- </button>
205
- </div>
206
- </div>
207
- ) : (
208
- <div className="p-3" style={{ borderTop: "1px solid var(--sidebar-border)" }}>
209
- <button
210
- onClick={() => setShowCreate(true)}
211
- className="w-full px-3 py-1.5 text-caption text-[--text-sidebar] hover:text-[--text-sidebar-active] rounded-sm border border-dashed transition-colors"
212
- style={{ borderColor: "var(--sidebar-divider)" }}
213
- >
214
- + New channel
215
- </button>
216
- </div>
217
- )}
218
225
  </div>
219
226
  );
220
227
  }
@@ -223,27 +230,42 @@ function SectionHeader({
223
230
  label,
224
231
  expanded,
225
232
  onToggle,
233
+ onAdd,
226
234
  }: {
227
235
  label: string;
228
236
  expanded: boolean;
229
237
  onToggle: () => void;
238
+ onAdd?: (e: React.MouseEvent) => void;
230
239
  }) {
231
240
  return (
232
- <button
233
- onClick={onToggle}
234
- className="w-full flex items-center gap-1 px-4 py-1.5 text-tiny uppercase tracking-wider text-[--text-sidebar] hover:text-[--text-sidebar-active] transition-colors"
235
- >
236
- <svg
237
- className={`w-3 h-3 transition-transform ${expanded ? "rotate-0" : "-rotate-90"}`}
238
- fill="none"
239
- viewBox="0 0 24 24"
240
- stroke="currentColor"
241
- strokeWidth={2}
241
+ <div className="w-full flex items-center px-4 py-1.5">
242
+ <button
243
+ onClick={onToggle}
244
+ className="flex items-center gap-1 text-tiny uppercase tracking-wider text-[--text-sidebar] hover:text-[--text-sidebar-active] transition-colors"
242
245
  >
243
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
244
- </svg>
245
- {label}
246
- </button>
246
+ <svg
247
+ className={`w-3 h-3 transition-transform ${expanded ? "rotate-0" : "-rotate-90"}`}
248
+ fill="none"
249
+ viewBox="0 0 24 24"
250
+ stroke="currentColor"
251
+ strokeWidth={2}
252
+ >
253
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
254
+ </svg>
255
+ {label}
256
+ </button>
257
+ {onAdd && (
258
+ <button
259
+ onClick={onAdd}
260
+ className="ml-auto p-0.5 rounded transition-colors text-[--text-sidebar] hover:text-[--text-sidebar-active] hover:bg-[--sidebar-hover]"
261
+ title={`New ${label.toLowerCase().replace(/s$/, "")}`}
262
+ >
263
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
264
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
265
+ </svg>
266
+ </button>
267
+ )}
268
+ </div>
247
269
  );
248
270
  }
249
271
 
@@ -1,4 +1,4 @@
1
- import { deriveKey, encryptText, decryptText, toBase64, fromBase64 } from "e2e-crypto";
1
+ import { deriveKey, encryptText, decryptText, encryptBytes, decryptBytes, toBase64, fromBase64 } from "e2e-crypto";
2
2
 
3
3
  const STORAGE_KEY = "botschat_e2e_pwd_cache";
4
4
  const KEY_CACHE_KEY = "botschat_e2e_key_cache"; // base64-encoded derived key
@@ -108,12 +108,13 @@ export const E2eService = {
108
108
 
109
109
  /**
110
110
  * Encrypt text using the current key.
111
- * Generates a random messageId (UUID) as contextId/nonce source.
111
+ * If contextId is provided, uses it as the nonce source (for preserving
112
+ * existing messageIds). Otherwise generates a random UUID.
112
113
  * Returns { ciphertext: base64, messageId: string }
113
114
  */
114
- async encrypt(text: string): Promise<{ ciphertext: string; messageId: string }> {
115
+ async encrypt(text: string, contextId?: string): Promise<{ ciphertext: string; messageId: string }> {
115
116
  if (!currentKey) throw new Error("E2E key not set");
116
- const messageId = crypto.randomUUID();
117
+ const messageId = contextId || crypto.randomUUID();
117
118
  const encrypted = await encryptText(currentKey, text, messageId);
118
119
  return { ciphertext: toBase64(encrypted), messageId };
119
120
  },
@@ -134,10 +135,30 @@ export const E2eService = {
134
135
  return currentPassword;
135
136
  },
136
137
 
138
+ /**
139
+ * Encrypt raw binary data (e.g., an image file).
140
+ * Returns { encrypted: Uint8Array, contextId: string }.
141
+ * The encrypted data is the same length as the input (AES-CTR, no padding).
142
+ */
143
+ async encryptMedia(data: Uint8Array, contextId?: string): Promise<{ encrypted: Uint8Array; contextId: string }> {
144
+ if (!currentKey) throw new Error("E2E key not set");
145
+ const cid = contextId || crypto.randomUUID();
146
+ const encrypted = await encryptBytes(currentKey, data, cid);
147
+ return { encrypted, contextId: cid };
148
+ },
149
+
150
+ /**
151
+ * Decrypt raw binary data (e.g., an encrypted image).
152
+ */
153
+ async decryptMedia(encrypted: Uint8Array, contextId: string): Promise<Uint8Array> {
154
+ if (!currentKey) throw new Error("E2E key not set");
155
+ return decryptBytes(currentKey, encrypted, contextId);
156
+ },
157
+
137
158
  /**
138
159
  * Decrypt bytes (base64) -> Uint8Array.
139
160
  */
140
- async decryptBytes(ciphertextBase64: string, messageId: string): Promise<Uint8Array> {
161
+ async decryptBytesLegacy(ciphertextBase64: string, messageId: string): Promise<Uint8Array> {
141
162
  if (!currentKey) throw new Error("E2E key not set");
142
163
  const ciphertext = fromBase64(ciphertextBase64);
143
164
  const plainStr = await decryptText(currentKey, ciphertext, messageId);