@schlessera/brain-ui-react 0.34.0 → 0.34.1

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 (157) hide show
  1. package/README.md +5 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -1
  3. package/dist/components/activity/activity-page.js +8 -149
  4. package/dist/components/activity/activity-page.js.map +1 -1
  5. package/dist/components/activity/activity-rollups.d.ts +5 -0
  6. package/dist/components/activity/activity-rollups.d.ts.map +1 -0
  7. package/dist/components/activity/activity-rollups.js +39 -0
  8. package/dist/components/activity/activity-rollups.js.map +1 -0
  9. package/dist/components/activity/activity-run-detail.d.ts +11 -0
  10. package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
  11. package/dist/components/activity/activity-run-detail.js +111 -0
  12. package/dist/components/activity/activity-run-detail.js.map +1 -0
  13. package/dist/components/activity/activity-run-list.d.ts +15 -0
  14. package/dist/components/activity/activity-run-list.d.ts.map +1 -0
  15. package/dist/components/activity/activity-run-list.js +25 -0
  16. package/dist/components/activity/activity-run-list.js.map +1 -0
  17. package/dist/components/chat/brain-markdown-code.d.ts +4 -0
  18. package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
  19. package/dist/components/chat/brain-markdown-code.js +47 -0
  20. package/dist/components/chat/brain-markdown-code.js.map +1 -0
  21. package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
  22. package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
  23. package/dist/components/chat/brain-markdown-entities.js +93 -0
  24. package/dist/components/chat/brain-markdown-entities.js.map +1 -0
  25. package/dist/components/chat/brain-markdown-links.d.ts +36 -0
  26. package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
  27. package/dist/components/chat/brain-markdown-links.js +176 -0
  28. package/dist/components/chat/brain-markdown-links.js.map +1 -0
  29. package/dist/components/chat/brain-markdown-share.d.ts +15 -0
  30. package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
  31. package/dist/components/chat/brain-markdown-share.js +40 -0
  32. package/dist/components/chat/brain-markdown-share.js.map +1 -0
  33. package/dist/components/chat/brain-markdown.d.ts +1 -34
  34. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  35. package/dist/components/chat/brain-markdown.js +9 -402
  36. package/dist/components/chat/brain-markdown.js.map +1 -1
  37. package/dist/components/chat/composer.d.ts.map +1 -1
  38. package/dist/components/chat/composer.js +14 -1
  39. package/dist/components/chat/composer.js.map +1 -1
  40. package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
  41. package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
  42. package/dist/components/chat/use-markdown-highlight.js +56 -0
  43. package/dist/components/chat/use-markdown-highlight.js.map +1 -0
  44. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  45. package/dist/components/connectivity/connection-gate.js +41 -7
  46. package/dist/components/connectivity/connection-gate.js.map +1 -1
  47. package/dist/components/connectivity/connection-state.d.ts +12 -0
  48. package/dist/components/connectivity/connection-state.d.ts.map +1 -0
  49. package/dist/components/connectivity/connection-state.js +23 -0
  50. package/dist/components/connectivity/connection-state.js.map +1 -0
  51. package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
  52. package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
  53. package/dist/components/graph/graph-canvas-lazy.js +4 -0
  54. package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
  55. package/dist/components/graph/graph-controls.js +4 -1
  56. package/dist/components/graph/graph-controls.js.map +1 -1
  57. package/dist/components/graph/graph-maintenance.d.ts +8 -0
  58. package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
  59. package/dist/components/graph/graph-maintenance.js +115 -0
  60. package/dist/components/graph/graph-maintenance.js.map +1 -0
  61. package/dist/components/graph/graph-page.d.ts.map +1 -1
  62. package/dist/components/graph/graph-page.js +11 -306
  63. package/dist/components/graph/graph-page.js.map +1 -1
  64. package/dist/components/graph/graph-scene.d.ts +8 -0
  65. package/dist/components/graph/graph-scene.d.ts.map +1 -0
  66. package/dist/components/graph/graph-scene.js +208 -0
  67. package/dist/components/graph/graph-scene.js.map +1 -0
  68. package/dist/components/graph/graph-spinner.d.ts +2 -0
  69. package/dist/components/graph/graph-spinner.d.ts.map +1 -0
  70. package/dist/components/graph/graph-spinner.js +6 -0
  71. package/dist/components/graph/graph-spinner.js.map +1 -0
  72. package/dist/hooks/use-vpn-status.d.ts +5 -0
  73. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  74. package/dist/hooks/use-vpn-status.js +61 -29
  75. package/dist/hooks/use-vpn-status.js.map +1 -1
  76. package/dist/hooks/use-websocket.d.ts +3 -2
  77. package/dist/hooks/use-websocket.d.ts.map +1 -1
  78. package/dist/hooks/use-websocket.js +61 -252
  79. package/dist/hooks/use-websocket.js.map +1 -1
  80. package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
  81. package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
  82. package/dist/hooks/websocket-handlers/activity.js +10 -0
  83. package/dist/hooks/websocket-handlers/activity.js.map +1 -0
  84. package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
  85. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
  86. package/dist/hooks/websocket-handlers/chat.js +175 -0
  87. package/dist/hooks/websocket-handlers/chat.js.map +1 -0
  88. package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
  89. package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
  90. package/dist/hooks/websocket-handlers/connection.js +27 -0
  91. package/dist/hooks/websocket-handlers/connection.js.map +1 -0
  92. package/dist/hooks/websocket-handlers/index.d.ts +4 -0
  93. package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
  94. package/dist/hooks/websocket-handlers/index.js +19 -0
  95. package/dist/hooks/websocket-handlers/index.js.map +1 -0
  96. package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
  97. package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
  98. package/dist/hooks/websocket-handlers/mask.js +14 -0
  99. package/dist/hooks/websocket-handlers/mask.js.map +1 -0
  100. package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
  101. package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
  102. package/dist/hooks/websocket-handlers/provider.js +31 -0
  103. package/dist/hooks/websocket-handlers/provider.js.map +1 -0
  104. package/dist/hooks/websocket-handlers/types.d.ts +24 -0
  105. package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
  106. package/dist/hooks/websocket-handlers/types.js +2 -0
  107. package/dist/hooks/websocket-handlers/types.js.map +1 -0
  108. package/dist/stores/activity-store-helpers.d.ts +50 -0
  109. package/dist/stores/activity-store-helpers.d.ts.map +1 -0
  110. package/dist/stores/activity-store-helpers.js +143 -0
  111. package/dist/stores/activity-store-helpers.js.map +1 -0
  112. package/dist/stores/activity-store-types.d.ts +48 -0
  113. package/dist/stores/activity-store-types.d.ts.map +1 -0
  114. package/dist/stores/activity-store-types.js +2 -0
  115. package/dist/stores/activity-store-types.js.map +1 -0
  116. package/dist/stores/activity-store.d.ts +2 -78
  117. package/dist/stores/activity-store.d.ts.map +1 -1
  118. package/dist/stores/activity-store.js +22 -142
  119. package/dist/stores/activity-store.js.map +1 -1
  120. package/dist/stores/connection-store.d.ts +14 -2
  121. package/dist/stores/connection-store.d.ts.map +1 -1
  122. package/dist/stores/connection-store.js +17 -0
  123. package/dist/stores/connection-store.js.map +1 -1
  124. package/dist/styles.css +1 -1
  125. package/package.json +2 -2
  126. package/src/components/activity/activity-page.tsx +6 -477
  127. package/src/components/activity/activity-rollups.tsx +94 -0
  128. package/src/components/activity/activity-run-detail.tsx +301 -0
  129. package/src/components/activity/activity-run-list.tsx +97 -0
  130. package/src/components/chat/brain-markdown-code.tsx +60 -0
  131. package/src/components/chat/brain-markdown-entities.tsx +116 -0
  132. package/src/components/chat/brain-markdown-links.tsx +247 -0
  133. package/src/components/chat/brain-markdown-share.ts +47 -0
  134. package/src/components/chat/brain-markdown.tsx +27 -535
  135. package/src/components/chat/composer.tsx +14 -1
  136. package/src/components/chat/use-markdown-highlight.ts +63 -0
  137. package/src/components/connectivity/connection-gate.tsx +64 -13
  138. package/src/components/connectivity/connection-state.ts +37 -0
  139. package/src/components/graph/graph-canvas-lazy.tsx +4 -0
  140. package/src/components/graph/graph-controls.tsx +4 -1
  141. package/src/components/graph/graph-maintenance.tsx +304 -0
  142. package/src/components/graph/graph-page.tsx +11 -840
  143. package/src/components/graph/graph-scene.tsx +548 -0
  144. package/src/components/graph/graph-spinner.tsx +9 -0
  145. package/src/hooks/use-vpn-status.ts +64 -31
  146. package/src/hooks/use-websocket.ts +70 -273
  147. package/src/hooks/websocket-handlers/activity.ts +13 -0
  148. package/src/hooks/websocket-handlers/chat.ts +210 -0
  149. package/src/hooks/websocket-handlers/connection.ts +30 -0
  150. package/src/hooks/websocket-handlers/index.ts +59 -0
  151. package/src/hooks/websocket-handlers/mask.ts +17 -0
  152. package/src/hooks/websocket-handlers/provider.ts +35 -0
  153. package/src/hooks/websocket-handlers/types.ts +27 -0
  154. package/src/stores/activity-store-helpers.ts +164 -0
  155. package/src/stores/activity-store-types.ts +55 -0
  156. package/src/stores/activity-store.ts +21 -217
  157. package/src/stores/connection-store.ts +33 -2
