@schlessera/brain-ui-react 0.12.1 → 0.13.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 (101) hide show
  1. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  2. package/dist/components/chat/brain-markdown.js +12 -5
  3. package/dist/components/chat/brain-markdown.js.map +1 -1
  4. package/dist/components/chat/chat-page.d.ts.map +1 -1
  5. package/dist/components/chat/chat-page.js +2 -1
  6. package/dist/components/chat/chat-page.js.map +1 -1
  7. package/dist/components/chat/mermaid-viewer.d.ts +11 -1
  8. package/dist/components/chat/mermaid-viewer.d.ts.map +1 -1
  9. package/dist/components/chat/mermaid-viewer.js +8 -250
  10. package/dist/components/chat/mermaid-viewer.js.map +1 -1
  11. package/dist/components/chat/message-bubble.d.ts.map +1 -1
  12. package/dist/components/chat/message-bubble.js +2 -1
  13. package/dist/components/chat/message-bubble.js.map +1 -1
  14. package/dist/components/chat/session-drawer.d.ts.map +1 -1
  15. package/dist/components/chat/session-drawer.js +11 -3
  16. package/dist/components/chat/session-drawer.js.map +1 -1
  17. package/dist/components/chat/share-card.d.ts +9 -0
  18. package/dist/components/chat/share-card.d.ts.map +1 -0
  19. package/dist/components/chat/share-card.js +58 -0
  20. package/dist/components/chat/share-card.js.map +1 -0
  21. package/dist/components/dev/share-harness.d.ts +4 -0
  22. package/dist/components/dev/share-harness.d.ts.map +1 -0
  23. package/dist/components/dev/share-harness.js +104 -0
  24. package/dist/components/dev/share-harness.js.map +1 -0
  25. package/dist/components/files/file-viewer-binary.d.ts.map +1 -1
  26. package/dist/components/files/file-viewer-binary.js +2 -1
  27. package/dist/components/files/file-viewer-binary.js.map +1 -1
  28. package/dist/components/files/file-viewer.d.ts.map +1 -1
  29. package/dist/components/files/file-viewer.js +11 -2
  30. package/dist/components/files/file-viewer.js.map +1 -1
  31. package/dist/components/images/image-share.d.ts +18 -0
  32. package/dist/components/images/image-share.d.ts.map +1 -0
  33. package/dist/components/images/image-share.js +47 -0
  34. package/dist/components/images/image-share.js.map +1 -0
  35. package/dist/components/images/image-viewer.d.ts +17 -0
  36. package/dist/components/images/image-viewer.d.ts.map +1 -0
  37. package/dist/components/images/image-viewer.js +20 -0
  38. package/dist/components/images/image-viewer.js.map +1 -0
  39. package/dist/components/images/zoomable-image.d.ts +35 -0
  40. package/dist/components/images/zoomable-image.d.ts.map +1 -0
  41. package/dist/components/images/zoomable-image.js +40 -0
  42. package/dist/components/images/zoomable-image.js.map +1 -0
  43. package/dist/components/viewer/zoom-viewer.d.ts +26 -0
  44. package/dist/components/viewer/zoom-viewer.d.ts.map +1 -0
  45. package/dist/components/viewer/zoom-viewer.js +254 -0
  46. package/dist/components/viewer/zoom-viewer.js.map +1 -0
  47. package/dist/hooks/use-share-intake.d.ts +6 -0
  48. package/dist/hooks/use-share-intake.d.ts.map +1 -0
  49. package/dist/hooks/use-share-intake.js +153 -0
  50. package/dist/hooks/use-share-intake.js.map +1 -0
  51. package/dist/hooks/use-websocket.d.ts +13 -0
  52. package/dist/hooks/use-websocket.d.ts.map +1 -1
  53. package/dist/hooks/use-websocket.js +24 -1
  54. package/dist/hooks/use-websocket.js.map +1 -1
  55. package/dist/index.d.ts +4 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +10 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/lib/image-optimize.d.ts +62 -0
  60. package/dist/lib/image-optimize.d.ts.map +1 -0
  61. package/dist/lib/image-optimize.js +152 -0
  62. package/dist/lib/image-optimize.js.map +1 -0
  63. package/dist/lib/image-source.d.ts +13 -0
  64. package/dist/lib/image-source.d.ts.map +1 -0
  65. package/dist/lib/image-source.js +57 -0
  66. package/dist/lib/image-source.js.map +1 -0
  67. package/dist/lib/share-intake.d.ts +49 -0
  68. package/dist/lib/share-intake.d.ts.map +1 -0
  69. package/dist/lib/share-intake.js +203 -0
  70. package/dist/lib/share-intake.js.map +1 -0
  71. package/dist/stores/chat-store.d.ts +7 -1
  72. package/dist/stores/chat-store.d.ts.map +1 -1
  73. package/dist/stores/chat-store.js +13 -3
  74. package/dist/stores/chat-store.js.map +1 -1
  75. package/dist/stores/share-store.d.ts +29 -0
  76. package/dist/stores/share-store.d.ts.map +1 -0
  77. package/dist/stores/share-store.js +19 -0
  78. package/dist/stores/share-store.js.map +1 -0
  79. package/dist/styles.css +1 -1
  80. package/package.json +2 -2
  81. package/src/components/chat/brain-markdown.tsx +15 -9
  82. package/src/components/chat/chat-page.tsx +6 -0
  83. package/src/components/chat/mermaid-viewer.tsx +15 -310
  84. package/src/components/chat/message-bubble.tsx +6 -2
  85. package/src/components/chat/session-drawer.tsx +19 -3
  86. package/src/components/chat/share-card.tsx +185 -0
  87. package/src/components/dev/share-harness.tsx +243 -0
  88. package/src/components/files/file-viewer-binary.tsx +10 -1
  89. package/src/components/files/file-viewer.tsx +11 -2
  90. package/src/components/images/image-share.ts +57 -0
  91. package/src/components/images/image-viewer.tsx +50 -0
  92. package/src/components/images/zoomable-image.tsx +93 -0
  93. package/src/components/viewer/zoom-viewer.tsx +354 -0
  94. package/src/hooks/use-share-intake.ts +190 -0
  95. package/src/hooks/use-websocket.ts +24 -1
  96. package/src/index.ts +21 -1
  97. package/src/lib/image-optimize.ts +195 -0
  98. package/src/lib/image-source.ts +53 -0
  99. package/src/lib/share-intake.ts +231 -0
  100. package/src/stores/chat-store.ts +25 -4
  101. package/src/stores/share-store.ts +54 -0
