@brainpilot/web 0.0.5 → 0.0.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 (58) hide show
  1. package/dist/assets/index-DWOsU22G.css +1 -0
  2. package/dist/assets/index-j3rGyO6m.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +6 -3
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollBehavior.test.ts +48 -0
  8. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  9. package/src/__tests__/demoConversation.test.ts +96 -0
  10. package/src/__tests__/demoReset.test.ts +24 -0
  11. package/src/__tests__/internalToolStrip.test.ts +108 -0
  12. package/src/__tests__/runningToast.test.ts +29 -0
  13. package/src/__tests__/tokenUsage.test.ts +48 -0
  14. package/src/__tests__/toolDisplay.test.ts +55 -0
  15. package/src/__tests__/traceReducer.test.ts +62 -0
  16. package/src/components/chat/MessageStream.tsx +104 -56
  17. package/src/components/chat/PromptComposer.tsx +120 -29
  18. package/src/components/chat/chatScrollMemory.ts +49 -0
  19. package/src/components/demo/DemoView.tsx +98 -29
  20. package/src/components/demo/TraceNodeModal.tsx +6 -2
  21. package/src/components/demo/demoBundle.ts +7 -2
  22. package/src/components/demo/demoReset.ts +16 -0
  23. package/src/components/session/AgentNetwork.tsx +68 -75
  24. package/src/components/session/AgentTraceViews.tsx +35 -70
  25. package/src/components/session/AnalyticsTab.tsx +58 -224
  26. package/src/components/session/TraceGraphView.tsx +36 -30
  27. package/src/components/session/TraceNodeDetail.tsx +61 -24
  28. package/src/components/session/agentNetworkShared.ts +10 -0
  29. package/src/components/session/traceLayout.ts +32 -0
  30. package/src/components/settings/SettingsDialog.tsx +19 -1
  31. package/src/components/shell/DesktopShell.tsx +72 -17
  32. package/src/components/sidebar/SessionList.tsx +127 -0
  33. package/src/components/sidebar/Sidebar.tsx +94 -98
  34. package/src/contexts/SSEContext.tsx +90 -1
  35. package/src/contexts/SessionContext.tsx +397 -43
  36. package/src/contexts/agentsReducer.ts +49 -0
  37. package/src/contexts/messageGroups.ts +56 -0
  38. package/src/contexts/messageReducer.ts +4 -0
  39. package/src/contexts/runningToast.ts +33 -0
  40. package/src/contexts/traceReducer.ts +62 -0
  41. package/src/contexts/turnTimer.test.ts +97 -0
  42. package/src/contexts/turnTimer.ts +108 -0
  43. package/src/contexts/useTurnTimer.ts +104 -0
  44. package/src/contracts/backend.ts +53 -2
  45. package/src/i18n/messages/analytics.ts +16 -6
  46. package/src/i18n/messages/chat.ts +26 -4
  47. package/src/i18n/messages/contexts.ts +2 -0
  48. package/src/i18n/messages/network.ts +13 -9
  49. package/src/i18n/messages/profile.ts +4 -0
  50. package/src/i18n/messages/settings.ts +4 -0
  51. package/src/i18n/messages/shell.ts +2 -0
  52. package/src/i18n/messages/trace.ts +69 -17
  53. package/src/mocks/backend.ts +7 -0
  54. package/src/styles/global.css +289 -70
  55. package/src/utils/api.ts +105 -8
  56. package/src/utils/toolDisplay.ts +74 -0
  57. package/dist/assets/index-C-8G4D4j.js +0 -448
  58. package/dist/assets/index-C501m5OS.css +0 -1
@@ -1,21 +1,15 @@
1
1
  import {
2
- Clock3,
3
- Check,
4
- MessageCircle,
5
- MessageSquarePlus,
2
+ MessagesSquare,
6
3
  MonitorPlay,
7
4
  PanelLeft,
8
5
  PenLine,
9
- Plug,
10
- Search,
11
6
  Settings,
12
- Trash2,
13
- X,
14
7
  } from "lucide-react";
15
- import { FormEvent, useState } from "react";
8
+ import { useEffect, useRef, useState } from "react";
16
9
  import { useSessions } from "../../contexts/SessionContext";
17
10
  import { useT } from "../../i18n/useT";
18
11
  import { IconButton } from "../primitives/IconButton";