@@ -0,0 +1,301 @@
1
+ import { Fragment, useEffect, useMemo, useState } from "react";
2
+ import { ArrowLeft, ChevronRight } from "lucide-react";
3
+ import { useShallow } from "zustand/react/shallow";
4
+ import { isFailureOutcome, type ActivitySpan } from "@schlessera/brain-ui-sdk/protocol";
5
+
6
+ import { api, type ActivityRunRollup } from "../../lib/api-client.js";
7
+ import {
8
+ useActivityStore,
9
+ narrativeEventsFor,
10
+ runEvents,
11
+ runSpans,
12
+ } from "../../stores/activity-store.js";
13
+ import { cn } from "../../lib/utils.js";
14
+ import { formatDuration, formatRelativeTime } from "../chat/tool-views.js";
15
+ import {
16
+ SpanEventBlock,
17
+ SpanPayload,
18
+ SpanStatusDot,
19
+ formatEffectiveCost,
20
+ formatSpanUsage,
21
+ spanToolLabel,
22
+ } from "./span-bits.js";
23
+
24
+ /**
25
+ * Chat-less run detail (cron runs; pruned runs show their rollup) — the full
26
+ * view of one run: its rollup header, the span tree with per-span usage,
27
+ * attributes and recorded events, and a raw-trace escape hatch for anything
28
+ * the rendered view does not have an opinion about.
29
+ */
30
+ export function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
31
+ const streamed = useActivityStore(useShallow((s) => runSpans(s, runId)));
32
+ const events = useActivityStore(useShallow((s) => runEvents(s, runId)));
33
+ const applySnapshot = useActivityStore((s) => s.applySnapshot);
34
+ const [pruned, setPruned] = useState<object | null>(null);
35
+ const [rollup, setRollup] = useState<ActivityRunRollup | null>(null);
36
+ const [missing, setMissing] = useState(false);
37
+ const [rawOpen, setRawOpen] = useState(false);
38
+
39
+ useEffect(() => {
40
+ let active = true;
41
+ api
42
+ // Payload bodies are excluded from run detail by default; this view's
43
+ // rows expand to them, so opt in.
44
+ .activityRun(runId, { includePayloads: true })
45
+ .then((detail) => {
46
+ if (!active) return;
47
+ if (detail.rollup) setRollup(detail.rollup);
48
+ if (detail.detailPruned) {
49
+ setPruned(detail.rollup ?? {});
50
+ } else if (detail.spans) {
51
+ applySnapshot({
52
+ type: "activity_snapshot",
53
+ view: "run",
54
+ runId,
55
+ spans: detail.spans,
56
+ events: detail.events ?? [],
57
+ highWaterSeq: { [runId]: detail.highWaterSeq ?? 0 },
58
+ });
59
+ }
60
+ })
61
+ .catch(() => {
62
+ if (active) setMissing(true);
63
+ });
64
+ return () => {
65
+ active = false;
66
+ };
67
+ }, [runId, applySnapshot]);
68
+
69
+ const root = streamed.find((s) => !s.parentSpanId);
70
+ const title = rollup?.jobName ?? rollup?.name ?? (root ? spanToolLabel(root) : runId);
71
+ // The root carries the run's usage for backend-recorded turns; a run whose
72
+ // usage only exists on children (the span-sink shape) sums them instead.
73
+ // Never both, so nothing is double-counted.
74
+ const totalUsage = useMemo(() => {
75
+ if (root?.usage) return formatSpanUsage(root);
76
+ const children = streamed.filter((s) => s.parentSpanId && s.usage);
77
+ if (children.length === 0) return null;
78
+ const sum = children.reduce(
79
+ (acc, s) => ({
80
+ inputTokens: acc.inputTokens + (s.usage?.inputTokens ?? 0),
81
+ outputTokens: acc.outputTokens + (s.usage?.outputTokens ?? 0),
82
+ cacheReadTokens: acc.cacheReadTokens + (s.usage?.cacheReadTokens ?? 0),
83
+ cacheCreationTokens: acc.cacheCreationTokens + (s.usage?.cacheCreationTokens ?? 0),
84
+ }),
85
+ { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }
86
+ );
87
+ return formatSpanUsage({ ...children[0]!, usage: { ...sum, model: undefined } });
88
+ }, [root, streamed]);
89
+
90
+ return (
91
+ <div className="flex h-full flex-col overflow-y-auto">
92
+ <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
93
+ <button
94
+ type="button"
95
+ onClick={onBack}
96
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
97
+ aria-label="Back"
98
+ >
99
+ <ArrowLeft className="h-4 w-4" />
100
+ </button>
101
+ <div className="min-w-0 flex-1">
102
+ <h1 className="truncate text-sm font-medium">{title}</h1>
103
+ <p className="truncate font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
104
+ {runId}
105
+ </p>
106
+ </div>
107
+ </div>
108
+ <div className="mx-auto w-full max-w-3xl space-y-1 p-4">
109
+ {missing && <p className="text-xs text-muted-foreground">Unknown run.</p>}
110
+ {rollup && (
111
+ <div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-[11px] text-muted-foreground">
112
+ <span className="text-foreground/80">{rollup.origin}</span>
113
+ {rollup.outcome && (
114
+ <span className={cn(isFailureOutcome(rollup.outcome) && "text-destructive")}>
115
+ {rollup.outcome}
116
+ </span>
117
+ )}
118
+ <span>{formatRelativeTime(rollup.startedAt)}</span>
119
+ {rollup.durationMs !== null && <span>{formatDuration(rollup.durationMs)}</span>}
120
+ <span>
121
+ list{" "}
122
+ <span className="font-[family-name:var(--font-mono)] text-foreground/80">
123
+ {formatEffectiveCost(rollup.costUsd)}
124
+ </span>
125
+ </span>
126
+ <span>
127
+ effective{" "}
128
+ <span className="font-[family-name:var(--font-mono)] text-foreground/80">
129
+ {formatEffectiveCost(
130
+ rollup.effectiveCostUsd,
131
+ rollup.pricingEstimate,
132
+ rollup.billingMode
133
+ )}
134
+ </span>
135
+ </span>
136
+ {rollup.billingMode && (
137
+ <span>{rollup.billingMode === "api" ? "API billed" : "subscription billed"}</span>
138
+ )}
139
+ {rollup.pricingEstimate && <span>~ estimated rates</span>}
140
+ {totalUsage && (
141
+ <span className="font-[family-name:var(--font-mono)] text-foreground/80">
142
+ {totalUsage}
143
+ </span>
144
+ )}
145
+ {rollup.failureReason && (
146
+ <span className="w-full whitespace-pre-wrap break-words pt-1 text-destructive/80">
147
+ {rollup.failureReason}
148
+ </span>
149
+ )}
150
+ </div>
151
+ )}
152
+ {pruned && (
153
+ <div className="rounded-lg border border-border-subtle bg-surface p-3 text-xs text-muted-foreground">
154
+ Detail pruned — only the rollup remains.
155
+ <pre className="mt-2 overflow-x-auto text-[11px]">{JSON.stringify(pruned, null, 2)}</pre>
156
+ </div>
157
+ )}
158
+ {streamed.map((span) => (
159
+ <DetailSpanRow key={span.spanId} span={span} depth={depthOf(span, streamed)} />
160
+ ))}
161
+ {!pruned && !missing && streamed.length === 0 && (
162
+ <p className="text-xs text-muted-foreground">No spans recorded for this run.</p>
163
+ )}
164
+ {/* The escape hatch: whatever the rendered tree has no opinion about
165
+ is still readable here, so the view is never LESS than the trace. */}
166
+ {streamed.length > 0 && (
167
+ <div className="pt-3">
168
+ <button
169
+ type="button"
170
+ onClick={() => setRawOpen((v) => !v)}
171
+ className="flex items-center gap-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
172
+ >
173
+ <ChevronRight
174
+ className={cn("h-3 w-3 transition-transform", rawOpen && "rotate-90")}
175
+ />
176
+ Raw trace
177
+ </button>
178
+ {rawOpen && (
179
+ <pre className="mt-1 max-h-[60vh] overflow-auto rounded-lg border border-border-subtle bg-surface p-2 font-[family-name:var(--font-mono)] text-[10px] leading-relaxed text-muted-foreground">
180
+ {JSON.stringify({ runId, rollup, spans: streamed, events }, null, 2)}
181
+ </pre>
182
+ )}
183
+ </div>
184
+ )}
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ /**
191
+ * One span tree row. EVERY span expands — a tool span to its recorded payload
192
+ * (AE7), any span to its usage, attributes and recorded events. The uniform
193
+ * affordance is the point: a cron root or a turn root used to be a dead dot
194
+ * with a duration, which is strictly less than the trace it was summarizing.
195
+ */
196
+ function DetailSpanRow({ span, depth }: { span: ActivitySpan; depth: number }) {
197
+ const [expanded, setExpanded] = useState(false);
198
+ const usage = formatSpanUsage(span);
199
+ // Approval wait is not execution — same boundary the subagent drill-in uses.
200
+ const duration =
201
+ span.endedAt !== undefined
202
+ ? formatDuration(span.endedAt - (span.waitUntil ?? span.startedAt))
203
+ : "…";
204
+ return (
205
+ <div style={{ paddingLeft: `${depth * 16}px` }}>
206
+ <div
207
+ className="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
208
+ onClick={() => setExpanded((v) => !v)}
209
+ >
210
+ <SpanStatusDot span={span} />
211
+ <span className="truncate font-[family-name:var(--font-mono)]">
212
+ {spanToolLabel(span)}
213
+ </span>
214
+ {span.kind !== "tool" && (
215
+ <span className="shrink-0 rounded bg-surface-raised px-1 text-[9px] uppercase text-muted-foreground/70">
216
+ {span.kind}
217
+ </span>
218
+ )}
219
+ {span.outcome && span.outcome !== "success" && (
220
+ <span className="text-[10px] uppercase">{span.outcome}</span>
221
+ )}
222
+ {span.outcomeReason && (
223
+ <span className="truncate text-[10px] text-muted-foreground/60">
224
+ {span.outcomeReason}
225
+ </span>
226
+ )}
227
+ <span className="ml-auto flex shrink-0 items-center gap-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
228
+ {usage && <span className="hidden sm:inline">{usage}</span>}
229
+ <span>{duration}</span>
230
+ </span>
231
+ <ChevronRight
232
+ className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
233
+ />
234
+ </div>
235
+ {expanded && <DetailSpanBody span={span} usage={usage} />}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ /** Everything recorded against one span, under its row. */
241
+ function DetailSpanBody({ span, usage }: { span: ActivitySpan; usage: string | null }) {
242
+ const narrative = useActivityStore(useShallow((s) => narrativeEventsFor(s, span.spanId)));
243
+ const attrs = Object.entries(span.attrs ?? {});
244
+ return (
245
+ <div className="mb-1 ml-1 space-y-1.5 border-l border-border-subtle pl-2 pt-1">
246
+ <div className="flex flex-wrap gap-x-3 gap-y-0.5 px-2 text-[10px] text-muted-foreground/70">
247
+ <span>started {new Date(span.startedAt).toLocaleString()}</span>
248
+ {span.waitUntil !== undefined && (
249
+ <span>approval wait {formatDuration(span.waitUntil - span.startedAt)}</span>
250
+ )}
251
+ {usage && <span className="font-[family-name:var(--font-mono)]">{usage}</span>}
252
+ {span.usage?.costUsd !== undefined && (
253
+ <span className="font-[family-name:var(--font-mono)]">
254
+ {formatEffectiveCost(span.usage.costUsd)} list
255
+ </span>
256
+ )}
257
+ </div>
258
+ {span.subagent?.summary && (
259
+ <p className="px-2 text-[11px] text-muted-foreground">{span.subagent.summary}</p>
260
+ )}
261
+ {attrs.length > 0 && (
262
+ <dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-2 gap-y-0.5 px-2 text-[10px]">
263
+ {attrs.map(([key, value]) => (
264
+ <Fragment key={key}>
265
+ <dt className="truncate font-[family-name:var(--font-mono)] text-muted-foreground/60">
266
+ {key}
267
+ </dt>
268
+ <dd className="min-w-0 break-words font-[family-name:var(--font-mono)] text-muted-foreground">
269
+ {typeof value === "string" ? value : JSON.stringify(value)}
270
+ </dd>
271
+ </Fragment>
272
+ ))}
273
+ </dl>
274
+ )}
275
+ {narrative.length > 0 && (
276
+ <div className="space-y-1.5 px-2">
277
+ {narrative.map((event) => (
278
+ <SpanEventBlock key={`${event.spanId}:${event.eventIndex}`} event={event} />
279
+ ))}
280
+ </div>
281
+ )}
282
+ {span.kind === "tool" && <SpanPayload spanId={span.spanId} />}
283
+ {span.kind !== "tool" && narrative.length === 0 && attrs.length === 0 && (
284
+ <p className="px-2 text-[11px] text-muted-foreground/60">
285
+ Nothing else recorded for this step.
286
+ </p>
287
+ )}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ function depthOf(span: ActivitySpan, all: ActivitySpan[]): number {
293
+ let depth = 0;
294
+ let current: ActivitySpan | undefined = span;
295
+ while (current?.parentSpanId) {
296
+ depth += 1;
297
+ current = all.find((s) => s.spanId === current!.parentSpanId);
298
+ if (depth > 6) break;
299
+ }
300
+ return depth;
301
+ }
@@ -0,0 +1,97 @@
1
+ import { AlertTriangle, Bot, Clock, Timer } from "lucide-react";
2
+ import { useShallow } from "zustand/react/shallow";
3
+ import { isFailureOutcome, type ActivitySpan } from "@schlessera/brain-ui-sdk/protocol";
4
+
5
+ import type { ActivityRunSummary } from "../../lib/api-client.js";
6
+ import { useNow } from "../../hooks/use-now.js";
7
+ import { useActivityStore } from "../../stores/activity-store.js";
8
+ import { cn } from "../../lib/utils.js";
9
+ import { formatDuration, formatRelativeTime } from "../chat/tool-views.js";
10
+ import { runCostText, spanToolLabel } from "./span-bits.js";
11
+
12
+ export function LiveRow({
13
+ span,
14
+ onOpen,
15
+ }: {
16
+ span: ActivitySpan;
17
+ onOpen: (row: { runId: string; origin: string; sessionId?: string | null }) => void;
18
+ }) {
19
+ const children = useActivityStore(
20
+ useShallow((s) => Object.values(s.spans[span.runId] ?? {}).filter((x) => x.parentSpanId))
21
+ );
22
+ const now = useNow();
23
+ const current = children.filter((c) => c.outcome === undefined).at(-1);
24
+ const currentLabel = current ? spanToolLabel(current) : null;
25
+ return (
26
+ <button
27
+ type="button"
28
+ onClick={() => onOpen({ runId: span.runId, origin: span.origin, sessionId: span.sessionId })}
29
+ className="flex w-full items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised"
30
+ >
31
+ <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-primary" />
32
+ {span.origin === "cron" ? (
33
+ <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
34
+ ) : (
35
+ <Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
36
+ )}
37
+ <span className="truncate font-medium">
38
+ {span.jobName ?? span.name}
39
+ </span>
40
+ {/* A span-only run (plain cron job) has no current step — name+elapsed
41
+ is its whole live story. */}
42
+ {currentLabel && (
43
+ <span className="truncate text-muted-foreground">· {currentLabel}</span>
44
+ )}
45
+ <span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
46
+ {formatDuration(now - span.startedAt)}
47
+ </span>
48
+ </button>
49
+ );
50
+ }
51
+
52
+ export function RunRow({
53
+ run,
54
+ onOpen,
55
+ }: {
56
+ run: ActivityRunSummary;
57
+ onOpen: (row: ActivityRunSummary) => void;
58
+ }) {
59
+ const failed = isFailureOutcome(run.outcome);
60
+ // Effective cost only — list price lives on the Spend card and the detail
61
+ // view. "—" is unknown, never $0.00 (AE3); a pre-pricing server that never
62
+ // sent the field keeps the original list-cost span instead (see runCostText).
63
+ const cost = runCostText(run);
64
+ return (
65
+ <button
66
+ type="button"
67
+ onClick={() => onOpen(run)}
68
+ className={cn(
69
+ "flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised",
70
+ failed && "border border-destructive/30 bg-destructive/5"
71
+ )}
72
+ >
73
+ {run.origin === "cron" ? (
74
+ <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
75
+ ) : (
76
+ <Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
77
+ )}
78
+ <span className="truncate">{run.jobName ?? run.name}</span>
79
+ {failed && <AlertTriangle className="h-3 w-3 shrink-0 text-destructive" />}
80
+ {run.outcome && run.outcome !== "success" && (
81
+ <span className={cn("text-[10px] uppercase", failed ? "text-destructive" : "text-muted-foreground")}>
82
+ {run.outcome}
83
+ </span>
84
+ )}
85
+ <span className="ml-auto flex shrink-0 items-center gap-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
86
+ {cost !== null && <span>{cost}</span>}
87
+ {run.durationMs !== null && (
88
+ <span className="flex items-center gap-0.5">
89
+ <Timer className="h-3 w-3" />
90
+ {formatDuration(run.durationMs)}
91
+ </span>
92
+ )}
93
+ <span>{formatRelativeTime(run.startedAt)}</span>
94
+ </span>
95
+ </button>
96
+ );
97
+ }
@@ -0,0 +1,60 @@
1
+ import React, { useRef } from "react";
2
+ import { CopyButton } from "./copy-button.js";
3
+ import { MermaidBlock } from "./mermaid-block.js";
4
+
5
+ /** Concatenate all text descendants of a React node tree. */
6
+ function extractText(node: React.ReactNode): string {
7
+ if (typeof node === "string") return node;
8
+ if (typeof node === "number") return String(node);
9
+ if (Array.isArray(node)) return node.map(extractText).join("");
10
+ if (React.isValidElement(node)) {
11
+ return extractText((node.props as { children?: React.ReactNode }).children);
12
+ }
13
+ return "";
14
+ }
15
+
16
+ /**
17
+ * If a <pre>'s children are a ```mermaid / ```mmd fence, recover the raw
18
+ * diagram source. Text is extracted recursively because rehype-highlight
19
+ * and the linkify pass may have wrapped parts of it in elements.
20
+ */
21
+ function mermaidSourceFrom(children: React.ReactNode): string | null {
22
+ for (const child of React.Children.toArray(children)) {
23
+ if (!React.isValidElement(child)) continue;
24
+ const props = child.props as { className?: string; children?: React.ReactNode };
25
+ if (typeof props.className === "string" && /\blanguage-(?:mermaid|mmd)\b/.test(props.className)) {
26
+ return extractText(props.children).replace(/\n$/, "");
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export function MarkdownPre({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) {
33
+ const mermaid = mermaidSourceFrom(children);
34
+ if (mermaid !== null) return <MermaidBlock source={mermaid} />;
35
+ return <CodePre {...props}>{children}</CodePre>;
36
+ }
37
+
38
+ function CodePre({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) {
39
+ const ref = useRef<HTMLPreElement>(null);
40
+ return (
41
+ <div className="group relative">
42
+ <pre
43
+ ref={ref}
44
+ className="overflow-x-auto rounded-lg border border-border bg-surface p-4 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed"
45
+ {...props}
46
+ >
47
+ {children}
48
+ </pre>
49
+ <CopyButton getText={() => ref.current?.textContent ?? ""} />
50
+ </div>
51
+ );
52
+ }
53
+
54
+ export function MarkdownTable({ children, ...props }: React.ComponentPropsWithoutRef<"table">) {
55
+ return (
56
+ <div className="overflow-x-auto rounded-lg border border-border">
57
+ <table {...props}>{children}</table>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,116 @@
1
+ import React from "react";
2
+ import { renderBarePathsInText } from "./brain-markdown-links.js";
3
+
4
+ const ENTITY_TAGS: Record<string, string> = {
5
+ co: "entity-co",
6
+ p: "entity-p",
7
+ proj: "entity-proj",
8
+ ev: "entity-ev",
9
+ d: "entity-d",
10
+ st: "entity-st",
11
+ f: "entity-f",
12
+ };
13
+
14
+ // Use a unicode marker that won't appear in normal text to wrap entities
15
+ // so they survive markdown parsing without relying on rehypeRaw
16
+ const ENTITY_START = "\u200B\u200B"; // zero-width spaces as delimiter
17
+ const ENTITY_SEP = "\u200B";
18
+ const ENTITY_END = "\u200B\u200B\u200B";
19
+
20
+ export function renderEntityTags(md: string): string {
21
+ // Convert entity tags to marker-based format:
22
+ // START tag SEP content END (START/SEP/END are ZWSP runs, see above)
23
+ let result = md.replace(
24
+ /<(co|p|proj|ev|d|st|f)>([\s\S]*?)<\/\1>/g,
25
+ (_match, tag, content) => `${ENTITY_START}${tag}${ENTITY_SEP}${content}${ENTITY_END}`
26
+ );
27
+ // Clean up any orphaned/unmatched entity tags
28
+ result = result.replace(/<\/?(?:co|p|proj|ev|d|st|f)>/g, "");
29
+
30
+ // Log and strip any remaining non-standard HTML-like tags the LLM may have output
31
+ result = result.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tag) => {
32
+ const allowed = [
33
+ "span", "strong", "em", "b", "i", "a", "br", "hr", "p",
34
+ "h1", "h2", "h3", "h4", "h5", "h6",
35
+ "ul", "ol", "li", "code", "pre", "blockquote",
36
+ "table", "thead", "tbody", "tr", "th", "td",
37
+ "div", "img", "del", "sup", "sub",
38
+ ];
39
+ if (allowed.includes(tag.toLowerCase())) return match;
40
+ console.warn("[entity-tags] Stripping unrecognized tag:", match);
41
+ return "";
42
+ });
43
+
44
+ return result;
45
+ }
46
+
47
+ /**
48
+ * Parse entity markers in a text string. Each entity content is run through
49
+ * `innerTransform` so file-path linkification (or any other inline transform)
50
+ * applies inside `<f>...</f>` spans too.
51
+ */
52
+ export function renderEntitiesInText(
53
+ text: string,
54
+ innerTransform: (s: string) => React.ReactNode = (s) => s
55
+ ): React.ReactNode[] {
56
+ const pattern = new RegExp(
57
+ `${ENTITY_START}(co|p|proj|ev|d|st|f)${ENTITY_SEP}(.*?)${ENTITY_END}`,
58
+ "g"
59
+ );
60
+ const nodes: React.ReactNode[] = [];
61
+ let lastIndex = 0;
62
+ let match;
63
+ let key = 0;
64
+
65
+ while ((match = pattern.exec(text)) !== null) {
66
+ if (match.index > lastIndex) {
67
+ nodes.push(innerTransform(text.slice(lastIndex, match.index)));
68
+ }
69
+ const cls = ENTITY_TAGS[match[1]] || "";
70
+ nodes.push(
71
+ <span key={key++} className={cls}>
72
+ {innerTransform(match[2])}
73
+ </span>
74
+ );
75
+ lastIndex = pattern.lastIndex;
76
+ }
77
+ if (lastIndex < text.length) {
78
+ nodes.push(innerTransform(text.slice(lastIndex)));
79
+ }
80
+ return nodes;
81
+ }
82
+ /** Process a text string for entity markers and/or bare file paths. */
83
+ function transformTextString(
84
+ text: string,
85
+ opts: { entityTags: boolean; fileLinks: boolean }
86
+ ): React.ReactNode {
87
+ const linkify = opts.fileLinks
88
+ ? (s: string): React.ReactNode => {
89
+ if (!s) return s;
90
+ const parts = renderBarePathsInText(s);
91
+ if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
92
+ return <>{parts}</>;
93
+ }
94
+ : (s: string): React.ReactNode => s;
95
+
96
+ if (opts.entityTags && text.includes(ENTITY_START)) {
97
+ return <>{renderEntitiesInText(text, opts.fileLinks ? (s) => linkify(s) : undefined)}</>;
98
+ }
99
+
100
+ return linkify(text);
101
+ }
102
+
103
+ /** Recursively process React children to expand entity markers / file paths in text nodes */
104
+ export function processChildText(children: React.ReactNode, opts: { entityTags: boolean; fileLinks: boolean }): React.ReactNode {
105
+ if (typeof children === "string") {
106
+ return transformTextString(children, opts);
107
+ }
108
+ if (Array.isArray(children)) {
109
+ return children.map((child, i) =>
110
+ typeof child === "string"
111
+ ? <React.Fragment key={i}>{transformTextString(child, opts)}</React.Fragment>
112
+ : child
113
+ );
114
+ }
115
+ return children;
116
+ }