@brainpilot/web 0.0.5 → 0.0.6

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 (52) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +5 -2
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  8. package/src/__tests__/demoConversation.test.ts +73 -0
  9. package/src/__tests__/demoReset.test.ts +24 -0
  10. package/src/__tests__/runningToast.test.ts +29 -0
  11. package/src/__tests__/tokenUsage.test.ts +48 -0
  12. package/src/__tests__/toolDisplay.test.ts +55 -0
  13. package/src/__tests__/traceReducer.test.ts +62 -0
  14. package/src/components/chat/MessageStream.tsx +97 -56
  15. package/src/components/chat/PromptComposer.tsx +120 -29
  16. package/src/components/chat/chatScrollMemory.ts +49 -0
  17. package/src/components/demo/DemoView.tsx +91 -29
  18. package/src/components/demo/TraceNodeModal.tsx +6 -2
  19. package/src/components/demo/demoBundle.ts +7 -2
  20. package/src/components/demo/demoReset.ts +16 -0
  21. package/src/components/session/AgentNetwork.tsx +68 -75
  22. package/src/components/session/AgentTraceViews.tsx +35 -70
  23. package/src/components/session/AnalyticsTab.tsx +58 -224
  24. package/src/components/session/TraceGraphView.tsx +36 -30
  25. package/src/components/session/TraceNodeDetail.tsx +61 -24
  26. package/src/components/session/agentNetworkShared.ts +10 -0
  27. package/src/components/session/traceLayout.ts +32 -0
  28. package/src/components/settings/SettingsDialog.tsx +19 -1
  29. package/src/components/shell/DesktopShell.tsx +39 -14
  30. package/src/components/sidebar/Sidebar.tsx +6 -2
  31. package/src/contexts/SSEContext.tsx +90 -1
  32. package/src/contexts/SessionContext.tsx +354 -43
  33. package/src/contexts/agentsReducer.ts +49 -0
  34. package/src/contexts/runningToast.ts +33 -0
  35. package/src/contexts/traceReducer.ts +62 -0
  36. package/src/contexts/turnTimer.test.ts +97 -0
  37. package/src/contexts/turnTimer.ts +108 -0
  38. package/src/contexts/useTurnTimer.ts +104 -0
  39. package/src/contracts/backend.ts +53 -2
  40. package/src/i18n/messages/analytics.ts +16 -6
  41. package/src/i18n/messages/chat.ts +26 -4
  42. package/src/i18n/messages/contexts.ts +2 -0
  43. package/src/i18n/messages/network.ts +13 -9
  44. package/src/i18n/messages/profile.ts +4 -0
  45. package/src/i18n/messages/settings.ts +4 -0
  46. package/src/i18n/messages/trace.ts +69 -17
  47. package/src/mocks/backend.ts +7 -0
  48. package/src/styles/global.css +204 -55
  49. package/src/utils/api.ts +105 -8
  50. package/src/utils/toolDisplay.ts +74 -0
  51. package/dist/assets/index-C-8G4D4j.js +0 -448
  52. package/dist/assets/index-C501m5OS.css +0 -1
package/src/utils/api.ts CHANGED
@@ -39,10 +39,29 @@ const API_BASE = "/api";
39
39
  // Trust-front: the hosted gateway authenticates via an httpOnly cookie that the
40
40
  // browser carries automatically. The frontend never reads, stores, or attaches a
41
41
  // token — it just makes credentialed requests.
42
- function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
43
- return fetch(input, { credentials: "include", ...init });
42
+ //
43
+ // #106: callers that drive composer state (postMessage / create) pass a
44
+ // `timeoutMs`. A hung request used to leave `isSending` true forever (the
45
+ // `finally` that resets it never ran), permanently disabling the composer and
46
+ // silently dropping the user's input. With a timeout the request rejects, the
47
+ // caller's catch surfaces a recoverable error, and `isSending` is released.
48
+ function apiFetch(
49
+ input: RequestInfo | URL,
50
+ init: RequestInit & { timeoutMs?: number } = {},
51
+ ): Promise<Response> {
52
+ const { timeoutMs, signal, ...rest } = init;
53
+ if (timeoutMs == null) {
54
+ return fetch(input, { credentials: "include", signal, ...rest });
55
+ }
56
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
57
+ // Honour an upstream signal too, if one was supplied.
58
+ const merged = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
59
+ return fetch(input, { credentials: "include", signal: merged, ...rest });
44
60
  }
45
61
 
62
+ /** #106: default ceiling for composer-driving requests (create / postMessage). */
63
+ const SEND_TIMEOUT_MS = 30_000;
64
+
46
65
  function authHeaders(json = true): Record<string, string> {
47
66
  return json ? { "Content-Type": "application/json" } : {};
48
67
  }
@@ -69,6 +88,21 @@ async function handleJson<T>(res: Response): Promise<T> {
69
88
  return (await res.json()) as T;
70
89
  }
71
90
 