12
+ import { SessionList } from "./SessionList";
19
13
 
20
14
  type SidebarProps = {
21
15
  isCollapsed: boolean;
@@ -39,20 +33,46 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
39
33
  deleteSession,
40
34
  } = useSessions();
41
35
  const t = useT();
42
- const [editingId, setEditingId] = useState<string | null>(null);
43
- const [editingTitle, setEditingTitle] = useState("");
44
- const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
36
+ // #131 — when collapsed to the icon rail, the session list moves into a
37
+ // floating popover opened from a single icon, so it no longer competes for
38
+ // horizontal space yet stays one click away.
39
+ const [isSessionsPopoverOpen, setIsSessionsPopoverOpen] = useState(false);
40
+ const sessionsPopoverRef = useRef<HTMLDivElement | null>(null);
45
41
 
46
- const submitRename = async (event: FormEvent) => {
47
- event.preventDefault();
48
- if (!editingId || !editingTitle.trim()) {
49
- setEditingId(null);
50
- return;
51
- }
52
- await updateSessionTitle(editingId, editingTitle.trim());
53
- setEditingId(null);
42
+ const newConversation = () => {
43
+ onGoWorkspace();
44
+ startDraftSession();
54
45
  };
55
46
 
47
+ const selectAndGo = (sessionId: string) => {
48
+ onGoWorkspace();
49
+ selectSession(sessionId);
50
+ };
51
+
52
+ // Collapsing the rail (manually or at narrow widths) closes a stale popover.
53
+ useEffect(() => {
54
+ if (!isCollapsed) setIsSessionsPopoverOpen(false);
55
+ }, [isCollapsed]);
56
+
57
+ // Dismiss the popover on outside click / Escape, like a standard menu.
58
+ useEffect(() => {
59
+ if (!isSessionsPopoverOpen) return;
60
+ const onPointerDown = (event: PointerEvent) => {
61
+ if (!sessionsPopoverRef.current?.contains(event.target as Node)) {
62
+ setIsSessionsPopoverOpen(false);
63
+ }
64
+ };
65
+ const onKeyDown = (event: KeyboardEvent) => {
66
+ if (event.key === "Escape") setIsSessionsPopoverOpen(false);
67
+ };
68
+ window.addEventListener("pointerdown", onPointerDown);
69
+ window.addEventListener("keydown", onKeyDown);
70
+ return () => {
71
+ window.removeEventListener("pointerdown", onPointerDown);
72
+ window.removeEventListener("keydown", onKeyDown);
73
+ };
74
+ }, [isSessionsPopoverOpen]);
75
+
56
76
  return (
57
77
  <aside className="sidebar" aria-label={t("sidebar.aria.nav")}>
58
78
  <div
@@ -76,21 +96,54 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
76
96
  </div>
77
97
 
78
98
  <nav className="sidebar__nav" aria-label={t("sidebar.aria.primary")}>
79
- <button className="nav-item nav-item--strong" onClick={() => { onGoWorkspace(); startDraftSession(); }} type="button">
99
+ <button className="nav-item nav-item--strong" onClick={newConversation} type="button" title={t("sidebar.newChat")}>
80
100
  <PenLine size={16} />
81
101
  <span>{t("sidebar.newChat")}</span>
82
102
  </button>
83
- <button className="nav-item" type="button">
84
- <Plug size={16} />
85
- <span>{t("sidebar.plugins")}</span>
86
- </button>
87
- <button className="nav-item" type="button">
88
- <Clock3 size={16} />
89
- <span>{t("sidebar.automations")}</span>
90
- </button>
103
+ {/*
104
+ #131 collapsed icon rail: a single Sessions icon opens the session
105
+ list in a popover (the inline list below is hidden when collapsed).
106
+ Rendered only in the rail so the expanded sidebar keeps its full list.
107
+ */}
108
+ {isCollapsed ? (
109
+ <div className="sidebar__sessions-popover-anchor" ref={sessionsPopoverRef}>
110
+ <button
111
+ aria-expanded={isSessionsPopoverOpen}
112
+ aria-haspopup="menu"
113
+ className={`nav-item ${isSessionsPopoverOpen ? "is-active" : ""}`}
114
+ onClick={() => setIsSessionsPopoverOpen((open) => !open)}
115
+ title={t("sidebar.conversations")}
116
+ type="button"
117
+ >
118
+ <MessagesSquare size={16} />
119
+ <span>{t("sidebar.conversations")}</span>
120
+ </button>
121
+ {isSessionsPopoverOpen ? (
122
+ <div className="sidebar__sessions-popover" role="menu" aria-label={t("sidebar.conversations")}>
123
+ <div className="sidebar__sessions-popover-head">
124
+ <h2>{t("sidebar.conversations")}</h2>
125
+ <button className="nav-item nav-item--strong" onClick={() => { newConversation(); setIsSessionsPopoverOpen(false); }} type="button">
126
+ <PenLine size={14} />
127
+ <span>{t("sidebar.newChat")}</span>
128
+ </button>
129
+ </div>
130
+ <SessionList
131
+ sessions={sessions}
132
+ currentId={currentSession?.id}
133
+ isLoading={isLoading}
134
+ onSelect={(id) => { selectAndGo(id); setIsSessionsPopoverOpen(false); }}
135
+ onRename={updateSessionTitle}
136
+ onDelete={deleteSession}
137
+ onOpenSearch={() => { onOpenSearch(); setIsSessionsPopoverOpen(false); }}
138
+ />
139
+ </div>
140
+ ) : null}
141
+ </div>
142
+ ) : null}
91
143
  <button
92
144
  className={`nav-item ${activePage === "demo" ? "is-active" : ""}`}
93
145
  onClick={onOpenDemo}
146
+ title={t("sidebar.demo")}
94
147
  type="button"
95
148
  >
96
149
  <MonitorPlay size={16} />
@@ -102,82 +155,25 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
102
155
  <div className="section-heading">
103
156
  <h2 id="conversations-heading">{t("sidebar.conversations")}</h2>
104
157
  <div className="section-heading__actions">
105
- <IconButton label={t("sidebar.aria.newConversation")} onClick={() => { onGoWorkspace(); startDraftSession(); }}>
106
- <MessageSquarePlus size={13} />
158
+ <IconButton label={t("sidebar.aria.newConversation")} onClick={newConversation}>
159
+ <PenLine size={13} />
107
160
  </IconButton>
108
161
  </div>
109
162
  </div>
110
163
 
111
- <div className="conversation-stack">
112
- <button className="conversation-search-trigger" onClick={onOpenSearch} type="button">
113
- <Search size={14} />
114
- <span>{t("sidebar.search")}</span>
115
- </button>
116
- <p className="muted-label">{isLoading ? t("sidebar.loading") : t("sidebar.sessionCount", { count: sessions.length })}</p>
117
- {sessions.length === 0 && !isLoading ? <p className="sidebar-empty">{t("sidebar.empty")}</p> : null}
118
- {sessions.map((session) => {
119
- const isEditing = editingId === session.id;
120
- const isConfirming = confirmDeleteId === session.id;
121
- return (
122
- <div className={`conversation-item ${currentSession?.id === session.id ? "is-active" : ""}`} key={session.id}>
123
- {isEditing ? (
124
- <form className="conversation-edit" onSubmit={submitRename}>
125
- <input
126
- autoFocus
127
- onChange={(event) => setEditingTitle(event.target.value)}
128
- value={editingTitle}
129
- />
130
- <IconButton label={t("sidebar.aria.saveTitle")} type="submit">
131
- <Check size={14} />
132
- </IconButton>
133
- <IconButton label={t("sidebar.aria.cancelRename")} onClick={() => setEditingId(null)}>
134
- <X size={14} />
135
- </IconButton>
136
- </form>
137
- ) : (
138
- <>
139
- <button className="conversation-row" onClick={() => { onGoWorkspace(); selectSession(session.id); }} type="button">
140
- <MessageCircle size={16} />
141
- <span>{session.title}</span>
142
- <small>{new Date(session.updatedAt).toLocaleDateString()}</small>
143
- </button>
144
- <div className="conversation-actions">
145
- {isConfirming ? (
146
- <>
147
- <IconButton label={t("sidebar.aria.confirmDelete")} onClick={() => void deleteSession(session.id)}>
148
- <Check size={14} />
149
- </IconButton>
150
- <IconButton label={t("sidebar.aria.cancelDelete")} onClick={() => setConfirmDeleteId(null)}>
151
- <X size={14} />
152
- </IconButton>
153
- </>
154
- ) : (
155
- <>
156
- <IconButton
157
- label={t("sidebar.aria.rename")}
158
- onClick={() => {
159
- setEditingId(session.id);
160
- setEditingTitle(session.title);
161
- }}
162
- >
163
- <PenLine size={14} />
164
- </IconButton>
165
- <IconButton label={t("sidebar.aria.delete")} onClick={() => setConfirmDeleteId(session.id)}>
166
- <Trash2 size={14} />
167
- </IconButton>
168
- </>
169
- )}
170
- </div>
171
- </>
172
- )}
173
- </div>
174
- );
175
- })}
176
- </div>
164
+ <SessionList
165
+ sessions={sessions}
166
+ currentId={currentSession?.id}
167
+ isLoading={isLoading}
168
+ onSelect={selectAndGo}
169
+ onRename={updateSessionTitle}
170
+ onDelete={deleteSession}
171
+ onOpenSearch={onOpenSearch}
172
+ />
177
173
  </section>
178
174
 
179
175
  <div className="sidebar__footer">
180
- <button className="nav-item" onClick={onOpenSettings} type="button">
176
+ <button className="nav-item" onClick={onOpenSettings} type="button" title={t("sidebar.settings")}>
181
177
  <Settings size={16} />
182
178
  <span>{t("sidebar.settings")}</span>
183
179
  </button>
@@ -25,11 +25,18 @@ const SSEContext = createContext<SSEContextValue | null>(null);
25
25
 
26
26
  const RECONNECT_BASE_MS = 3000;
27
27
  const RECONNECT_MAX_MS = 30000;
28
+ // #106: if an EventSource never fires `onopen` within this window we treat the
29
+ // connection as dead and force a rebuild. A frozen tab / bfcache restore can
30
+ // leave a stale source stuck in CONNECTING whose onopen/onerror never fire
31
+ // again — without this watchdog the UI sits on "正在连接实时通道" forever.
32
+ const OPEN_WATCHDOG_MS = 8000;
28
33
 
29
34
  interface SessionConn {
30
35
  source: EventSource;
31
36
  reconnectAttempt: number;
32
37
  reconnectTimer: number | null;
38
+ /** #106: fires if onopen doesn't arrive in time — forces a reconnect. */
39
+ openWatchdog: number | null;
33
40
  /** Whether disconnectSession was called — disable auto-reconnect. */
34
41
  manuallyClosed: boolean;
35
42
  }
@@ -61,20 +68,62 @@ export function SSEProvider({ children }: { children: ReactNode }) {
61
68
  return;
62
69
  }
63
70
 
71
+ // A stale entry may exist (e.g. watchdog-forced rebuild) — clear its timers
72
+ // and close its source before replacing it.
73
+ if (conn) {
74
+ if (conn.reconnectTimer !== null) window.clearTimeout(conn.reconnectTimer);
75
+ if (conn.openWatchdog !== null) window.clearTimeout(conn.openWatchdog);
76
+ try {
77
+ conn.source.close();
78
+ } catch {
79
+ /* already closed */
80
+ }
81
+ }
82
+
64
83
  console.log(`[SSE] openConnection: ${sessionId}`);
65
84
  setStatus(sessionId, "connecting");
66
85
  const source = new EventSource(getSSEUrl(sessionId));
67
86
 
68
87
  const entry: SessionConn = {
69
88
  source,
70
- reconnectAttempt: 0,
89
+ reconnectAttempt: conn?.reconnectAttempt ?? 0,
71
90
  reconnectTimer: null,
91
+ openWatchdog: null,
72
92
  manuallyClosed: false,
73
93
  };
74
94
  connsRef.current.set(sessionId, entry);
75
95
 
96
+ // #106: if onopen never lands, the connection is wedged. Tear it down and
97
+ // reconnect through the normal backoff path so the composer doesn't stay
98
+ // disabled on a dead "connecting" state.
99
+ entry.openWatchdog = window.setTimeout(() => {
100
+ entry.openWatchdog = null;
101
+ if (entry.manuallyClosed) return;
102
+ if (entry.source.readyState === EventSource.OPEN) return;
103
+ console.warn(`[SSE] open watchdog fired for ${sessionId} — forcing reconnect`);
104
+ try {
105
+ entry.source.close();
106
+ } catch {
107
+ /* already closed */
108
+ }
109
+ setStatus(sessionId, "error");
110
+ entry.reconnectAttempt += 1;
111
+ const delay = Math.min(
112
+ RECONNECT_BASE_MS * Math.pow(2, entry.reconnectAttempt - 1),
113
+ RECONNECT_MAX_MS,
114
+ );
115
+ entry.reconnectTimer = window.setTimeout(() => {
116
+ entry.reconnectTimer = null;
117
+ openConnection(sessionId);
118
+ }, delay);
119
+ }, OPEN_WATCHDOG_MS);
120
+
76
121
  source.onopen = () => {
77
122
  entry.reconnectAttempt = 0;
123
+ if (entry.openWatchdog !== null) {
124
+ window.clearTimeout(entry.openWatchdog);
125
+ entry.openWatchdog = null;
126
+ }
78
127
  console.log(`[SSE] onopen: ${sessionId}`);
79
128
  setStatus(sessionId, "open");
80
129
  };
@@ -106,6 +155,10 @@ export function SSEProvider({ children }: { children: ReactNode }) {
106
155
 
107
156
  source.onerror = () => {
108
157
  console.error(`[SSE] onerror: ${sessionId}, reconnectAttempt=${entry.reconnectAttempt + 1}`);
158
+ if (entry.openWatchdog !== null) {
159
+ window.clearTimeout(entry.openWatchdog);
160
+ entry.openWatchdog = null;
161
+ }
109
162
  setStatus(sessionId, "error");
110
163
  source.close();
111
164
  if (entry.manuallyClosed) return;
@@ -139,6 +192,10 @@ export function SSEProvider({ children }: { children: ReactNode }) {
139
192
  window.clearTimeout(entry.reconnectTimer);
140
193
  entry.reconnectTimer = null;
141
194
  }
195
+ if (entry.openWatchdog !== null) {
196
+ window.clearTimeout(entry.openWatchdog);
197
+ entry.openWatchdog = null;
198
+ }
142
199
  entry.source.close();
143
200
  connsRef.current.delete(sessionId);
144
201
  setStatus(sessionId, "idle");
@@ -152,12 +209,44 @@ export function SSEProvider({ children }: { children: ReactNode }) {
152
209
  if (entry.reconnectTimer !== null) {
153
210
  window.clearTimeout(entry.reconnectTimer);
154
211
  }
212
+ if (entry.openWatchdog !== null) {
213
+ window.clearTimeout(entry.openWatchdog);
214
+ }
155
215
  entry.source.close();
156
216
  }
157
217
  connsRef.current.clear();
158
218
  };
159
219
  }, [isAuthReady, currentSandbox?.status]);
160
220
 
221
+ // #106: bfcache / frozen-tab restore can leave an EventSource that looks
222
+ // alive (readyState !== CLOSED) but whose onopen/onerror never fire again, so
223
+ // the composer stays stuck on "connecting". On page restore or tab
224
+ // re-focus, force any non-open connection to rebuild. The browser-native
225
+ // `pageshow` (persisted) covers bfcache; `visibilitychange` covers the more
226
+ // common "switched away and back" case.
227
+ useEffect(() => {
228
+ const revive = () => {
229
+ for (const [sessionId, entry] of connsRef.current) {
230
+ if (entry.manuallyClosed) continue;
231
+ if (entry.source.readyState === EventSource.OPEN) continue;
232
+ console.log(`[SSE] revive stale connection on restore: ${sessionId}`);
233
+ openConnection(sessionId);
234
+ }
235
+ };
236
+ const onPageShow = (event: PageTransitionEvent) => {
237
+ if (event.persisted) revive();
238
+ };
239
+ const onVisibility = () => {
240
+ if (document.visibilityState === "visible") revive();
241
+ };
242
+ window.addEventListener("pageshow", onPageShow);
243
+ document.addEventListener("visibilitychange", onVisibility);
244
+ return () => {
245
+ window.removeEventListener("pageshow", onPageShow);
246
+ document.removeEventListener("visibilitychange", onVisibility);
247
+ };
248
+ }, [openConnection]);
249
+
161
250
  const value = useMemo<SSEContextValue>(
162
251
  () => ({ connectSession, disconnectSession, queueRef, tick, connections }),
163
252
  [connectSession, disconnectSession, tick, connections],