botschat 0.1.8 → 0.1.10

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 (30) hide show
  1. package/README.md +10 -0
  2. package/package.json +1 -1
  3. package/packages/api/src/do/connection-do.ts +57 -8
  4. package/packages/api/src/routes/upload.ts +2 -1
  5. package/packages/api/src/utils/uuid.ts +17 -0
  6. package/packages/plugin/dist/src/channel.js +27 -1
  7. package/packages/plugin/dist/src/channel.js.map +1 -1
  8. package/packages/plugin/dist/src/e2e-crypto.d.ts +47 -0
  9. package/packages/plugin/dist/src/e2e-crypto.d.ts.map +1 -0
  10. package/packages/plugin/dist/src/e2e-crypto.js +210 -0
  11. package/packages/plugin/dist/src/e2e-crypto.js.map +1 -0
  12. package/packages/plugin/dist/src/types.d.ts +6 -0
  13. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  14. package/packages/plugin/dist/src/ws-client.js +1 -1
  15. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  16. package/packages/plugin/package.json +2 -3
  17. package/packages/web/dist/assets/index-DpW6VzZK.js +1497 -0
  18. package/packages/web/dist/assets/{index-B1sFqYiM.css → index-Ev5M8VmV.css} +1 -1
  19. package/packages/web/dist/index.html +2 -2
  20. package/packages/web/src/App.tsx +7 -7
  21. package/packages/web/src/api.ts +1 -1
  22. package/packages/web/src/components/ChatWindow.tsx +114 -23
  23. package/packages/web/src/components/LoginPage.tsx +3 -0
  24. package/packages/web/src/components/MobileLayout.tsx +29 -2
  25. package/packages/web/src/components/SessionTabs.tsx +138 -21
  26. package/packages/web/src/components/Sidebar.tsx +14 -1
  27. package/packages/web/src/components/ThreadPanel.tsx +155 -13
  28. package/packages/web/src/utils/uuid.ts +21 -0
  29. package/packages/web/src/ws.ts +24 -4
  30. package/packages/web/dist/assets/index-C-FpELeN.js +0 -1497
@@ -3,6 +3,45 @@ import { useAppState, useAppDispatch } from "../store";
3
3
  import { sessionsApi, channelsApi, agentsApi } from "../api";
4
4
  import { dlog } from "../debug-log";
5
5
 
6
+ // ---------------------------------------------------------------------------
7
+ // Session history — tracks per-channel usage order in localStorage
8
+ // ---------------------------------------------------------------------------
9
+ const SESSION_HISTORY_KEY = "botschat:sessionHistory";
10
+
11
+ /** Get the ordered list of recently-used session IDs for a channel. */
12
+ function getSessionHistory(channelId: string): string[] {
13
+ try {
14
+ const all = JSON.parse(localStorage.getItem(SESSION_HISTORY_KEY) || "{}");
15
+ return Array.isArray(all[channelId]) ? all[channelId] : [];
16
+ } catch {
17
+ return [];
18
+ }
19
+ }
20
+
21
+ /** Record a session as the most-recently-used for its channel. */
22
+ function recordSessionUsage(channelId: string, sessionId: string) {
23
+ try {
24
+ const all = JSON.parse(localStorage.getItem(SESSION_HISTORY_KEY) || "{}");
25
+ const list: string[] = Array.isArray(all[channelId]) ? all[channelId] : [];
26
+ // Move to front (most recent)
27
+ const filtered = list.filter((id) => id !== sessionId);
28
+ filtered.unshift(sessionId);
29
+ // Keep at most 50 entries per channel
30
+ all[channelId] = filtered.slice(0, 50);
31
+ localStorage.setItem(SESSION_HISTORY_KEY, JSON.stringify(all));
32
+ } catch { /* ignore */ }
33
+ }
34
+
35
+ /** Remove a session from the history for a channel. */
36
+ function removeSessionFromHistory(channelId: string, sessionId: string) {
37
+ try {
38
+ const all = JSON.parse(localStorage.getItem(SESSION_HISTORY_KEY) || "{}");
39
+ const list: string[] = Array.isArray(all[channelId]) ? all[channelId] : [];
40
+ all[channelId] = list.filter((id) => id !== sessionId);
41
+ localStorage.setItem(SESSION_HISTORY_KEY, JSON.stringify(all));
42
+ } catch { /* ignore */ }
43
+ }
44
+
6
45
  type SessionTabsProps = {
7
46
  channelId: string | null;
8
47
  };