91
+ /** #47: encode a Blob/File as base64 (without the data: prefix) for upload. */
92
+ function blobToBase64(blob: Blob): Promise<string> {
93
+ return new Promise((resolve, reject) => {
94
+ const reader = new FileReader();
95
+ reader.onerror = () => reject(reader.error ?? new Error("file read failed"));
96
+ reader.onload = () => {
97
+ const result = reader.result as string;
98
+ // strip the "data:<mime>;base64," prefix
99
+ const comma = result.indexOf(",");
100
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
101
+ };
102
+ reader.readAsDataURL(blob);
103
+ });
104
+ }
105
+
72
106
  export function getSSEUrl(sessionId: string): string {
73
107
  // Same origin; relative path lets EventSource follow the current host/port and
74
108
  // carry the auth cookie automatically — no token in the query string.
@@ -246,6 +280,20 @@ export const api = {
246
280
  throw new Error(await parseError(res));
247
281
  }
248
282
  },
283
+
284
+ // #47: upload a file into the workspace (base64 over the JSON byte chain).
285
+ async uploadFile(sandboxId: string, path: string, file: Blob): Promise<{ path: string; size: number }> {
286
+ const contentBase64 = await blobToBase64(file);
287
+ const res = await apiFetch(`${API_BASE}/sandbox/${sandboxId}/files`, {
288
+ method: "POST",
289
+ headers: { ...authHeaders(), "content-type": "application/json" },
290
+ body: JSON.stringify({ path, contentBase64 }),
291
+ });
292
+ if (!res.ok) {
293
+ throw new Error(await parseError(res));
294
+ }
295
+ return handleJson(res);
296
+ },
249
297
  },
250
298
 