@@ -1,40 +1,19 @@
1
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
- import { createPortal } from "react-dom";
3
- import { Minus, Plus, Maximize2, X } from "lucide-react";
1
+ import { useMemo } from "react";
4
2
  import { sizeSvgForExport } from "../../lib/mermaid.js";
5
3
  import { ShareMenu } from "../share/share-menu.js";
4
+ import { ZoomViewer } from "../viewer/zoom-viewer.js";
6
5
  import { buildDiagramShareOptions } from "./mermaid-share.js";
7
6
 
8
7
  /**
9
- * Full-screen, pan-and-zoom view of one diagram.
10
- *
11
- * Hand-rolled on pointer events rather than pulling a pan/zoom library: the
12
- * whole surface is two transforms and the repo has no other dependency of that
13
- * shape. Touch gestures work because the stage sets `touch-action: none` —
14
- * the browser hands us every pointer instead of scrolling the page, which is
15
- * also why no preventDefault is needed on the touch path. Wheel is the one
16
- * exception: React's onWheel is passive, so it is bound manually.
8
+ * Full-screen, pan-and-zoom view of one diagram. The gesture surface is
9
+ * `ZoomViewer`, shared with the image viewer; this only supplies the SVG and
10
+ * the diagram-specific share menu.
17
11
  *
18
12
  * The SVG string is the same one the block already rendered, so opening the
19
13
  * viewer costs no mermaid work. Both copies carry mermaid's ids; that is
20
14
  * harmless because the markup is byte-identical — a `url(#id)` in either copy
21
15
  * resolves to an identical marker.
22
16
  */
