@schlessera/brain-ui-react 0.17.0 → 0.19.0

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 (81) hide show
  1. package/dist/components/activity/activity-page.d.ts +11 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -0
  3. package/dist/components/activity/activity-page.js +208 -0
  4. package/dist/components/activity/activity-page.js.map +1 -0
  5. package/dist/components/activity/digest-card.d.ts +10 -0
  6. package/dist/components/activity/digest-card.d.ts.map +1 -0
  7. package/dist/components/activity/digest-card.js +51 -0
  8. package/dist/components/activity/digest-card.js.map +1 -0
  9. package/dist/components/activity/push-toggle.d.ts +2 -0
  10. package/dist/components/activity/push-toggle.d.ts.map +1 -0
  11. package/dist/components/activity/push-toggle.js +82 -0
  12. package/dist/components/activity/push-toggle.js.map +1 -0
  13. package/dist/components/activity/span-bits.d.ts +28 -0
  14. package/dist/components/activity/span-bits.d.ts.map +1 -0
  15. package/dist/components/activity/span-bits.js +43 -0
  16. package/dist/components/activity/span-bits.js.map +1 -0
  17. package/dist/components/chat/chat-page.d.ts.map +1 -1
  18. package/dist/components/chat/chat-page.js +4 -1
  19. package/dist/components/chat/chat-page.js.map +1 -1
  20. package/dist/components/chat/session-drawer.d.ts.map +1 -1
  21. package/dist/components/chat/session-drawer.js +1 -15
  22. package/dist/components/chat/session-drawer.js.map +1 -1
  23. package/dist/components/chat/subagent-view.d.ts +18 -0
  24. package/dist/components/chat/subagent-view.d.ts.map +1 -0
  25. package/dist/components/chat/subagent-view.js +79 -0
  26. package/dist/components/chat/subagent-view.js.map +1 -0
  27. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  28. package/dist/components/chat/tool-call-timeline.js +40 -4
  29. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  30. package/dist/components/chat/tool-views.d.ts +4 -0
  31. package/dist/components/chat/tool-views.d.ts.map +1 -1
  32. package/dist/components/chat/tool-views.js +26 -0
  33. package/dist/components/chat/tool-views.js.map +1 -1
  34. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
  35. package/dist/components/layout/mobile-tab-bar.js +10 -4
  36. package/dist/components/layout/mobile-tab-bar.js.map +1 -1
  37. package/dist/components/layout/side-rail.d.ts.map +1 -1
  38. package/dist/components/layout/side-rail.js +7 -4
  39. package/dist/components/layout/side-rail.js.map +1 -1
  40. package/dist/hooks/use-now.d.ts +8 -0
  41. package/dist/hooks/use-now.d.ts.map +1 -0
  42. package/dist/hooks/use-now.js +32 -0
  43. package/dist/hooks/use-now.js.map +1 -0
  44. package/dist/hooks/use-websocket.d.ts.map +1 -1
  45. package/dist/hooks/use-websocket.js +46 -0
  46. package/dist/hooks/use-websocket.js.map +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/lib/api-client.d.ts +40 -1
  52. package/dist/lib/api-client.d.ts.map +1 -1
  53. package/dist/lib/api-client.js +28 -0
  54. package/dist/lib/api-client.js.map +1 -1
  55. package/dist/stores/activity-store.d.ts +93 -0
  56. package/dist/stores/activity-store.d.ts.map +1 -0
  57. package/dist/stores/activity-store.js +310 -0
  58. package/dist/stores/activity-store.js.map +1 -0
  59. package/dist/stores/ui-store.d.ts +9 -1
  60. package/dist/stores/ui-store.d.ts.map +1 -1
  61. package/dist/stores/ui-store.js +3 -0
  62. package/dist/stores/ui-store.js.map +1 -1
  63. package/dist/styles.css +1 -1
  64. package/package.json +2 -2
  65. package/src/components/activity/activity-page.tsx +495 -0
  66. package/src/components/activity/digest-card.tsx +101 -0
  67. package/src/components/activity/push-toggle.tsx +128 -0
  68. package/src/components/activity/span-bits.tsx +71 -0
  69. package/src/components/chat/chat-page.tsx +17 -0
  70. package/src/components/chat/session-drawer.tsx +1 -12
  71. package/src/components/chat/subagent-view.tsx +229 -0
  72. package/src/components/chat/tool-call-timeline.tsx +61 -3
  73. package/src/components/chat/tool-views.tsx +22 -0
  74. package/src/components/layout/mobile-tab-bar.tsx +25 -5
  75. package/src/components/layout/side-rail.tsx +17 -0
  76. package/src/hooks/use-now.ts +32 -0
  77. package/src/hooks/use-websocket.ts +44 -0
  78. package/src/index.ts +1 -0
  79. package/src/lib/api-client.ts +75 -0
  80. package/src/stores/activity-store.ts +402 -0
  81. package/src/stores/ui-store.ts +13 -1
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import {
3
+ Activity,
3
4
  Brain,
4
5
  RefreshCw,
5
6
  History,
@@ -11,7 +12,9 @@ import {
11
12
  } from "lucide-react";
12
13
  import { useUIStore } from "../../stores/ui-store.js";
13
14
  import { useChatStore } from "../../stores/chat-store.js";
15
+ import { useActivityStore } from "../../stores/activity-store.js";
14
16
  import { cn } from "../../lib/utils.js";
17
+ import { CountBadge } from "../activity/span-bits.js";
15
18
 
16
19
  export function MobileTabBar() {
17
20
  const activeView = useUIStore((s) => s.activeView);
@@ -21,6 +24,7 @@ export function MobileTabBar() {
21
24
  const toggleFilePanel = useUIStore((s) => s.toggleFilePanel);
22
25
  const toggleSettingsPanel = useUIStore((s) => s.toggleSettingsPanel);
23
26
  const clearMessages = useChatStore((s) => s.clearMessages);
27
+ const inboxCount = useActivityStore((s) => s.inbox.length);
24
28
 
25
29
  /** Chat-scoped panels live in the chat page — surface it before opening them. */
26
30
  function inChat(toggle: () => void) {
@@ -66,11 +70,15 @@ export function MobileTabBar() {
66
70
  clearMessages();
67
71
  }}
68
72
  />
73
+ {/* Activity holds the tab-bar slot; Graph moved into the More menu —
74
+ the phone glance-check ("is real work happening?") is the headline
75
+ flow and must stay one tap away (planning decision). */}
69
76
  <TabIcon
70
- icon={Waypoints}
71
- label="Graph"
72
- active={activeView === "graph"}
73
- onClick={() => setActiveView("graph")}
77
+ icon={Activity}
78
+ label="Activity"
79
+ active={activeView === "activity"}
80
+ badge={inboxCount}
81
+ onClick={() => setActiveView("activity")}
74
82
  />
75
83
  <TabIcon icon={FolderTree} label="Files" onClick={toggleFilePanel} />
76
84
  <div ref={moreRef} className="relative">
@@ -85,6 +93,14 @@ export function MobileTabBar() {
85
93
  role="menu"
86
94
  className="absolute bottom-full right-0 z-50 mb-2 min-w-[10rem] overflow-hidden rounded-xl border border-border bg-surface-overlay py-1 shadow-2xl"
87
95
  >
96
+ <MoreItem
97
+ icon={Waypoints}
98
+ label="Graph"
99
+ onClick={() => {
100
+ setMoreOpen(false);
101
+ setActiveView("graph");
102
+ }}
103
+ />
88
104
  <MoreItem
89
105
  icon={RefreshCw}
90
106
  label="Sync"
@@ -121,21 +137,25 @@ function TabIcon({
121
137
  label,
122
138
  active,
123
139
  onClick,
140
+ badge,
124
141
  }: {
125
142
  icon: typeof Brain;
126
143
  label: string;
127
144
  active?: boolean;
128
145
  onClick?: () => void;
146
+ /** Unread-count dot (the inbox badge). Hidden at 0. */
147
+ badge?: number;
129
148
  }) {
130
149
  return (
131
150
  <button
132
151
  onClick={onClick}
133
152
  className={cn(
134
- "flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]",
153
+ "relative flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]",
135
154
  active ? "text-primary" : "text-muted-foreground"
136
155
  )}
137
156
  >
138
157
  <Icon className="h-5 w-5" />
158
+ {badge !== undefined && <CountBadge count={badge} className="right-0 top-0" />}
139
159
  {label}
140
160
  </button>
141
161
  );
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Activity,
2
3
  Brain,
3
4
  RefreshCw,
4
5
  Newspaper,
@@ -9,9 +10,11 @@ import {
9
10
  Waypoints,
10
11
  } from "lucide-react";
11
12
  import { useConnectionStore } from "../../stores/connection-store.js";
13
+ import { useActivityStore } from "../../stores/activity-store.js";
12
14
  import { useUIStore } from "../../stores/ui-store.js";
13
15
  import { useChatStore, activeChat } from "../../stores/chat-store.js";
14
16
  import { cn } from "../../lib/utils.js";
17
+ import { CountBadge } from "../activity/span-bits.js";
15
18
 
16
19
  export function SideRail() {
17
20
  const wsStatus = useConnectionStore((s) => s.wsStatus);
@@ -25,6 +28,7 @@ export function SideRail() {
25
28
  const clearMessages = useChatStore((s) => s.clearMessages);
26
29
  const hasMessages = useChatStore((s) => activeChat(s).messages.length > 0);
27
30
  const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
31
+ const inboxCount = useActivityStore((s) => s.inbox.length);
28
32
 
29
33
  /** Chat-scoped panels live in the chat page — surface it before opening them. */
30
34
  function inChat(toggle: () => void) {
@@ -79,6 +83,15 @@ export function SideRail() {
79
83
  setActiveView(activeView === "graph" ? "chat" : "graph")
80
84
  }
81
85
  />
86
+ <RailButton
87
+ icon={Activity}
88
+ label="Activity"
89
+ active={activeView === "activity"}
90
+ badge={inboxCount}
91
+ onClick={() =>
92
+ setActiveView(activeView === "activity" ? "chat" : "activity")
93
+ }
94
+ />
82
95
 
83
96
  {/* Spacer */}
84
97
  <div className="flex-1" />
@@ -125,12 +138,15 @@ function RailButton({
125
138
  onClick,
126
139
  disabled,
127
140
  active,
141
+ badge,
128
142
  }: {
129
143
  icon: typeof Brain;
130
144
  label: string;
131
145
  onClick: () => void;
132
146
  disabled?: boolean;
133
147
  active?: boolean;
148
+ /** Unread-count dot (the inbox badge). Hidden at 0. */
149
+ badge?: number;
134
150
  }) {
135
151
  return (
136
152
  <button
@@ -143,6 +159,7 @@ function RailButton({
143
159
  )}
144
160
  >
145
161
  <Icon className="h-4.5 w-4.5" />
162
+ {badge !== undefined && <CountBadge count={badge} className="-right-0.5 -top-0.5" />}
146
163
  {/* Tooltip */}
147
164
  <span className="pointer-events-none absolute left-full ml-2 whitespace-nowrap rounded-md bg-surface-overlay px-2.5 py-1 text-xs font-medium text-foreground opacity-0 shadow-lg transition-opacity group-hover:opacity-100 border border-border">
148
165
  {label}
@@ -0,0 +1,32 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** All subscribed setters, driven by ONE shared interval. */
4
+ const listeners = new Set<(now: number) => void>();
5
+ let ticker: ReturnType<typeof setInterval> | null = null;
6
+
7
+ /**
8
+ * A ticking `Date.now()` for live elapsed labels. Every consumer shares a
9
+ * single setInterval (created on the first subscriber's cadence, torn down
10
+ * with the last), so a screen full of running rows costs one timer, and all
11
+ * their labels tick in the same frame.
12
+ */
13
+ export function useNow(intervalMs = 1000): number {
14
+ const [now, setNow] = useState(() => Date.now());
15
+ useEffect(() => {
16
+ listeners.add(setNow);
17
+ if (!ticker) {
18
+ ticker = setInterval(() => {
19
+ const t = Date.now();
20
+ for (const listener of listeners) listener(t);
21
+ }, intervalMs);
22
+ }
23
+ return () => {
24
+ listeners.delete(setNow);
25
+ if (listeners.size === 0 && ticker) {
26
+ clearInterval(ticker);
27
+ ticker = null;
28
+ }
29
+ };
30
+ }, [intervalMs]);
31
+ return now;
32
+ }
@@ -4,6 +4,7 @@ import { useConnectionStore } from "../stores/connection-store.js";
4
4
  import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
5
5
  import { useProviderStore } from "../stores/provider-store.js";
6
6
  import { useMaskStore } from "../stores/mask-store.js";
7
+ import { useActivityStore, loadSessionActivityHistory } from "../stores/activity-store.js";
7
8
  import { getWsUrl } from "../lib/backend.js";
8
9
  import type {
9
10
  ServerMessage,
@@ -121,9 +122,44 @@ function isOurDraftAnnouncement(
121
122
  return true;
122
123
  }
123
124
 
125
+ /**
126
+ * Lazily open a session-scoped activity subscription (live subagent rows,
127
+ * server-stamped tool timing). Idempotent per connection; the subscribed set
128
+ * resets when a new socket's server_hello arrives.
129
+ */
130
+ function ensureActivitySubscription(sessionId: string | null | undefined): void {
131
+ if (!sessionId) return;
132
+ const activity = useActivityStore.getState();
133
+ if (!activity.supported || activity.subscribed[sessionId]) return;
134
+ activity.markSubscribed(sessionId);
135
+ wsClient?.send({ type: "activity_subscribe", view: "session", sessionId });
136
+ // The subscription snapshot carries OPEN runs; finished runs (history
137
+ // timing, past fan-outs) come from the REST side.
138
+ void loadSessionActivityHistory(sessionId);
139
+ }
140
+
124
141
  export function handleServerMessage(msg: ServerMessage) {
125
142
  const state = useChatStore.getState();
126
143
 
144
+ // Activity stream frames feed their own store and never touch chat state.
145
+ if (msg.type === "server_hello") {
146
+ useActivityStore.getState().setSupported(msg.capabilities?.activity === true);
147
+ // A new hello means a new connection: server-side subscriptions are gone.
148
+ useActivityStore.getState().resetSubscriptions();
149
+ // …and view-owned subscriptions (the Activity index) must re-send too.
150
+ useActivityStore.getState().bumpConnectionEpoch();
151
+ ensureActivitySubscription(state.activeSessionId);
152
+ return;
153
+ }
154
+ if (msg.type === "activity_snapshot") {
155
+ useActivityStore.getState().applySnapshot(msg);
156
+ return;
157
+ }
158
+ if (msg.type === "activity_delta") {
159
+ useActivityStore.getState().applyDelta(msg);
160
+ return;
161
+ }
162
+
127
163
  // Parallel-session demux (per-session buffers). Every frame is scoped by
128
164
  // sessionId; a scoped frame lands in ITS session's buffer, so a background
129
165
  // session's transcript keeps accumulating while another one is in view.
@@ -186,6 +222,10 @@ export function handleServerMessage(msg: ServerMessage) {
186
222
  break;
187
223
 
188
224
  case "tool_use_start":
225
+ // A tool call INSIDE a subagent nests under its Agent entry, rendered
226
+ // from the activity stream — putting it in the flat transcript would
227
+ // interleave fan-out work with the main turn's steps.
228
+ if (msg.parentToolUseId) break;
189
229
  state.startToolCall(key, msg.toolUseId, msg.toolName);
190
230
  break;
191
231
 
@@ -194,6 +234,9 @@ export function handleServerMessage(msg: ServerMessage) {
194
234
  break;
195
235
 
196
236
  case "tool_use_complete":
237
+ // Subagent tool calls render nested (activity stream), and the flat
238
+ // store's name-based fallback match must never see them.
239
+ if (msg.parentToolUseId) break;
197
240
  state.completeToolCall(key, msg.toolUseId, msg.toolName, msg.input);
198
241
  break;
199
242
 
@@ -237,6 +280,7 @@ export function handleServerMessage(msg: ServerMessage) {
237
280
  break;
238
281
 
239
282
  case "session_info": {
283
+ ensureActivitySubscription(msg.sessionId);
240
284
  // bindDraftSession above handled draft adoption; an info frame may still
241
285
  // re-pin the provider picker when it concerns the session in view.
242
286
  const current = useChatStore.getState();
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export { ConnectionGate } from "./components/connectivity/connection-gate.js";
16
16
  export { AppShell } from "./components/layout/app-shell.js";
17
17
  export { ChatPage } from "./components/chat/chat-page.js";
18
18
  export { GraphPage } from "./components/graph/graph-page.js";
19
+ export { ActivityPage } from "./components/activity/activity-page.js";
19
20
 
20
21
  // Markdown renderer (also useful standalone, e.g. for a dev kitchen sink).
21
22
  export { BrainMarkdown } from "./components/chat/brain-markdown.js";
@@ -7,6 +7,23 @@ import type {
7
7
  ProviderInfo,
8
8
  PasskeySummary,
9
9
  ModelCatalogResponse,
10
+ ActivityRunSummary,
11
+ ActivityRunDetail,
12
+ ActivityRollups,
13
+ ActivityDigest,
14
+ ActivityIntent,
15
+ } from "@schlessera/brain-ui-sdk/protocol";
16
+
17
+ // Activity REST types live in the SDK protocol (shared with the server);
18
+ // re-exported here so existing importers keep working.
19
+ export type {
20
+ ActivityRunSummary,
21
+ ActivityRunDetail,
22
+ ActivityRunRollup,
23
+ ActivityAggregate,
24
+ ActivityRollups,
25
+ ActivityDigest,
26
+ ActivityIntent,
10
27
  } from "@schlessera/brain-ui-sdk/protocol";
11
28
  import type {
12
29
  AuthenticationResponseJSON,
@@ -219,4 +236,62 @@ export const api = {
219
236
  fetchJson<{ ok: true }>(`/auth/passkey/${encodeURIComponent(id)}`, {
220
237
  method: "DELETE",
221
238
  }),
239
+
240
+ // --- Activity record (read side; live updates ride the WebSocket) ---
241
+
242
+ activityRuns: (opts?: {
243
+ origin?: "session" | "cron";
244
+ job?: string;
245
+ session?: string;
246
+ status?: string;
247
+ limit?: number;
248
+ before?: number;
249
+ }) =>
250
+ fetchJson<{ live: ActivityRunSummary[]; history: ActivityRunSummary[] }>(
251
+ "/activity/runs?" +
252
+ [
253
+ opts?.origin && `origin=${opts.origin}`,
254
+ opts?.job && `job=${encodeURIComponent(opts.job)}`,
255
+ opts?.session && `session=${encodeURIComponent(opts.session)}`,
256
+ opts?.status && `status=${encodeURIComponent(opts.status)}`,
257
+ opts?.limit && `limit=${opts.limit}`,
258
+ opts?.before && `before=${opts.before}`,
259
+ ]
260
+ .filter(Boolean)
261
+ .join("&")
262
+ ),
263
+
264
+ activityRun: (runId: string) =>
265
+ fetchJson<ActivityRunDetail>(`/activity/runs/${encodeURIComponent(runId)}`),
266
+
267
+ activityRollups: (days?: number) =>
268
+ fetchJson<ActivityRollups>(`/activity/rollups${days ? `?days=${days}` : ""}`),
269
+
270
+ activityInbox: () => fetchJson<{ intents: ActivityIntent[] }>("/activity/inbox"),
271
+
272
+ activityInboxAck: (id: number) =>
273
+ fetchJson<{ ok: true }>(`/activity/inbox/${id}/ack`, { method: "POST" }),
274
+
275
+ activityInboxAckAll: () =>
276
+ fetchJson<{ acknowledged: number }>("/activity/inbox/ack-all", { method: "POST" }),
277
+
278
+ activityDigest: () =>
279
+ fetchJson<{ digest: ActivityDigest | null; dismissedAt: number }>("/activity/digest"),
280
+
281
+ activityDigestDismiss: () =>
282
+ fetchJson<{ ok: true }>("/activity/digest/dismiss", { method: "POST" }),
283
+
284
+ pushPublicKey: () => fetchJson<{ publicKey: string }>("/push/public-key"),
285
+
286
+ pushSubscribe: (subscription: unknown, label?: string) =>
287
+ fetchJson<{ ok: true }>("/push/subscribe", {
288
+ method: "POST",
289
+ body: JSON.stringify({ subscription, label }),
290
+ }),
291
+
292
+ pushUnsubscribe: (endpoint: string) =>
293
+ fetchJson<{ removed: boolean }>("/push/unsubscribe", {
294
+ method: "POST",
295
+ body: JSON.stringify({ endpoint }),
296
+ }),
222
297
  };