@schlessera/brain-ui-react 0.12.1 → 0.13.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 (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
@@ -0,0 +1,354 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Minus, Plus, Maximize2, X } from "lucide-react";
4
+
5
+ /**
6
+ * Full-screen, pan-and-zoom view of one visual thing — a diagram, an image,
7
+ * anything with a natural pixel size. Callers supply the content and any extra
8
+ * toolbar actions; everything about the gesture surface lives here.
9
+ *
10
+ * Hand-rolled on pointer events rather than pulling a pan/zoom library: the
11
+ * whole surface is two transforms and the repo has no other dependency of that
12
+ * shape. Touch gestures work because the stage sets `touch-action: none` —
13
+ * the browser hands us every pointer instead of scrolling the page, which is
14
+ * also why no preventDefault is needed on the touch path. Wheel is the one
15
+ * exception: React's onWheel is passive, so it is bound manually.
16
+ */
17
+
18
+ const MIN_SCALE = 0.15;
19
+ const MAX_SCALE = 12;
20
+
21
+ interface Point {
22
+ x: number;
23
+ y: number;
24
+ }
25
+
26
+ function clampScale(s: number): number {
27
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
28
+ }
29
+
30
+ export interface ZoomViewerProps {
31
+ children: React.ReactNode;
32
+ onClose: () => void;
33
+ /**
34
+ * Extra toolbar nodes, placed left of the close button — typically a share
35
+ * menu. Rendered inside the header, above the stage.
36
+ */
37
+ actions?: React.ReactNode;
38
+ /**
39
+ * Changes when the content's natural size may have changed (a streaming
40
+ * diagram growing, an image finishing its decode). Re-fits, but only while
41
+ * the user has not panned or zoomed — after that nothing moves behind them.
42
+ */
43
+ refitKey?: unknown;
44
+ /**
45
+ * Ceiling on the scale `fit()` may choose. Small diagrams may grow to fill
46
+ * the stage (blowing one up 8x reads as broken, hence a cap), but a raster
47
+ * image has real pixels: opening it above 100% only shows interpolation, so
48
+ * image callers pass 1.
49
+ */
50
+ maxFitScale?: number;
51
+ /** Aria label for the overlay. */
52
+ label?: string;
53
+ }
54
+
55
+ export function ZoomViewer({
56
+ children,
57
+ onClose,
58
+ actions,
59
+ refitKey,
60
+ maxFitScale = 2.5,
61
+ label = "Zoomed view",
62
+ }: ZoomViewerProps) {
63
+ const stageRef = useRef<HTMLDivElement>(null);
64
+ const contentRef = useRef<HTMLDivElement>(null);
65
+ const [scale, setScale] = useState(1);
66
+ const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
67
+ const [ready, setReady] = useState(false);
68
+
69
+ // Live gesture state lives in refs: pointer maths must not wait for a render.
70
+ const pointers = useRef(new Map<number, Point>());
71
+ const pinch = useRef<{ dist: number; center: Point } | null>(null);
72
+ const panFrom = useRef<{ pointer: Point; offset: Point } | null>(null);
73
+ const view = useRef({ scale: 1, offset: { x: 0, y: 0 } });
74
+ /** The scale `fit()` last produced — the baseline double-click toggles against. */
75
+ const fitScale = useRef(1);
76
+ /** Set once the user pans/zooms; after that nothing may re-fit behind their back. */
77
+ const touched = useRef(false);
78
+
79
+ const apply = useCallback((next: { scale: number; offset: Point }) => {
80
+ view.current = next;
81
+ setScale(next.scale);
82
+ setOffset(next.offset);
83
+ }, []);
84
+
85
+ /** Natural (untransformed) size of the content — offsetWidth ignores transforms. */
86
+ const natural = useCallback((): { w: number; h: number } | null => {
87
+ const el = contentRef.current;
88
+ if (!el || !el.offsetWidth || !el.offsetHeight) return null;
89
+ return { w: el.offsetWidth, h: el.offsetHeight };
90
+ }, []);
91
+
92
+ const fit = useCallback(() => {
93
+ const stage = stageRef.current;
94
+ const size = natural();
95
+ if (!stage || !size) return;
96
+ const pad = 32;
97
+ const sx = (stage.clientWidth - pad * 2) / size.w;
98
+ const sy = (stage.clientHeight - pad * 2) / size.h;
99
+ const next = clampScale(Math.min(maxFitScale, Math.min(sx, sy)));
100
+ fitScale.current = next;
101
+ apply({
102
+ scale: next,
103
+ offset: {
104
+ x: (stage.clientWidth - size.w * next) / 2,
105
+ y: (stage.clientHeight - size.h * next) / 2,
106
+ },
107
+ });
108
+ }, [apply, natural, maxFitScale]);
109
+
110
+ useLayoutEffect(() => {
111
+ // Re-runs when the content itself changes, which happens while a message is
112
+ // still streaming — so a user who has already panned keeps their view.
113
+ if (!touched.current) fit();
114
+ setReady(true);
115
+ }, [fit, refitKey]);
116
+
117
+ /** Zoom about a stage-local anchor, so the point under the cursor/fingers stays put. */
118
+ const zoomAt = useCallback(
119
+ (factor: number, anchor: Point) => {
120
+ // Any deliberate zoom counts as touching the view — including the
121
+ // toolbar buttons and the wheel. Without this, content that is still
122
+ // streaming would re-fit itself out from under the reader's zoom.
123
+ touched.current = true;
124
+ const { scale: prev, offset: prevOffset } = view.current;
125
+ const next = clampScale(prev * factor);
126
+ if (next === prev) return;
127
+ const ratio = next / prev;
128
+ apply({
129
+ scale: next,
130
+ offset: {
131
+ x: anchor.x - (anchor.x - prevOffset.x) * ratio,
132
+ y: anchor.y - (anchor.y - prevOffset.y) * ratio,
133
+ },
134
+ });
135
+ },
136
+ [apply]
137
+ );
138
+
139
+ const zoomCenter = useCallback(
140
+ (factor: number) => {
141
+ const stage = stageRef.current;
142
+ if (!stage) return;
143
+ zoomAt(factor, { x: stage.clientWidth / 2, y: stage.clientHeight / 2 });
144
+ },
145
+ [zoomAt]
146
+ );
147
+
148
+ const stagePoint = (e: { clientX: number; clientY: number }): Point => {
149
+ const rect = stageRef.current?.getBoundingClientRect();
150
+ return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
151
+ };
152
+
153
+ // Wheel must be bound manually: React's onWheel is passive, so it cannot
154
+ // preventDefault, and the page behind would scroll (or the browser would
155
+ // pinch-zoom the whole PWA on a ctrl+wheel trackpad gesture).
156
+ useEffect(() => {
157
+ const stage = stageRef.current;
158
+ if (!stage) return;
159
+ const onWheel = (e: WheelEvent) => {
160
+ e.preventDefault();
161
+ // A trackpad pinch arrives as ctrlKey+wheel with small deltas; a mouse
162
+ // wheel arrives as coarse notches. Both map to the same exponential.
163
+ const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
164
+ zoomAt(factor, stagePoint(e));
165
+ };
166
+ stage.addEventListener("wheel", onWheel, { passive: false });
167
+ return () => stage.removeEventListener("wheel", onWheel);
168
+ }, [zoomAt]);
169
+
170
+ useEffect(() => {
171
+ const onKey = (e: KeyboardEvent) => {
172
+ if (e.key === "Escape") onClose();
173
+ else if (e.key === "+" || e.key === "=") zoomCenter(1.25);
174
+ else if (e.key === "-" || e.key === "_") zoomCenter(0.8);
175
+ else if (e.key === "0") fit();
176
+ };
177
+ document.addEventListener("keydown", onKey);
178
+ return () => document.removeEventListener("keydown", onKey);
179
+ }, [onClose, zoomCenter, fit]);
180
+
181
+ // Lock body scroll while open.
182
+ useEffect(() => {
183
+ const prev = document.body.style.overflow;
184
+ document.body.style.overflow = "hidden";
185
+ return () => {
186
+ document.body.style.overflow = prev;
187
+ };
188
+ }, []);
189
+
190
+ // Re-fit on rotation / resize only while the view is untouched, so a resize
191
+ // never yanks content the user has deliberately panned into place.
192
+ useEffect(() => {
193
+ const onResize = () => {
194
+ if (!touched.current) fit();
195
+ };
196
+ window.addEventListener("resize", onResize);
197
+ return () => window.removeEventListener("resize", onResize);
198
+ }, [fit]);
199
+
200
+ const onPointerDown = (e: React.PointerEvent) => {
201
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
202
+ pointers.current.set(e.pointerId, stagePoint(e));
203
+ if (pointers.current.size === 2) {
204
+ const [a, b] = [...pointers.current.values()];
205
+ pinch.current = {
206
+ dist: Math.hypot(a.x - b.x, a.y - b.y),
207
+ center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
208
+ };
209
+ panFrom.current = null;
210
+ } else if (pointers.current.size === 1) {
211
+ panFrom.current = { pointer: stagePoint(e), offset: view.current.offset };
212
+ }
213
+ };
214
+
215
+ const onPointerMove = (e: React.PointerEvent) => {
216
+ if (!pointers.current.has(e.pointerId)) return;
217
+ const p = stagePoint(e);
218
+ pointers.current.set(e.pointerId, p);
219
+
220
+ if (pointers.current.size >= 2 && pinch.current) {
221
+ const [a, b] = [...pointers.current.values()];
222
+ const dist = Math.hypot(a.x - b.x, a.y - b.y);
223
+ const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
224
+ if (pinch.current.dist > 0) {
225
+ touched.current = true;
226
+ // Pan and zoom in one step: the midpoint drift moves the content, the
227
+ // distance ratio scales it about that same midpoint.
228
+ const drift = {
229
+ x: center.x - pinch.current.center.x,
230
+ y: center.y - pinch.current.center.y,
231
+ };
232
+ // Committed through apply(), not written straight to the ref: two
233
+ // fingers moving in parallel (or a pinch already clamped at min/max)
234
+ // produce no scale change, and zoomAt() returns early — the drift
235
+ // would then be stranded in the ref and nothing would pan.
236
+ apply({
237
+ scale: view.current.scale,
238
+ offset: {
239
+ x: view.current.offset.x + drift.x,
240
+ y: view.current.offset.y + drift.y,
241
+ },
242
+ });
243
+ zoomAt(dist / pinch.current.dist, center);
244
+ }
245
+ pinch.current = { dist, center };
246
+ return;
247
+ }
248
+
249
+ if (panFrom.current) {
250
+ touched.current = true;
251
+ apply({
252
+ scale: view.current.scale,
253
+ offset: {
254
+ x: panFrom.current.offset.x + (p.x - panFrom.current.pointer.x),
255
+ y: panFrom.current.offset.y + (p.y - panFrom.current.pointer.y),
256
+ },
257
+ });
258
+ }
259
+ };
260
+
261
+ const endPointer = (e: React.PointerEvent) => {
262
+ pointers.current.delete(e.pointerId);
263
+ if (pointers.current.size >= 2) {
264
+ // Still pinching, but with a DIFFERENT pair (a stray third touch got
265
+ // lifted). Rebase the baseline, or the next move would compare the new
266
+ // pair's spread against the old pair's and jump.
267
+ const [a, b] = [...pointers.current.values()];
268
+ pinch.current = {
269
+ dist: Math.hypot(a.x - b.x, a.y - b.y),
270
+ center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
271
+ };
272
+ return;
273
+ }
274
+ pinch.current = null;
275
+ if (pointers.current.size === 1) {
276
+ // Lifting one finger of a pinch must not teleport the content: re-anchor
277
+ // the pan to whichever pointer is still down.
278
+ const [remaining] = [...pointers.current.values()];
279
+ panFrom.current = { pointer: remaining, offset: view.current.offset };
280
+ } else if (pointers.current.size === 0) {
281
+ panFrom.current = null;
282
+ }
283
+ };
284
+
285
+ const onDoubleClick = (e: React.MouseEvent) => {
286
+ touched.current = true;
287
+ // Compared against the FIT scale, not against 1: content that fits at 125%
288
+ // would otherwise read as "already zoomed" and double-click would re-fit it
289
+ // to exactly where it already was.
290
+ if (view.current.scale > fitScale.current * 1.05) fit();
291
+ else zoomAt(2, stagePoint(e));
292
+ };
293
+
294
+ const btn =
295
+ "flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground";
296
+
297
+ // Portalled to <body>: message bubbles animate through framer-motion, and a
298
+ // transformed ancestor becomes the containing block for `fixed`, which would
299
+ // pin this overlay to the bubble instead of the viewport.
300
+ return createPortal(
301
+ <div
302
+ role="dialog"
303
+ aria-modal="true"
304
+ aria-label={label}
305
+ className="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm"
306
+ >
307
+ <div
308
+ className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2"
309
+ style={{ paddingTop: "calc(0.5rem + env(safe-area-inset-top))" }}
310
+ >
311
+ <span className="px-1 font-[family-name:var(--font-mono)] text-xs tabular-nums text-muted-foreground">
312
+ {Math.round(scale * 100)}%
313
+ </span>
314
+ <div className="flex items-center gap-1">
315
+ <button type="button" title="Zoom out" onClick={() => zoomCenter(0.8)} className={btn}>
316
+ <Minus className="h-4 w-4" />
317
+ </button>
318
+ <button type="button" title="Zoom in" onClick={() => zoomCenter(1.25)} className={btn}>
319
+ <Plus className="h-4 w-4" />
320
+ </button>
321
+ <button type="button" title="Fit to screen" onClick={fit} className={btn}>
322
+ <Maximize2 className="h-4 w-4" />
323
+ </button>
324
+ {actions}
325
+ <button type="button" title="Close" onClick={onClose} className={btn}>
326
+ <X className="h-4 w-4" />
327
+ </button>
328
+ </div>
329
+ </div>
330
+
331
+ <div
332
+ ref={stageRef}
333
+ className="relative flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing [&_img]:max-w-none [&_svg]:max-w-none"
334
+ onPointerDown={onPointerDown}
335
+ onPointerMove={onPointerMove}
336
+ onPointerUp={endPointer}
337
+ onPointerCancel={endPointer}
338
+ onDoubleClick={onDoubleClick}
339
+ >
340
+ <div
341
+ ref={contentRef}
342
+ className="absolute left-0 top-0 origin-top-left"
343
+ style={{
344
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
345
+ visibility: ready ? "visible" : "hidden",
346
+ }}
347
+ >
348
+ {children}
349
+ </div>
350
+ </div>
351
+ </div>,
352
+ document.body
353
+ );
354
+ }
@@ -0,0 +1,190 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import {
3
+ SHARE_STASH_TTL_MS,
4
+ type ShareIntakeResult,
5
+ } from "@schlessera/brain-ui-sdk/protocol";
6
+ import {
7
+ getShareStore,
8
+ pruneStoredShares,
9
+ readShareLaunchParams,
10
+ type StoredShare,
11
+ } from "@schlessera/brain-ui-sdk/share-target";
12
+ import { useChatStore } from "../stores/chat-store.js";
13
+ import { useConnectionStore } from "../stores/connection-store.js";
14
+ import { useShareStore } from "../stores/share-store.js";
15
+ import { detectClientEnvironment } from "../lib/client-environment.js";
16
+ import {
17
+ buildSharePrompt,
18
+ describeShareError,
19
+ describeUploadError,
20
+ dropShareClaim,
21
+ persistShareClaim,
22
+ readShareClaims,
23
+ shareImagesToAttachments,
24
+ uploadShare,
25
+ } from "../lib/share-intake.js";
26
+ import { sendClientMessage } from "./use-websocket.js";
27
+
28
+ /**
29
+ * Pick up shares the service worker stashed, and file them once confirmed.
30
+ *
31
+ * Deliberately thin: every decision lives in `lib/share-intake.ts`, which is
32
+ * testable without a DOM. What is left here is the browser-only part — reading
33
+ * the launch URL, rewriting it, and driving the stores.
34
+ */
35
+
36
+ /**
37
+ * Move `?share=<id>` out of the URL and into a durable claim.
38
+ *
39
+ * The parameter cannot stay: the shell reloads the page when a new service
40
+ * worker takes over, a reload preserves the query string, and a second pass
41
+ * over the same id while the first upload is in flight would file the share
42
+ * twice. (The claim is belt; `store.take()` is braces — it hands the record to
43
+ * exactly one caller.)
44
+ */
45
+ function claimFromLocation(): { claimed: string[]; error?: string } {
46
+ if (typeof window === "undefined") return { claimed: [] };
47
+ const { shareId, error } = readShareLaunchParams(window.location.href);
48
+ if (shareId) {
49
+ persistShareClaim(shareId);
50
+ const url = new URL(window.location.href);
51
+ url.searchParams.delete("share");
52
+ url.searchParams.delete("share_error");
53
+ history.replaceState(null, "", url.toString());
54
+ } else if (error) {
55
+ const url = new URL(window.location.href);
56
+ url.searchParams.delete("share_error");
57
+ history.replaceState(null, "", url.toString());
58
+ }
59
+ return {
60
+ claimed: readShareClaims(),
61
+ ...(error ? { error: describeShareError(error) } : {}),
62
+ };
63
+ }
64
+
65
+ export function useShareIntake(): {
66
+ confirm: (record: StoredShare) => Promise<void>;
67
+ dismiss: (record: StoredShare) => void;
68
+ } {
69
+ const enqueue = useShareStore((s) => s.enqueue);
70
+ const remove = useShareStore((s) => s.remove);
71
+ const setBusy = useShareStore((s) => s.setBusy);
72
+ const setError = useShareStore((s) => s.setError);
73
+ const setNotes = useShareStore((s) => s.setNotes);
74
+
75
+ useEffect(() => {
76
+ let disposed = false;
77
+ const { claimed, error } = claimFromLocation();
78
+ if (error) setError(error);
79
+
80
+ void (async () => {
81
+ const store = getShareStore();
82
+ try {
83
+ for (const id of claimed) {
84
+ const record = await store.take(id);
85
+ dropShareClaim(id);
86
+ if (record && !disposed) enqueue(record);
87
+ }
88
+
89
+ // Orphans: a share whose landing page never ran (the app opened
90
+ // offline on the fallback page, or the tab was closed before the
91
+ // parameter was read) leaves a record nobody holds the id for. The
92
+ // stash is the only place it still exists.
93
+ for (const record of await store.list()) {
94
+ const taken = await store.take(record.id);
95
+ if (taken && !disposed) enqueue(taken);
96
+ }
97
+
98
+ // The service worker's own prune runs after a stash, so it can never
99
+ // reach the newest record. This pass can.
100
+ await pruneStoredShares(store, SHARE_STASH_TTL_MS);
101
+ } catch (err) {
102
+ if (!disposed) {
103
+ setError(err instanceof Error ? err.message : "Could not read stashed shares");
104
+ }
105
+ }
106
+ })();
107
+
108
+ return () => {
109
+ disposed = true;
110
+ };
111
+ }, [enqueue, setError]);
112
+
113
+ const confirm = useCallback(
114
+ async (record: StoredShare) => {
115
+ const share = useShareStore.getState();
116
+ if (share.busy) return;
117
+ if (useConnectionStore.getState().wsStatus !== "connected") {
118
+ setError("Not connected yet — this will work as soon as the app reconnects.");
119
+ return;
120
+ }
121
+
122
+ setBusy(true);
123
+ setError(null);
124
+ setNotes([]);
125
+ try {
126
+ const outcome = await uploadShare(record);
127
+ if (!outcome.ok) {
128
+ setError(describeUploadError(outcome));
129
+ return;
130
+ }
131
+
132
+ const { attachments, errors } = await shareImagesToAttachments(record.files);
133
+ if (errors.length) setNotes(errors);
134
+
135
+ const prompt = buildSharePrompt(outcome.result as ShareIntakeResult);
136
+ const chat = useChatStore.getState();
137
+ // Always start from a fresh draft: `activeSessionId` is restored from
138
+ // localStorage at store creation, so on a cold boot it already points
139
+ // at whatever session was last open.
140
+ chat.clearMessages();
141
+ // Ownership of the preview URLs transfers to the message here — the
142
+ // chat store revokes them. Nothing below may revoke them again.
143
+ chat.addUserMessage(
144
+ null,
145
+ prompt,
146
+ "typed",
147
+ attachments.length
148
+ ? attachments.map((a) => ({
149
+ previewUrl: a.previewUrl,
150
+ mediaType: a.attachment.mediaType,
151
+ }))
152
+ : undefined
153
+ );
154
+ chat.startAssistantMessage(null);
155
+
156
+ const sent = sendClientMessage({
157
+ type: "chat_message",
158
+ text: prompt,
159
+ ...(attachments.length
160
+ ? { attachments: attachments.map((a) => a.attachment) }
161
+ : {}),
162
+ ...(detectClientEnvironment() ? { client: detectClientEnvironment() } : {}),
163
+ });
164
+ if (!sent) {
165
+ setError("The connection dropped before that could be sent.");
166
+ return;
167
+ }
168
+
169
+ remove(record.id);
170
+ dropShareClaim(record.id);
171
+ } finally {
172
+ setBusy(false);
173
+ }
174
+ },
175
+ [remove, setBusy, setError, setNotes]
176
+ );
177
+
178
+ const dismiss = useCallback(
179
+ (record: StoredShare) => {
180
+ remove(record.id);
181
+ dropShareClaim(record.id);
182
+ setError(null);
183
+ // Already taken out of the stash when it was claimed; dropping the queue
184
+ // entry is what discards it.
185
+ },
186
+ [remove, setError]
187
+ );
188
+
189
+ return { confirm, dismiss };
190
+ }
@@ -106,7 +106,11 @@ export function handleServerMessage(msg: ServerMessage) {
106
106
  // session's pre-binding frames) apply to the buffer in view.
107
107
  const frameSessionId = (msg as { sessionId?: string }).sessionId;
108
108
  if (frameSessionId) {
109
- state.setRunState(frameSessionId, runStateForFrame(msg));
109
+ // `detail` on a queued status is the host's queue-pressure note; it rides
110
+ // along so the session's badge can show WHY it is queued deep.
111
+ const queueNote =
112
+ msg.type === "status" && msg.status === "queued" ? msg.detail : undefined;
113
+ state.setRunState(frameSessionId, runStateForFrame(msg), queueNote);
110
114
  }
111
115
 
112
116
  // Resolve the target buffer key for this frame.
@@ -380,6 +384,25 @@ function handleStatusChange(status: "connecting" | "connected" | "disconnected")
380
384
  // Singleton client - survives React re-renders
381
385
  let wsClient: WSClient | null = null;
382
386
 
387
+ /**
388
+ * Send on the live socket from outside a component.
389
+ *
390
+ * `useWebSocket()` OWNS the socket — its construction guard is a per-instance
391
+ * ref, so a second caller would build a second client, overwrite this
392
+ * singleton, and orphan the first; unmounting either one would then close the
393
+ * socket for both. Anything that needs to send but not to own (the share
394
+ * intake) goes through here instead of calling the hook again.
395
+ *
396
+ * Returns false when there is no open socket, since `WSClient.send` drops
397
+ * silently in that case and a caller that just staged an upload needs to know.
398
+ */
399
+ export function sendClientMessage(msg: ClientMessage): boolean {
400
+ if (!wsClient) return false;
401
+ if (useConnectionStore.getState().wsStatus !== "connected") return false;
402
+ wsClient.send(msg);
403
+ return true;
404
+ }
405
+
383
406
  export function useWebSocket() {
384
407
  const initialized = useRef(false);
385
408
 
package/src/index.ts CHANGED
@@ -46,9 +46,29 @@ export { useConnectionStore } from "./stores/connection-store.js";
46
46
  export { useProviderStore } from "./stores/provider-store.js";
47
47
  export { useVoiceStore } from "./voice/voice-store.js";
48
48
 
49
+ // A stand-in for the system share sheet, so the share-target path can be
50
+ // exercised without reinstalling the PWA. Meant for a dev route — it lists and
51
+ // deletes stashed shares — so the shell should gate it behind a DEV check
52
+ // rather than linking it from the app.
53
+ export { ShareHarness } from "./components/dev/share-harness.js";
54
+
49
55
  // Connectivity probes.
50
56
  export { useVpnStatus } from "./hooks/use-vpn-status.js";
51
- export { useWebSocket, handleServerMessage, runStateForFrame } from "./hooks/use-websocket.js";
57
+ export {
58
+ useWebSocket,
59
+ sendClientMessage,
60
+ handleServerMessage,
61
+ runStateForFrame,
62
+ } from "./hooks/use-websocket.js";
63
+
64
+ // Share intake. The shell needs `hasPendingShare` for its service-worker
65
+ // reload guard: reloading mid-intake would file a share twice or lose it.
66
+ export {
67
+ useShareStore,
68
+ hasPendingShare,
69
+ type ShareIntakeState,
70
+ } from "./stores/share-store.js";
71
+ export { ShareIntake } from "./components/chat/share-card.js";
52
72
 
53
73
  // API surface (typed REST client + backend URL helpers).
54
74
  export { api } from "./lib/api-client.js";