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.
- package/README.md +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +20 -1
- package/packages/api/src/do/connection-do.ts +142 -24
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +7 -0
- package/packages/api/src/routes/auth.ts +85 -9
- package/packages/api/src/routes/channels.ts +3 -2
- package/packages/api/src/routes/dev-auth.ts +45 -0
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/routes/upload.ts +73 -38
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +218 -0
- package/packages/plugin/dist/src/channel.d.ts +6 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +71 -15
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
- package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
- package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
- package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
- package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
- package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
- package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
- package/packages/web/dist/index.html +6 -4
- package/packages/web/dist/sw.js +158 -1
- package/packages/web/index.html +4 -2
- package/packages/web/package.json +4 -1
- package/packages/web/src/App.tsx +117 -1
- package/packages/web/src/api.ts +21 -1
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/ChatWindow.tsx +302 -70
- package/packages/web/src/components/CronSidebar.tsx +89 -24
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +55 -7
- package/packages/web/src/components/MessageContent.tsx +71 -9
- package/packages/web/src/components/MobileLayout.tsx +28 -118
- package/packages/web/src/components/SessionTabs.tsx +41 -2
- package/packages/web/src/components/Sidebar.tsx +88 -66
- package/packages/web/src/e2e.ts +26 -5
- package/packages/web/src/firebase.ts +215 -3
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/index.css +10 -2
- package/packages/web/src/main.tsx +24 -2
- package/packages/web/src/push.ts +205 -0
- package/packages/web/src/ws.ts +20 -8
- package/scripts/dev.sh +158 -26
- package/scripts/mock-openclaw.mjs +382 -0
- package/scripts/test-e2e-chat.ts +2 -2
- package/scripts/test-e2e-live.ts +1 -1
- package/wrangler.toml +3 -0
- 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
|
|
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
|
-
| "
|
|
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 "
|
|
52
|
+
return "home";
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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("
|
|
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("
|
|
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 "
|
|
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 !== "
|
|
125
|
+
const showBackButton = screen !== "home";
|
|
159
126
|
|
|
160
127
|
return (
|
|
161
128
|
<div
|
|
162
|
-
className="flex flex-col
|
|
163
|
-
style={{
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
//
|
|
45
|
-
if
|
|
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
|
|
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
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
package/packages/web/src/e2e.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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);
|