@@ -12,8 +51,10 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
12
51
  const dispatch = useAppDispatch();
13
52
  const [editingId, setEditingId] = useState<string | null>(null);
14
53
  const [editValue, setEditValue] = useState("");
54
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
15
55
  const editRef = useRef<HTMLInputElement>(null);
16
56
  const scrollRef = useRef<HTMLDivElement>(null);
57
+ const confirmRef = useRef<HTMLDivElement>(null);
17
58
 
18
59
  const sessions = state.sessions;
19
60
  const selectedId = state.selectedSessionId;
@@ -26,19 +67,33 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
26
67
  }
27
68
  }, [editingId]);
28
69
 
70
+ // Close confirmation popover on outside click
71
+ useEffect(() => {
72
+ if (!confirmDeleteId) return;
73
+ const handler = (e: MouseEvent) => {
74
+ if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
75
+ setConfirmDeleteId(null);
76
+ }
77
+ };
78
+ document.addEventListener("mousedown", handler);
79
+ return () => document.removeEventListener("mousedown", handler);
80
+ }, [confirmDeleteId]);
81
+
29
82
  const handleSelect = useCallback(
30
83
  (sessionId: string) => {
31
84
  if (sessionId === selectedId) return;
32
85
  const session = sessions.find((s) => s.id === sessionId);
33
86
  if (!session) return;
34
87
  dlog.info("Session", `Switched to session: ${session.name} (${session.id})`);
88
+ // Record usage for smart navigation on close
89
+ if (channelId) recordSessionUsage(channelId, session.id);
35
90
  dispatch({
36
91
  type: "SELECT_SESSION",
37
92
  sessionId: session.id,
38
93
  sessionKey: session.sessionKey,
39
94
  });
40
95
  },
41
- [selectedId, sessions, dispatch],
96
+ [selectedId, sessions, channelId, dispatch],
42
97
  );
43
98
 