251
299
  sessions: {
@@ -287,6 +335,7 @@ export const api = {
287
335
  await apiFetch(`${API_BASE}/sessions`, {
288
336
  method: "POST",
289
337
  headers: authHeaders(),
338
+ timeoutMs: SEND_TIMEOUT_MS,
290
339
  body: JSON.stringify({
291
340
  title,
292
341
  ...(opts.providerId ? { providerId: opts.providerId } : {}),
@@ -294,7 +343,16 @@ export const api = {
294
343
  }),
295
344
  }),
296
345
  );
297
- return normalizeSession(raw as Parameters<typeof normalizeSession>[0]);
346
+ // The runtime's POST /sessions returns the envelope `{ id, session }`
347
+ // (server.ts), unlike GET /sessions[/:id] which return the bare session.
348
+ // Unwrap `session` if present so normalizeSession reads the real `title`
349
+ // instead of falling back to `Session <id8>` (#96). Tolerate a bare
350
+ // object too (mock / future shape change).
351
+ const envelope = raw as { session?: unknown } | null;
352
+ const sessionRaw = envelope && typeof envelope === "object" && "session" in envelope
353
+ ? envelope.session
354
+ : raw;
355
+ return normalizeSession(sessionRaw as Parameters<typeof normalizeSession>[0]);
298
356
  },
299
357
 
300
358
  async update(sessionId: string, title: string): Promise<Session> {
@@ -323,15 +381,19 @@ export const api = {
323
381
  );
324
382
  },
325
383
 
326
- async interrupt(sessionId: string): Promise<{ status: string }> {
384
+ async interrupt(sessionId: string): Promise<{ interrupted: boolean }> {
327
385
  if (runtimeConfig.useMockBackend) {
328
- return { status: "ok" };
386
+ return { interrupted: true };
329
387
  }
330
- return handleJson<{ status: string }>(
331
- await apiFetch(`${API_BASE}/sessions/${sessionId}/messages`, {
388
+ // #90: Stop = whole-session interrupt. Hit the dedicated interrupt route
389
+ // (RUNTIME_ROUTES.interrupt), NOT /messages — the messages endpoint's body
390
+ // schema rejects {type:"interrupt"} so the agent was never actually
391
+ // stopped. Empty body = interrupt every agent in the session.
392
+ return handleJson<{ interrupted: boolean }>(
393
+ await apiFetch(`${API_BASE}/sessions/${sessionId}/interrupt`, {
332
394
  method: "POST",
333
395
  headers: { ...authHeaders(), "Content-Type": "application/json" },
334
- body: JSON.stringify({ type: "interrupt", session_id: sessionId }),
396
+ body: JSON.stringify({}),
335
397
  }),
336
398
  );
337
399
  },
@@ -348,6 +410,7 @@ export const api = {
348
410
  await apiFetch(`${API_BASE}/sessions/${sessionId}/messages`, {
349
411
  method: "POST",
350
412
  headers: { ...authHeaders(), "Content-Type": "application/json" },
413
+ timeoutMs: SEND_TIMEOUT_MS,
351
414
  body: JSON.stringify({
352
415
  type: payload.type ?? "user_message",
353
416
  content: payload.content,
@@ -419,6 +482,40 @@ export const api = {
419
482
  return Array.isArray(raw.events) ? (raw.events as RawAgUiEvent[]) : [];
420
483
  },
421
484
 
485
+ /**
486
+ * Persisted AG-UI event history from `events.jsonl` — used to rehydrate
487
+ * the chat list (and trace/agents seed) when a session is activated after
488
+ * a runtime restart. SSE only replays the in-memory ring buffer; this
489
+ * endpoint walks the on-disk log and returns the tail when long. Pass
490
+ * `limit: 0` to request the full log for lossless rehydrate.
491
+ *
492
+ * Tolerates any non-200 / non-JSON response by returning an empty
493
+ * envelope, so callers can fall through to whatever live data the SSE
494
+ * stream eventually delivers.
495
+ */
496
+ async getHistory(
497
+ sessionId: string,
498
+ opts: { limit?: number } = {},
499
+ ): Promise<{ events: RawAgUiEvent[]; total: number; truncated: boolean }> {
500
+ if (runtimeConfig.useMockBackend) {
501
+ return { events: [], total: 0, truncated: false };
502
+ }
503
+ const qs = opts.limit !== undefined ? `?limit=${encodeURIComponent(opts.limit)}` : "";
504
+ const res = await apiFetch(
505
+ `${API_BASE}/sessions/${sessionId}/history${qs}`,
506
+ { headers: authHeaders() },
507
+ );
508
+ if (!res.ok) return { events: [], total: 0, truncated: false };
509
+ const raw = (await res.json().catch(() => null)) as
510
+ | { events?: unknown[]; total?: number; truncated?: boolean }
511
+ | null;
512
+ return {
513
+ events: Array.isArray(raw?.events) ? (raw!.events as RawAgUiEvent[]) : [],
514
+ total: typeof raw?.total === "number" ? raw!.total : 0,
515
+ truncated: Boolean(raw?.truncated),
516
+ };
517
+ },
518
+
422
519
  async state(sessionId: string): Promise<SessionStateSnapshot> {
423
520
  if (runtimeConfig.useMockBackend) {
424
521
  return mockBackend.state();
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Presentation helpers for the tool-activity block (#84).
3
+ *
4
+ * The runtime namespaces MCP tools as `mcp__<server>__<tool>` (see
5
+ * packages/runtime/src/mcp-bridge.ts) to avoid collisions, and tool
6
+ * args/results arrive as already-encoded JSON strings. Surfacing those raw in
7
+ * the chat UI reads like debug output:
8
+ * - `mcp__bp_skills__skills_tool` instead of a friendly name, and
9
+ * - payloads double-encoded into `\"key\": \"value\"` walls of backslashes.
10
+ *
11
+ * These helpers are display-only — the raw name/payload stays available for
12
+ * copying and debugging; nothing here touches the wire protocol.
13
+ */
14
+
15
+ /**
16
+ * Friendly tool name. `mcp__<server>__<tool>` collapses to `<server> · <tool>`;
17
+ * any other name (built-in tools, already-friendly names) is returned as-is.
18
+ *
19
+ * The MCP prefix split is intentionally lenient: a server or tool segment may
20
+ * itself contain single underscores, so we split on the literal `mcp__` prefix
21
+ * and the FIRST `__` separator after the server name.
22
+ */
23
+ export function formatToolName(raw: string | undefined | null): string {
24
+ if (!raw) return "tool";
25
+ if (!raw.startsWith("mcp__")) return raw;
26
+ const rest = raw.slice("mcp__".length);
27
+ const sep = rest.indexOf("__");
28
+ if (sep <= 0 || sep >= rest.length - 2) {
29
+ // Malformed (no tool segment) — show the un-prefixed remainder rather than
30
+ // the raw mcp__ identifier.
31
+ return rest || raw;
32
+ }
33
+ const server = rest.slice(0, sep);
34
+ const tool = rest.slice(sep + 2);
35
+ return `${server} · ${tool}`;
36
+ }
37
+
38
+ /**
39
+ * Pretty-print a tool payload without double-escaping. Tool args/results are
40
+ * accumulated as JSON strings over TOOL_CALL_ARGS deltas; calling
41
+ * JSON.stringify on an already-stringified value yields a `\"`-littered wall.
42
+ *
43
+ * Strategy:
44
+ * - string that parses as JSON → parse, then pretty-print the value;
45
+ * - string that does NOT parse → return verbatim (plain text / partial);
46
+ * - anything else (object/etc.) → pretty-print directly.
47
+ *
48
+ * Returns "" for null/undefined so callers can skip empty <pre> blocks.
49
+ */
50
+ export function formatPayload(value: unknown): string {
51
+ if (value === undefined || value === null) return "";
52
+ if (typeof value === "string") {
53
+ const trimmed = value.trim();
54
+ if (trimmed === "") return "";
55
+ // Only attempt a parse when it looks like JSON — avoids turning a bare
56
+ // number/quoted word into a reformatted value users didn't write.
57
+ const looksJson =
58
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
59
+ (trimmed.startsWith("[") && trimmed.endsWith("]"));
60
+ if (looksJson) {
61
+ try {
62
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
63
+ } catch {
64
+ return value;
65
+ }
66
+ }
67
+ return value;
68
+ }
69
+ try {
70
+ return JSON.stringify(value, null, 2);
71
+ } catch {
72
+ return String(value);
73
+ }
74
+ }