23
-
24
- const MIN_SCALE = 0.15;
25
- const MAX_SCALE = 12;
26
- /** Small diagrams may grow to fill the stage, but blowing one up 8x reads as broken. */
27
- const MAX_FIT_SCALE = 2.5;
28
-
29
- interface Point {
30
- x: number;
31
- y: number;
32
- }
33
-
34
- function clampScale(s: number): number {
35
- return Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
36
- }
37
-
38
17
  export function MermaidViewer({
39
18
  svg,
40
19
  source,
@@ -44,293 +23,19 @@ export function MermaidViewer({
44
23
  source: string;
45
24
  onClose: () => void;
46
25
  }) {
47
- const stageRef = useRef<HTMLDivElement>(null);
48
- const contentRef = useRef<HTMLDivElement>(null);
49
26
  // Mermaid ships `width: 100%` + `max-width` on the svg, which has no
50
27
  // intrinsic size to measure or to scale from. Reuse the export sizer to pin
51
- // it to its viewBox pixels; the transform below does all the scaling.
28
+ // it to its viewBox pixels; the viewer's transform does all the scaling.
52
29
  const sizedSvg = useMemo(() => sizeSvgForExport(svg), [svg]);
53
- const [scale, setScale] = useState(1);
54
- const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
55
- const [ready, setReady] = useState(false);
56
-
57
- // Live gesture state lives in refs: pointer maths must not wait for a render.
58
- const pointers = useRef(new Map<number, Point>());
59
- const pinch = useRef<{ dist: number; center: Point } | null>(null);
60
- const panFrom = useRef<{ pointer: Point; offset: Point } | null>(null);
61
- const view = useRef({ scale: 1, offset: { x: 0, y: 0 } });
62
- /** The scale `fit()` last produced — the baseline double-click toggles against. */
63
- const fitScale = useRef(1);
64
- /** Set once the user pans/zooms; after that nothing may re-fit behind their back. */
65
- const touched = useRef(false);
66
-
67
- const apply = useCallback((next: { scale: number; offset: Point }) => {
68
- view.current = next;
69
- setScale(next.scale);
70
- setOffset(next.offset);
71
- }, []);
72
-
73
- /** Natural (untransformed) size of the diagram — offsetWidth ignores transforms. */
74
- const natural = useCallback((): { w: number; h: number } | null => {
75
- const el = contentRef.current;
76
- if (!el || !el.offsetWidth || !el.offsetHeight) return null;
77
- return { w: el.offsetWidth, h: el.offsetHeight };
78
- }, []);
79
-
80
- const fit = useCallback(() => {
81
- const stage = stageRef.current;
82
- const size = natural();
83
- if (!stage || !size) return;
84
- const pad = 32;
85
- const sx = (stage.clientWidth - pad * 2) / size.w;
86
- const sy = (stage.clientHeight - pad * 2) / size.h;
87
- const next = clampScale(Math.min(MAX_FIT_SCALE, Math.min(sx, sy)));
88
- fitScale.current = next;
89
- apply({
90
- scale: next,
91
- offset: {
92
- x: (stage.clientWidth - size.w * next) / 2,
93
- y: (stage.clientHeight - size.h * next) / 2,
94
- },
95
- });
96
- }, [apply, natural]);
97
-
98
- useLayoutEffect(() => {
99
- // Re-runs when the diagram itself changes, which happens while the message
100
- // is still streaming — so a user who has already panned keeps their view.
101
- if (!touched.current) fit();
102
- setReady(true);
103
- }, [fit, sizedSvg]);
104
-
105
- /** Zoom about a stage-local anchor, so the point under the cursor/fingers stays put. */
106
- const zoomAt = useCallback(
107
- (factor: number, anchor: Point) => {
108
- // Any deliberate zoom counts as touching the view — including the
109
- // toolbar buttons and the wheel. Without this a diagram that is still
110
- // streaming would re-fit itself out from under the reader's zoom.
111
- touched.current = true;
112
- const { scale: prev, offset: prevOffset } = view.current;
113
- const next = clampScale(prev * factor);
114
- if (next === prev) return;
115
- const ratio = next / prev;
116
- apply({
117
- scale: next,
118
- offset: {
119
- x: anchor.x - (anchor.x - prevOffset.x) * ratio,
120
- y: anchor.y - (anchor.y - prevOffset.y) * ratio,
121
- },
122
- });
123
- },
124
- [apply]
125
- );
126
-
127
- const zoomCenter = useCallback(
128
- (factor: number) => {
129
- const stage = stageRef.current;
130
- if (!stage) return;
131
- zoomAt(factor, { x: stage.clientWidth / 2, y: stage.clientHeight / 2 });
132
- },
133
- [zoomAt]
134
- );
135
-
136
- const stagePoint = (e: { clientX: number; clientY: number }): Point => {
137
- const rect = stageRef.current?.getBoundingClientRect();
138
- return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
139
- };
140
-
141
- // Wheel must be bound manually: React's onWheel is passive, so it cannot
142
- // preventDefault, and the page behind would scroll (or the browser would
143
- // pinch-zoom the whole PWA on a ctrl+wheel trackpad gesture).
144
- useEffect(() => {
145
- const stage = stageRef.current;
146
- if (!stage) return;
147
- const onWheel = (e: WheelEvent) => {
148
- e.preventDefault();
149
- // A trackpad pinch arrives as ctrlKey+wheel with small deltas; a mouse
150
- // wheel arrives as coarse notches. Both map to the same exponential.
151
- const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
152
- zoomAt(factor, stagePoint(e));
153
- };
154
- stage.addEventListener("wheel", onWheel, { passive: false });
155
- return () => stage.removeEventListener("wheel", onWheel);
156
- }, [zoomAt]);
157
-
158
- useEffect(() => {
159
- const onKey = (e: KeyboardEvent) => {
160
- if (e.key === "Escape") onClose();
161
- else if (e.key === "+" || e.key === "=") zoomCenter(1.25);
162
- else if (e.key === "-" || e.key === "_") zoomCenter(0.8);
163
- else if (e.key === "0") fit();
164
- };
165
- document.addEventListener("keydown", onKey);
166
- return () => document.removeEventListener("keydown", onKey);
167
- }, [onClose, zoomCenter, fit]);
168
-
169
- // Lock body scroll while open.
170
- useEffect(() => {
171
- const prev = document.body.style.overflow;
172
- document.body.style.overflow = "hidden";
173
- return () => {
174
- document.body.style.overflow = prev;
175
- };
176
- }, []);
177
-
178
- // Re-fit on rotation / resize only while the view is untouched, so a resize
179
- // never yanks a diagram the user has deliberately panned into place.
180
- useEffect(() => {
181
- const onResize = () => {
182
- if (!touched.current) fit();
183
- };
184
- window.addEventListener("resize", onResize);
185
- return () => window.removeEventListener("resize", onResize);
186
- }, [fit]);
187
-
188
- const onPointerDown = (e: React.PointerEvent) => {
189
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
190
- pointers.current.set(e.pointerId, stagePoint(e));
191
- if (pointers.current.size === 2) {
192
- const [a, b] = [...pointers.current.values()];
193
- pinch.current = {
194
- dist: Math.hypot(a.x - b.x, a.y - b.y),
195
- center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
196
- };
197
- panFrom.current = null;
198
- } else if (pointers.current.size === 1) {
199
- panFrom.current = { pointer: stagePoint(e), offset: view.current.offset };
200
- }
201
- };
202
-
203
- const onPointerMove = (e: React.PointerEvent) => {
204
- if (!pointers.current.has(e.pointerId)) return;
205
- const p = stagePoint(e);
206
- pointers.current.set(e.pointerId, p);
207
-
208
- if (pointers.current.size >= 2 && pinch.current) {
209
- const [a, b] = [...pointers.current.values()];
210
- const dist = Math.hypot(a.x - b.x, a.y - b.y);
211
- const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
212
- if (pinch.current.dist > 0) {
213
- touched.current = true;
214
- // Pan and zoom in one step: the midpoint drift moves the diagram, the
215
- // distance ratio scales it about that same midpoint.
216
- const drift = {
217
- x: center.x - pinch.current.center.x,
218
- y: center.y - pinch.current.center.y,
219
- };
220
- // Committed through apply(), not written straight to the ref: two
221
- // fingers moving in parallel (or a pinch already clamped at min/max)
222
- // produce no scale change, and zoomAt() returns early — the drift
223
- // would then be stranded in the ref and the diagram would not pan.
224
- apply({
225
- scale: view.current.scale,
226
- offset: {
227
- x: view.current.offset.x + drift.x,
228
- y: view.current.offset.y + drift.y,
229
- },
230
- });
231
- zoomAt(dist / pinch.current.dist, center);
232
- }
233
- pinch.current = { dist, center };
234
- return;
235
- }
236
-
237
- if (panFrom.current) {
238
- touched.current = true;
239
- apply({
240
- scale: view.current.scale,
241
- offset: {
242
- x: panFrom.current.offset.x + (p.x - panFrom.current.pointer.x),
243
- y: panFrom.current.offset.y + (p.y - panFrom.current.pointer.y),
244
- },
245
- });
246
- }
247
- };
248
-
249
- const endPointer = (e: React.PointerEvent) => {
250
- pointers.current.delete(e.pointerId);
251
- if (pointers.current.size >= 2) {
252
- // Still pinching, but with a DIFFERENT pair (a stray third touch got
253
- // lifted). Rebase the baseline, or the next move would compare the new
254
- // pair's spread against the old pair's and jump.
255
- const [a, b] = [...pointers.current.values()];
256
- pinch.current = {
257
- dist: Math.hypot(a.x - b.x, a.y - b.y),
258
- center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
259
- };
260
- return;
261
- }
262
- pinch.current = null;
263
- if (pointers.current.size === 1) {
264
- // Lifting one finger of a pinch must not teleport the diagram: re-anchor
265
- // the pan to whichever pointer is still down.
266
- const [remaining] = [...pointers.current.values()];
267
- panFrom.current = { pointer: remaining, offset: view.current.offset };
268
- } else if (pointers.current.size === 0) {
269
- panFrom.current = null;
270
- }
271
- };
272
-
273
- const onDoubleClick = (e: React.MouseEvent) => {
274
- touched.current = true;
275
- // Compared against the FIT scale, not against 1: a diagram that fits at
276
- // 125% would otherwise read as "already zoomed" and double-click would
277
- // re-fit it to exactly where it already was.
278
- if (view.current.scale > fitScale.current * 1.05) fit();
279
- else zoomAt(2, stagePoint(e));
280
- };
281
-
282
- const btn =
283
- "flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground";
284
-
285
- // Portalled to <body>: message bubbles animate through framer-motion, and a
286
- // transformed ancestor becomes the containing block for `fixed`, which would
287
- // pin this overlay to the bubble instead of the viewport.
288
- return createPortal(
289
- <div className="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm">
290
- <div
291
- className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2"
292
- style={{ paddingTop: "calc(0.5rem + env(safe-area-inset-top))" }}
293
- >
294
- <span className="px-1 font-[family-name:var(--font-mono)] text-xs tabular-nums text-muted-foreground">
295
- {Math.round(scale * 100)}%
296
- </span>
297
- <div className="flex items-center gap-1">
298
- <button type="button" title="Zoom out" onClick={() => zoomCenter(0.8)} className={btn}>
299
- <Minus className="h-4 w-4" />
300
- </button>
301
- <button type="button" title="Zoom in" onClick={() => zoomCenter(1.25)} className={btn}>
302
- <Plus className="h-4 w-4" />
303
- </button>
304
- <button type="button" title="Fit to screen" onClick={fit} className={btn}>
305
- <Maximize2 className="h-4 w-4" />
306
- </button>
307
- <ShareMenu options={buildDiagramShareOptions(source)} title="Share diagram" />
308
- <button type="button" title="Close" onClick={onClose} className={btn}>
309
- <X className="h-4 w-4" />
310
- </button>
311
- </div>
312
- </div>
313
30
 
314
- <div
315
- ref={stageRef}
316
- className="relative flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing [&_svg]:max-w-none"
317
- onPointerDown={onPointerDown}
318
- onPointerMove={onPointerMove}
319
- onPointerUp={endPointer}
320
- onPointerCancel={endPointer}
321
- onDoubleClick={onDoubleClick}
322
- >
323
- <div
324
- ref={contentRef}
325
- className="absolute left-0 top-0 origin-top-left"
326
- style={{
327
- transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
328
- visibility: ready ? "visible" : "hidden",
329
- }}
330
- dangerouslySetInnerHTML={{ __html: sizedSvg }}
331
- />
332
- </div>
333
- </div>,
334
- document.body
31
+ return (
32
+ <ZoomViewer
33
+ onClose={onClose}
34
+ refitKey={sizedSvg}
35
+ label="Diagram viewer"
36
+ actions={<ShareMenu options={buildDiagramShareOptions(source)} title="Share diagram" />}
37
+ >
38
+ <div dangerouslySetInnerHTML={{ __html: sizedSvg }} />
39
+ </ZoomViewer>
335
40
  );
336
41
  }
@@ -16,6 +16,7 @@ import { isAskUserTool } from "../../lib/tool-names.js";
16
16
  import { motion } from "framer-motion";
17
17
  import { buildMessageShareOptions } from "./message-share.js";
18
18
  import { ShareMenu } from "../share/share-menu.js";
19
+ import { ZoomableImage } from "../images/zoomable-image.js";
19
20
 
20
21
  export function MessageBubble({
21
22
  message,
@@ -104,11 +105,14 @@ function UserAttachments({ message }: { message: ChatMessage }) {
104
105
  return (
105
106
  <div className="flex flex-wrap gap-2">
106
107
  {message.attachments.map((a, i) => (
107
- <img
108
+ // Zoomable: the thumbnail is an object-cover crop, so the full frame
109
+ // is not even visible until it opens.
110
+ <ZoomableImage
108
111
  key={i}
109
112
  src={a.previewUrl}
110
113
  alt="Attached image"
111
- className="h-20 w-20 rounded-lg border border-border/60 object-cover"
114
+ toolbar={false}
115
+ className="h-20 w-20 cursor-zoom-in rounded-lg border border-border/60 object-cover"
112
116
  />
113
117
  ))}
114
118
  </div>
@@ -32,6 +32,7 @@ export function SessionDrawer({
32
32
  const clearMessages = useChatStore((s) => s.clearMessages);
33
33
  const currentSessionId = useChatStore((s) => s.activeSessionId);
34
34
  const runStates = useChatStore((s) => s.runStates);
35
+ const queueNotes = useChatStore((s) => s.queueNotes);
35
36
  // A running/queued session other than the one in view is reattachable. Derive
36
37
  // it from the live per-session run-state (kept current by the frame demux)
37
38
  // rather than the deprecated status.activeSessionId, which multi-session
@@ -128,7 +129,7 @@ export function SessionDrawer({
128
129
  <div className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
129
130
  {session.title || "Untitled"}
130
131
  </div>
131
- <RunBadge state={runStates[session.id]} />
132
+ <RunBadge state={runStates[session.id]} note={queueNotes[session.id]} />
132
133
  </div>
133
134
  <div className="mt-0.5 flex items-center gap-2">
134
135
  <span className="text-[11px] text-muted-foreground">
@@ -153,14 +154,29 @@ export function SessionDrawer({
153
154
  }
154
155
 
155
156
  /** Live run-state pill on a session row (streaming / queued). */
156
- function RunBadge({ state }: { state?: "streaming" | "queued" | "idle" }) {
157
+ function RunBadge({
158
+ state,
159
+ note,
160
+ }: {
161
+ state?: "streaming" | "queued" | "idle";
162
+ /** Host's queue-pressure note, present only once the queue is heavy. */
163
+ note?: string;
164
+ }) {
157
165
  if (state !== "streaming" && state !== "queued") return null;
158
166
  const running = state === "streaming";
167
+ // A note only ever accompanies a queue under pressure, so it doubles as the
168
+ // "this is getting heavy" signal: the pill turns red and says how much.
169
+ const heavy = !running && Boolean(note);
159
170
  return (
160
171
  <span
172
+ title={note}
161
173
  className={cn(
162
174
  "shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide",
163
- running ? "bg-primary/15 text-primary" : "bg-amber-500/15 text-amber-500"
175
+ running
176
+ ? "bg-primary/15 text-primary"
177
+ : heavy
178
+ ? "bg-destructive/15 text-destructive"
179
+ : "bg-amber-500/15 text-amber-500"
164
180
  )}
165
181
  >
166
182
  {running ? "Running" : "Queued"}
@@ -0,0 +1,185 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { Loader2, Share2, X } from "lucide-react";
3
+ import type { StoredShare } from "@schlessera/brain-ui-sdk/share-target";
4
+ import { useShareIntake } from "../../hooks/use-share-intake.js";
5
+ import { useShareStore } from "../../stores/share-store.js";
6
+
7
+ /**
8
+ * What arrived from the system share sheet, waiting for a tap.
9
+ *
10
+ * This card is the security boundary, not a convenience. The share target is
11
+ * reachable by any website — a page that auto-submits a cross-site form to it
12
+ * is indistinguishable from a real share — so nothing is uploaded and no agent
13
+ * turn starts until the user has seen what arrived and confirmed it. Do not
14
+ * "streamline" this into an automatic send.
15
+ */
16
+
17
+ function useThumbnails(files: File[]): { url: string; name: string }[] {
18
+ const images = useMemo(
19
+ () => files.filter((file) => file.type.startsWith("image/")).slice(0, 4),
20
+ [files]
21
+ );
22
+ const [thumbs, setThumbs] = useState<{ url: string; name: string }[]>([]);
23
+
24
+ useEffect(() => {
25
+ const created = images.map((file) => ({
26
+ url: URL.createObjectURL(file),
27
+ name: file.name,
28
+ }));
29
+ setThumbs(created);
30
+ // These object URLs belong to this component alone — the ones handed to a
31
+ // sent message are made separately, and the chat store owns those.
32
+ return () => {
33
+ for (const thumb of created) URL.revokeObjectURL(thumb.url);
34
+ setThumbs([]);
35
+ };
36
+ }, [images]);
37
+
38
+ return thumbs;
39
+ }
40
+
41
+ function ShareCard({
42
+ record,
43
+ busy,
44
+ onConfirm,
45
+ onDismiss,
46
+ }: {
47
+ record: StoredShare;
48
+ busy: boolean;
49
+ onConfirm: () => void;
50
+ onDismiss: () => void;
51
+ }) {
52
+ const thumbs = useThumbnails(record.files);
53
+ const otherFiles = record.files.filter((file) => !file.type.startsWith("image/"));
54
+
55
+ return (
56
+ <div className="mb-2 rounded-lg border border-border bg-surface p-3">
57
+ <div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
58
+ <Share2 className="h-3.5 w-3.5" />
59
+ <span className="flex-1">Shared to your brain</span>
60
+ <button
61
+ type="button"
62
+ onClick={onDismiss}
63
+ title="Dismiss"
64
+ className="rounded p-0.5 transition-colors hover:text-foreground"
65
+ >
66
+ <X className="h-3.5 w-3.5" />
67
+ </button>
68
+ </div>
69
+
70
+ <div className="flex gap-3">
71
+ {thumbs.length > 0 && (
72
+ <div className="flex shrink-0 flex-wrap gap-1">
73
+ {thumbs.map((thumb) => (
74
+ <img
75
+ key={thumb.url}
76
+ src={thumb.url}
77
+ alt={thumb.name}
78
+ className="h-14 w-14 rounded-md border border-border object-cover"
79
+ />
80
+ ))}
81
+ </div>
82
+ )}
83
+
84
+ <div className="min-w-0 flex-1 space-y-1 text-sm">
85
+ {record.title && (
86
+ <div className="truncate font-medium text-foreground">{record.title}</div>
87
+ )}
88
+ {record.url && (
89
+ <div className="truncate text-xs text-muted-foreground">{record.url}</div>
90
+ )}
91
+ {record.text && (
92
+ <div className="line-clamp-3 text-xs text-muted-foreground">
93
+ {record.text}
94
+ </div>
95
+ )}
96
+ {otherFiles.map((file) => (
97
+ <div key={file.name} className="truncate text-xs text-muted-foreground">
98
+ {file.name}
99
+ </div>
100
+ ))}
101
+ </div>
102
+ </div>
103
+
104
+ <div className="mt-3 flex items-center gap-2">
105
+ <button
106
+ type="button"
107
+ onClick={onConfirm}
108
+ disabled={busy}
109
+ className="flex items-center gap-2 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-60"
110
+ >
111
+ {busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
112
+ Add to brain
113
+ </button>
114
+ <button
115
+ type="button"
116
+ onClick={onDismiss}
117
+ disabled={busy}
118
+ className="rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground disabled:opacity-60"
119
+ >
120
+ Dismiss
121
+ </button>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Drives the intake and renders the head of the queue.
129
+ *
130
+ * One card at a time, and the next only after the current one is gone: the
131
+ * client has a single unbound chat draft, so two turns started before the first
132
+ * `session_info` arrives would share a buffer and lose the second transcript.
133
+ */
134
+ export function ShareIntake() {
135
+ const { confirm, dismiss } = useShareIntake();
136
+ const queue = useShareStore((s) => s.queue);
137
+ const busy = useShareStore((s) => s.busy);
138
+ const error = useShareStore((s) => s.error);
139
+ const notes = useShareStore((s) => s.notes);
140
+ const setError = useShareStore((s) => s.setError);
141
+
142
+ const head = queue[0];
143
+ if (!head && !error) return null;
144
+
145
+ return (
146
+ <>
147
+ {error && (
148
+ <div className="mb-2 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
149
+ <div className="flex-1">{error}</div>
150
+ <button
151
+ type="button"
152
+ onClick={() => setError(null)}
153
+ title="Dismiss"
154
+ className="shrink-0 rounded p-0.5 transition-colors hover:text-foreground"
155
+ >
156
+ <X className="h-3.5 w-3.5" />
157
+ </button>
158
+ </div>
159
+ )}
160
+
161
+ {notes.length > 0 && (
162
+ <div className="mb-2 space-y-0.5 rounded-lg border border-border bg-surface px-3 py-2 text-[11px] text-muted-foreground">
163
+ {notes.map((note, i) => (
164
+ <div key={i}>{note}</div>
165
+ ))}
166
+ </div>
167
+ )}
168
+
169
+ {head && (
170
+ <ShareCard
171
+ record={head}
172
+ busy={busy}
173
+ onConfirm={() => void confirm(head)}
174
+ onDismiss={() => dismiss(head)}
175
+ />
176
+ )}
177
+
178
+ {queue.length > 1 && (
179
+ <div className="mb-2 text-[11px] text-muted-foreground">
180
+ {queue.length - 1} more share(s) waiting.
181
+ </div>
182
+ )}
183
+ </>
184
+ );
185
+ }