44
99
  const handleCreate = useCallback(async () => {
@@ -73,6 +128,7 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
73
128
  const session = await sessionsApi.create(effectiveChannelId);
74
129
  dlog.info("Session", `Created session: ${session.name} (${session.id})`);
75
130
  dispatch({ type: "ADD_SESSION", session });
131
+ recordSessionUsage(effectiveChannelId, session.id);
76
132
  dispatch({
77
133
  type: "SELECT_SESSION",
78
134
  sessionId: session.id,
@@ -96,14 +152,22 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
96
152
  await sessionsApi.delete(channelId, sessionId);
97
153
  dlog.info("Session", `Deleted session: ${sessionId}`);
98
154
  dispatch({ type: "REMOVE_SESSION", sessionId });
99
- // If deleted the selected session, switch to the first remaining
155
+ removeSessionFromHistory(channelId, sessionId);
156
+ setConfirmDeleteId(null);
157
+
158
+ // If deleted the selected session, navigate to the last-used session
159
+ // in the same channel (from history), or fall back to first remaining
100
160
  if (selectedId === sessionId) {
101
161
  const remaining = sessions.filter((s) => s.id !== sessionId);
102
162
  if (remaining.length > 0) {
163
+ const history = getSessionHistory(channelId);
164
+ const target = history
165
+ .map((hId) => remaining.find((s) => s.id === hId))
166
+ .find(Boolean) ?? remaining[0];
103
167
  dispatch({
104
168
  type: "SELECT_SESSION",
105
- sessionId: remaining[0].id,
106
- sessionKey: remaining[0].sessionKey,
169
+ sessionId: target.id,
170
+ sessionKey: target.sessionKey,
107
171
  });
108
172
  }
109
173
  }
@@ -197,23 +261,6 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
197
261
  title={`${session.name} (double-click to rename)`}
198
262
  >
199
263
  <span className="max-w-[120px] truncate">{session.name}</span>
200
-
201
- {/* Close button — only show on hover, not for last session */}
202
- {sessions.length > 1 && (
203
- <span
204
- onClick={(e) => {
205
- e.stopPropagation();
206
- handleDelete(session.id);
207
- }}
208
- className="ml-0.5 w-4 h-4 flex items-center justify-center rounded-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[--bg-hover]"
209
- style={{ color: "var(--text-muted)" }}
210
- title="Close session"
211
- >
212
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
213
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
214
- </svg>
215
- </span>
216
- )}
217
264
  </button>
218
265
  )}
219
266
  </div>
@@ -241,6 +288,76 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
241
288
  </svg>
242
289
  </button>
243
290
  </div>
291
+
292
+ {/* Close current session button — separate from tabs, with confirmation */}
293
+ {sessions.length > 1 && selectedId && (
294
+ <div className="relative ml-auto pl-2 flex-shrink-0">
295
+ <button
296
+ onClick={() => setConfirmDeleteId(selectedId)}
297
+ className="w-7 h-7 flex items-center justify-center rounded-md transition-colors"
298
+ style={{ color: "var(--text-muted)" }}
299
+ title="Close current session"
300
+ aria-label="Close current session"
301
+ onMouseEnter={(e) => {
302
+ e.currentTarget.style.background = "var(--bg-hover)";
303
+ e.currentTarget.style.color = "var(--text-primary)";
304
+ }}
305
+ onMouseLeave={(e) => {
306
+ e.currentTarget.style.background = "";
307
+ e.currentTarget.style.color = "var(--text-muted)";
308
+ }}
309
+ >
310
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
311
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
312
+ </svg>
313
+ </button>
314
+
315
+ {/* Confirmation popover */}
316
+ {confirmDeleteId && (
317
+ <div
318
+ ref={confirmRef}
319
+ className="absolute right-0 top-full mt-1 z-50 rounded-md shadow-lg py-2 px-3 min-w-[180px]"
320
+ style={{
321
+ background: "var(--bg-surface)",
322
+ border: "1px solid var(--border)",
323
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
324
+ }}
325
+ >
326
+ <p className="text-caption mb-2" style={{ color: "var(--text-primary)" }}>
327
+ Close this session?
328
+ </p>
329
+ <div className="flex items-center gap-2 justify-end">
330
+ <button
331
+ onClick={() => setConfirmDeleteId(null)}
332
+ className="px-2.5 py-1 text-caption rounded-md transition-colors"
333
+ style={{ color: "var(--text-secondary)" }}
334
+ onMouseEnter={(e) => {
335
+ e.currentTarget.style.background = "var(--bg-hover)";
336
+ }}
337
+ onMouseLeave={(e) => {
338
+ e.currentTarget.style.background = "";
339
+ }}
340
+ >
341
+ Cancel
342
+ </button>
343
+ <button
344
+ onClick={() => handleDelete(confirmDeleteId)}
345
+ className="px-2.5 py-1 text-caption font-bold rounded-md text-white transition-colors"
346
+ style={{ background: "#e74c3c" }}
347
+ onMouseEnter={(e) => {
348
+ e.currentTarget.style.background = "#c0392b";
349
+ }}
350
+ onMouseLeave={(e) => {
351
+ e.currentTarget.style.background = "#e74c3c";
352
+ }}
353
+ >
354
+ Close
355
+ </button>
356
+ </div>
357
+ </div>
358
+ )}
359
+ </div>
360
+ )}
244
361
  </div>
245
362
  );
246
363
  }
@@ -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() {
6
+ export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}) {
7
7
  const state = useAppState();
8
8
  const dispatch = useAppDispatch();
9
9
  const [showCreate, setShowCreate] = useState(false);
@@ -88,6 +88,19 @@ export function Sidebar() {
88
88
  <span className="text-[--text-sidebar-active] font-bold text-h2 truncate flex-1">
89
89
  BotsChat
90
90
  </span>
91
+ {onOpenSettings && (
92
+ <button
93
+ onClick={onOpenSettings}
94
+ className="p-1 rounded transition-colors hover:bg-[--sidebar-hover]"
95
+ style={{ color: "var(--text-sidebar)" }}
96
+ title="Settings"
97
+ >
98
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
99
+ <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" />
100
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
101
+ </svg>
102
+ </button>
103
+ )}
91
104
  <svg className="w-3 h-3 text-[--text-sidebar]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
92
105
  <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
93
106
  </svg>
@@ -1,9 +1,19 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React, { useState, useEffect, useCallback } from "react";
2
2
  import { useAppState, useAppDispatch, type ChatMessage } from "../store";
3
3
  import { messagesApi } from "../api";
4
4
  import type { WSMessage } from "../ws";
5
5
  import { MessageContent } from "./MessageContent";
6
6
  import { dlog } from "../debug-log";
7
+ import { randomUUID } from "../utils/uuid";
8
+
9
+ /** Simple string hash for action prompt keys (matches MessageContent / ChatWindow) */
10
+ function simpleHash(str: string): string {
11
+ let hash = 0;
12
+ for (let i = 0; i < str.length; i++) {
13
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
14
+ }
15
+ return hash.toString(36);
16
+ }
7
17
 
8
18
  type ThreadPanelProps = {
9
19
  sendMessage: (msg: WSMessage) => void;
@@ -39,15 +49,85 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
39
49
  (m) => m.id === state.activeThreadId,
40
50
  );
41
51
 
52
+ const threadSessionKey = state.selectedSessionKey
53
+ ? `${state.selectedSessionKey}:thread:${state.activeThreadId}`
54
+ : null;
55
+
56
+ /** Handle A2UI action button clicks in thread — sends as user message */
57
+ const handleA2UIAction = useCallback((action: string) => {
58
+ if (!threadSessionKey) return;
59
+ dlog.info("Thread/A2UI", `Action triggered: ${action}`);
60
+ const msg: ChatMessage = {
61
+ id: randomUUID(),
62
+ sender: "user",
63
+ text: action,
64
+ timestamp: Date.now(),
65
+ threadId: state.activeThreadId ?? undefined,
66
+ };
67
+ dispatch({ type: "ADD_THREAD_MESSAGE", message: msg });
68
+ sendMessage({
69
+ type: "user.message",
70
+ sessionKey: threadSessionKey,
71
+ text: action,
72
+ userId: state.user?.id ?? "",
73
+ messageId: msg.id,
74
+ });
75
+ }, [threadSessionKey, state.activeThreadId, state.user?.id, sendMessage, dispatch]);
76
+
77
+ /** Handle ActionCard resolve in thread — marks widget done + sends choice */
78
+ const handleResolveAction = useCallback((messageId: string, value: string, label: string) => {
79
+ if (!threadSessionKey) return;
80
+ dlog.info("Thread/ActionCard", `Resolved: "${label}" (value="${value}")`);
81
+ const promptHash = simpleHash(label + value);
82
+ dispatch({ type: "RESOLVE_ACTION", messageId, promptHash, value, label });
83
+ const msg: ChatMessage = {
84
+ id: randomUUID(),
85
+ sender: "user",
86
+ text: label,
87
+ timestamp: Date.now(),
88
+ threadId: state.activeThreadId ?? undefined,
89
+ };
90
+ dispatch({ type: "ADD_THREAD_MESSAGE", message: msg });
91
+ sendMessage({
92
+ type: "user.message",
93
+ sessionKey: threadSessionKey,
94
+ text: label,
95
+ userId: state.user?.id ?? "",
96
+ messageId: msg.id,
97
+ });
98
+ }, [threadSessionKey, state.activeThreadId, state.user?.id, sendMessage, dispatch]);
99
+
100
+ /** Stop the current thread streaming — sends /stop */
101
+ const handleStop = useCallback(() => {
102
+ if (!threadSessionKey || !state.streamingRunId || !state.streamingThreadId) return;
103
+ dlog.info("Thread", "Stop streaming requested");
104
+ const msg: ChatMessage = {
105
+ id: randomUUID(),
106
+ sender: "user",
107
+ text: "/stop",
108
+ timestamp: Date.now(),
109
+ threadId: state.activeThreadId ?? undefined,
110
+ };
111
+ dispatch({ type: "ADD_THREAD_MESSAGE", message: msg });
112
+ sendMessage({
113
+ type: "user.message",
114
+ sessionKey: threadSessionKey,
115
+ text: "/stop",
116
+ userId: state.user?.id ?? "",
117
+ messageId: msg.id,
118
+ });
119
+ }, [threadSessionKey, state.streamingRunId, state.streamingThreadId, state.activeThreadId, state.user?.id, sendMessage, dispatch]);
120
+
121
+ const isThreadStreaming = !!state.streamingRunId && !!state.streamingThreadId;
122
+
42
123
  const handleSend = () => {
43
124
  if (!input.trim() || !state.selectedSessionKey) return;
44
125
 
45
126
  const trimmed = input.trim();
46
- const threadSessionKey = `${state.selectedSessionKey}:thread:${state.activeThreadId}`;
47
127
  dlog.info("Thread", `Send reply: ${trimmed.length > 120 ? trimmed.slice(0, 120) + "…" : trimmed}`, { threadId: state.activeThreadId });
48
128
 
49
129
  const msg: ChatMessage = {
50
- id: crypto.randomUUID(),
130
+ id: randomUUID(),
51
131
  sender: "user",
52
132
  text: trimmed,
53
133
  timestamp: Date.now(),
@@ -58,7 +138,7 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
58
138
 
59
139
  sendMessage({
60
140
  type: "user.message",
61
- sessionKey: threadSessionKey,
141
+ sessionKey: threadSessionKey!,
62
142
  text: trimmed,
63
143
  userId: state.user?.id ?? "",
64
144
  messageId: msg.id,
@@ -113,7 +193,14 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
113
193
  {new Date(parentMessage.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
114
194
  </span>
115
195
  </div>
116
- <MessageContent text={parentMessage.text} mediaUrl={parentMessage.mediaUrl} />
196
+ <MessageContent
197
+ text={parentMessage.text}
198
+ mediaUrl={parentMessage.mediaUrl}
199
+ a2ui={parentMessage.a2ui}
200
+ onAction={handleA2UIAction}
201
+ onResolveAction={(value, label) => handleResolveAction(parentMessage.id, value, label)}
202
+ resolvedActions={parentMessage.resolvedActions}
203
+ />
117
204
  </div>
118
205
  </div>
119
206
  </div>
@@ -164,7 +251,44 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
164
251
  text={msg.text}
165
252
  mediaUrl={msg.mediaUrl}
166
253
  a2ui={msg.a2ui}
254
+ isStreaming={msg.isStreaming}
255
+ onAction={handleA2UIAction}
256
+ onResolveAction={(value, label) => handleResolveAction(msg.id, value, label)}
257
+ resolvedActions={msg.resolvedActions}
167
258
  />
259
+ {msg.isStreaming && (
260
+ <div className="flex items-center gap-2 mt-1">
261
+ <span
262
+ className="inline-block w-1.5 h-4 rounded-sm animate-pulse"
263
+ style={{ background: "var(--text-link)", verticalAlign: "text-bottom" }}
264
+ />
265
+ <button
266
+ onClick={handleStop}
267
+ className="flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium transition-colors"
268
+ style={{
269
+ color: "var(--text-secondary)",
270
+ background: "var(--bg-hover)",
271
+ border: "1px solid var(--border)",
272
+ }}
273
+ onMouseEnter={(e) => {
274
+ e.currentTarget.style.background = "#e74c3c";
275
+ e.currentTarget.style.color = "#fff";
276
+ e.currentTarget.style.borderColor = "#e74c3c";
277
+ }}
278
+ onMouseLeave={(e) => {
279
+ e.currentTarget.style.background = "var(--bg-hover)";
280
+ e.currentTarget.style.color = "var(--text-secondary)";
281
+ e.currentTarget.style.borderColor = "var(--border)";
282
+ }}
283
+ title="Stop generating"
284
+ >
285
+ <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
286
+ <rect x="6" y="6" width="12" height="12" rx="2" />
287
+ </svg>
288
+ Stop
289
+ </button>
290
+ </div>
291
+ )}
168
292
  </div>
169
293
  </div>
170
294
  </div>
@@ -193,14 +317,32 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
193
317
  style={{ color: "var(--text-primary)", minHeight: 36 }}
194
318
  />
195
319
  <div className="flex justify-end px-3 pb-2">
196
- <button
197
- onClick={handleSend}
198
- disabled={!input.trim()}
199
- className="px-3 py-1 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
200
- style={{ background: "var(--bg-active)" }}
201
- >
202
- Send
203
- </button>
320
+ {isThreadStreaming ? (
321
+ <button
322
+ onClick={handleStop}
323
+ className="px-3 py-1 rounded-sm text-caption font-bold text-white transition-colors"
324
+ style={{ background: "#e74c3c" }}
325
+ onMouseEnter={(e) => { e.currentTarget.style.background = "#c0392b"; }}
326
+ onMouseLeave={(e) => { e.currentTarget.style.background = "#e74c3c"; }}
327
+ title="Stop generating"
328
+ >
329
+ <div className="flex items-center gap-1">
330
+ <svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
331
+ <rect x="6" y="6" width="12" height="12" rx="2" />
332
+ </svg>
333
+ Stop
334
+ </div>
335
+ </button>
336
+ ) : (
337
+ <button
338
+ onClick={handleSend}
339
+ disabled={!input.trim()}
340
+ className="px-3 py-1 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
341
+ style={{ background: "var(--bg-active)" }}
342
+ >
343
+ Send
344
+ </button>
345
+ )}
204
346
  </div>
205
347
  </div>
206
348
  </div>
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Generate a UUID v4. Uses crypto.randomUUID() when available (HTTPS),
3
+ * otherwise falls back to crypto.getRandomValues() for HTTP/insecure contexts
4
+ * where randomUUID is not defined.
5
+ */
6
+ export function randomUUID(): string {
7
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
8
+ return crypto.randomUUID();
9
+ }
10
+ // Fallback for insecure context (e.g. http://0.0.0.0:8787): UUID v4 via getRandomValues
11
+ const bytes = new Uint8Array(16);
12
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
13
+ crypto.getRandomValues(bytes);
14
+ } else {
15
+ for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
16
+ }
17
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
18
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
19
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
20
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
21
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { dlog } from "./debug-log";
4
4
  import { E2eService } from "./e2e";
5
+ import { getToken, tryRefreshAccessToken } from "./api";
5
6
 
6
7
  export type WSMessage = {
7
8
  type: string;
@@ -11,7 +12,8 @@ export type WSMessage = {
11
12
  export type WSClientOptions = {
12
13
  userId: string;
13
14
  sessionId: string;
14
- token: string;
15
+ /** Function that returns the current access token (reads from localStorage). */
16
+ getToken: () => string | null;
15
17
  onMessage: (msg: WSMessage) => void;
16
18
  onStatusChange: (connected: boolean) => void;
17
19
  };
@@ -41,8 +43,14 @@ export class BotsChatWSClient {
41
43
 
42
44
  this.ws.onopen = () => {
43
45
  dlog.info("WS", "Socket opened, sending auth");
46
+ const token = this.opts.getToken();
47
+ if (!token) {
48
+ dlog.error("WS", "No access token available, closing");
49
+ this.ws?.close();
50
+ return;
51
+ }
44
52
  // Authenticate with the ConnectionDO
45
- this.ws!.send(JSON.stringify({ type: "auth", token: this.opts.token }));
53
+ this.ws!.send(JSON.stringify({ type: "auth", token }));
46
54
  };
47
55
 
48
56
  this.ws.onmessage = async (evt) => {
@@ -124,9 +132,21 @@ export class BotsChatWSClient {
124
132
  this._connected = false;
125
133
  this.opts.onStatusChange(false);
126
134
  if (!this.intentionalClose) {
127
- dlog.warn("WS", `Connection closed (code=${evt.code}), reconnecting in ${this.backoffMs}ms`);
128
- this.reconnectTimer = setTimeout(() => {
135
+ const isAuthFail = evt.code === 4001;
136
+ dlog.warn("WS", `Connection closed (code=${evt.code}), reconnecting in ${this.backoffMs}ms${isAuthFail ? " (will refresh token)" : ""}`);
137
+ this.reconnectTimer = setTimeout(async () => {
129
138
  this.backoffMs = Math.min(this.backoffMs * 2, 30000);
139
+ // On auth failure (4001), refresh the access token before reconnecting
140
+ if (isAuthFail) {
141
+ dlog.info("WS", "Refreshing access token before reconnect...");
142
+ const ok = await tryRefreshAccessToken();
143
+ if (ok) {
144
+ dlog.info("WS", "Token refreshed, reconnecting");
145
+ this.backoffMs = 1000; // reset backoff on successful refresh
146
+ } else {
147
+ dlog.error("WS", "Token refresh failed — will retry on next cycle");
148
+ }
149
+ }
130
150
  this.connect();
131
151
  }, this.backoffMs);
132
152
  } else {