@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,35 @@
1
+ /**
2
+ * An image that opens into the full-screen zoom viewer when tapped — the same
3
+ * treatment a mermaid diagram gets, and for the same reason: the inline preview
4
+ * is only as wide as the viewport, which on a phone means a generated image is
5
+ * visible but not legible.
6
+ *
7
+ * Click-to-open rather than pinch-in-place: both the chat transcript and the
8
+ * file panel scroll, and a zoom surface embedded in a scroller has to steal the
9
+ * gestures that scrolling needs. Opening a dedicated stage keeps both intact.
10
+ *
11
+ * Sharing is deliberately NOT offered here. The hover affordance sits inside
12
+ * markdown prose and inside a file panel, both of which clip overflow, and a
13
+ * share menu that drops out of a clipping ancestor loses its lower entries. The
14
+ * viewer this opens carries the share menu, and the file viewer's own header
15
+ * has one too.
16
+ *
17
+ * The wrapper is a `span`, not a `div`: markdown puts images inside paragraphs,
18
+ * and a block element there is invalid nesting.
19
+ */
20
+ export declare function ZoomableImage({ src, alt, className, mime, bytes, toolbar, imgProps, }: {
21
+ src: string;
22
+ alt?: string;
23
+ /** Classes for the inline `<img>` itself. */
24
+ className?: string;
25
+ mime?: string;
26
+ /** Byte size when known (the file viewer knows it; the chat does not). */
27
+ bytes?: number;
28
+ /**
29
+ * Hover expand button. Off for thumbnails, where a 28px button would cover
30
+ * the picture it is meant to act on — tapping the image still opens it.
31
+ */
32
+ toolbar?: boolean;
33
+ imgProps?: Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt" | "className">;
34
+ }): import("react").JSX.Element;
35
+ //# sourceMappingURL=zoomable-image.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zoomable-image.d.ts","sourceRoot":"","sources":["../../../src/components/images/zoomable-image.tsx"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,EAC5B,GAAG,EACH,GAAG,EACH,SAAS,EACT,IAAI,EACJ,KAAK,EACL,OAAc,EACd,QAAQ,GACT,EAAE;IACD,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,wBAAwB,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,WAAW,CAAC,CAAC;CACrF,+BA8CA"}
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Expand } from "lucide-react";
4
+ import { imageFilenameFromSrc } from "../../lib/image-source.js";
5
+ import { ImageViewer } from "./image-viewer.js";
6
+ /**
7
+ * An image that opens into the full-screen zoom viewer when tapped — the same
8
+ * treatment a mermaid diagram gets, and for the same reason: the inline preview
9
+ * is only as wide as the viewport, which on a phone means a generated image is
10
+ * visible but not legible.
11
+ *
12
+ * Click-to-open rather than pinch-in-place: both the chat transcript and the
13
+ * file panel scroll, and a zoom surface embedded in a scroller has to steal the
14
+ * gestures that scrolling needs. Opening a dedicated stage keeps both intact.
15
+ *
16
+ * Sharing is deliberately NOT offered here. The hover affordance sits inside
17
+ * markdown prose and inside a file panel, both of which clip overflow, and a
18
+ * share menu that drops out of a clipping ancestor loses its lower entries. The
19
+ * viewer this opens carries the share menu, and the file viewer's own header
20
+ * has one too.
21
+ *
22
+ * The wrapper is a `span`, not a `div`: markdown puts images inside paragraphs,
23
+ * and a block element there is invalid nesting.
24
+ */
25
+ export function ZoomableImage({ src, alt, className, mime, bytes, toolbar = true, imgProps, }) {
26
+ const [zoomed, setZoomed] = useState(false);
27
+ const filename = imageFilenameFromSrc(src, mime);
28
+ return (_jsxs("span", { className: "group relative inline-block max-w-full align-top", children: [_jsx("img", { ...imgProps, src: src, alt: alt ?? "",
29
+ // The whole image is the affordance — the hover button is pointer-only,
30
+ // and this has to work on a touch screen. Focusable and Enter/Space
31
+ // activated because for a thumbnail (toolbar off) this is the ONLY way
32
+ // in, so it cannot be pointer-only either.
33
+ role: "button", tabIndex: 0, onClick: () => setZoomed(true), onKeyDown: (e) => {
34
+ if (e.key !== "Enter" && e.key !== " ")
35
+ return;
36
+ e.preventDefault();
37
+ setZoomed(true);
38
+ }, className: className }), toolbar && (_jsx("button", { type: "button", title: "Open image", onClick: () => setZoomed(true), className: "absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-md bg-surface-raised/80 text-muted-foreground opacity-0 transition-all hover:bg-surface-overlay hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100", children: _jsx(Expand, { className: "h-3.5 w-3.5" }) })), zoomed && (_jsx(ImageViewer, { src: src, alt: alt, filename: filename, mime: mime, bytes: bytes, onClose: () => setZoomed(false) }))] }));
39
+ }
40
+ //# sourceMappingURL=zoomable-image.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zoomable-image.js","sourceRoot":"","sources":["../../../src/components/images/zoomable-image.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACjC,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,aAAa,CAAC,EAC5B,GAAG,EACH,GAAG,EACH,SAAS,EACT,IAAI,EACJ,KAAK,EACL,OAAO,GAAG,IAAI,EACd,QAAQ,GAeT;IACC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC5C,MAAM,QAAQ,GAAG,oBAAoB,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAEjD,OAAO,CACL,gBAAM,SAAS,EAAC,kDAAkD,aAChE,iBACM,QAAQ,EACZ,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,GAAG,IAAI,EAAE;gBACd,wEAAwE;gBACxE,oEAAoE;gBACpE,uEAAuE;gBACvE,2CAA2C;gBAC3C,IAAI,EAAC,QAAQ,EACb,QAAQ,EAAE,CAAC,EACX,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,EAC9B,SAAS,EAAE,CAAC,CAAC,EAAE,EAAE;oBACf,IAAI,CAAC,CAAC,GAAG,KAAK,OAAO,IAAI,CAAC,CAAC,GAAG,KAAK,GAAG;wBAAE,OAAO;oBAC/C,CAAC,CAAC,cAAc,EAAE,CAAC;oBACnB,SAAS,CAAC,IAAI,CAAC,CAAC;gBAClB,CAAC,EACD,SAAS,EAAE,SAAS,GACpB,EACD,OAAO,IAAI,CACV,iBACE,IAAI,EAAC,QAAQ,EACb,KAAK,EAAC,YAAY,EAClB,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,EAC9B,SAAS,EAAC,iPAAiP,YAE3P,KAAC,MAAM,IAAC,SAAS,EAAC,aAAa,GAAG,GAC3B,CACV,EACA,MAAM,IAAI,CACT,KAAC,WAAW,IACV,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,GAC/B,CACH,IACI,CACR,CAAC;AACJ,CAAC"}
@@ -0,0 +1,26 @@
1
+ export interface ZoomViewerProps {
2
+ children: React.ReactNode;
3
+ onClose: () => void;
4
+ /**
5
+ * Extra toolbar nodes, placed left of the close button — typically a share
6
+ * menu. Rendered inside the header, above the stage.
7
+ */
8
+ actions?: React.ReactNode;
9
+ /**
10
+ * Changes when the content's natural size may have changed (a streaming
11
+ * diagram growing, an image finishing its decode). Re-fits, but only while
12
+ * the user has not panned or zoomed — after that nothing moves behind them.
13
+ */
14
+ refitKey?: unknown;
15
+ /**
16
+ * Ceiling on the scale `fit()` may choose. Small diagrams may grow to fill
17
+ * the stage (blowing one up 8x reads as broken, hence a cap), but a raster
18
+ * image has real pixels: opening it above 100% only shows interpolation, so
19
+ * image callers pass 1.
20
+ */
21
+ maxFitScale?: number;
22
+ /** Aria label for the overlay. */
23
+ label?: string;
24
+ }
25
+ export declare function ZoomViewer({ children, onClose, actions, refitKey, maxFitScale, label, }: ZoomViewerProps): import("react").ReactPortal;
26
+ //# sourceMappingURL=zoom-viewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zoom-viewer.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/zoom-viewer.tsx"],"names":[],"mappings":"AA6BA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB;;;OAGG;IACH,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,OAAO,EACP,OAAO,EACP,QAAQ,EACR,WAAiB,EACjB,KAAqB,GACtB,EAAE,eAAe,+BAoSjB"}
@@ -0,0 +1,254 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { Minus, Plus, Maximize2, X } from "lucide-react";
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
+ const MIN_SCALE = 0.15;
18
+ const MAX_SCALE = 12;
19
+ function clampScale(s) {
20
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
21
+ }
22
+ export function ZoomViewer({ children, onClose, actions, refitKey, maxFitScale = 2.5, label = "Zoomed view", }) {
23
+ const stageRef = useRef(null);
24
+ const contentRef = useRef(null);
25
+ const [scale, setScale] = useState(1);
26
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
27
+ const [ready, setReady] = useState(false);
28
+ // Live gesture state lives in refs: pointer maths must not wait for a render.
29
+ const pointers = useRef(new Map());
30
+ const pinch = useRef(null);
31
+ const panFrom = useRef(null);
32
+ const view = useRef({ scale: 1, offset: { x: 0, y: 0 } });
33
+ /** The scale `fit()` last produced — the baseline double-click toggles against. */
34
+ const fitScale = useRef(1);
35
+ /** Set once the user pans/zooms; after that nothing may re-fit behind their back. */
36
+ const touched = useRef(false);
37
+ const apply = useCallback((next) => {
38
+ view.current = next;
39
+ setScale(next.scale);
40
+ setOffset(next.offset);
41
+ }, []);
42
+ /** Natural (untransformed) size of the content — offsetWidth ignores transforms. */
43
+ const natural = useCallback(() => {
44
+ const el = contentRef.current;
45
+ if (!el || !el.offsetWidth || !el.offsetHeight)
46
+ return null;
47
+ return { w: el.offsetWidth, h: el.offsetHeight };
48
+ }, []);
49
+ const fit = useCallback(() => {
50
+ const stage = stageRef.current;
51
+ const size = natural();
52
+ if (!stage || !size)
53
+ return;
54
+ const pad = 32;
55
+ const sx = (stage.clientWidth - pad * 2) / size.w;
56
+ const sy = (stage.clientHeight - pad * 2) / size.h;
57
+ const next = clampScale(Math.min(maxFitScale, Math.min(sx, sy)));
58
+ fitScale.current = next;
59
+ apply({
60
+ scale: next,
61
+ offset: {
62
+ x: (stage.clientWidth - size.w * next) / 2,
63
+ y: (stage.clientHeight - size.h * next) / 2,
64
+ },
65
+ });
66
+ }, [apply, natural, maxFitScale]);
67
+ useLayoutEffect(() => {
68
+ // Re-runs when the content itself changes, which happens while a message is
69
+ // still streaming — so a user who has already panned keeps their view.
70
+ if (!touched.current)
71
+ fit();
72
+ setReady(true);
73
+ }, [fit, refitKey]);
74
+ /** Zoom about a stage-local anchor, so the point under the cursor/fingers stays put. */
75
+ const zoomAt = useCallback((factor, anchor) => {
76
+ // Any deliberate zoom counts as touching the view — including the
77
+ // toolbar buttons and the wheel. Without this, content that is still
78
+ // streaming would re-fit itself out from under the reader's zoom.
79
+ touched.current = true;
80
+ const { scale: prev, offset: prevOffset } = view.current;
81
+ const next = clampScale(prev * factor);
82
+ if (next === prev)
83
+ return;
84
+ const ratio = next / prev;
85
+ apply({
86
+ scale: next,
87
+ offset: {
88
+ x: anchor.x - (anchor.x - prevOffset.x) * ratio,
89
+ y: anchor.y - (anchor.y - prevOffset.y) * ratio,
90
+ },
91
+ });
92
+ }, [apply]);
93
+ const zoomCenter = useCallback((factor) => {
94
+ const stage = stageRef.current;
95
+ if (!stage)
96
+ return;
97
+ zoomAt(factor, { x: stage.clientWidth / 2, y: stage.clientHeight / 2 });
98
+ }, [zoomAt]);
99
+ const stagePoint = (e) => {
100
+ const rect = stageRef.current?.getBoundingClientRect();
101
+ return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
102
+ };
103
+ // Wheel must be bound manually: React's onWheel is passive, so it cannot
104
+ // preventDefault, and the page behind would scroll (or the browser would
105
+ // pinch-zoom the whole PWA on a ctrl+wheel trackpad gesture).
106
+ useEffect(() => {
107
+ const stage = stageRef.current;
108
+ if (!stage)
109
+ return;
110
+ const onWheel = (e) => {
111
+ e.preventDefault();
112
+ // A trackpad pinch arrives as ctrlKey+wheel with small deltas; a mouse
113
+ // wheel arrives as coarse notches. Both map to the same exponential.
114
+ const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
115
+ zoomAt(factor, stagePoint(e));
116
+ };
117
+ stage.addEventListener("wheel", onWheel, { passive: false });
118
+ return () => stage.removeEventListener("wheel", onWheel);
119
+ }, [zoomAt]);
120
+ useEffect(() => {
121
+ const onKey = (e) => {
122
+ if (e.key === "Escape")
123
+ onClose();
124
+ else if (e.key === "+" || e.key === "=")
125
+ zoomCenter(1.25);
126
+ else if (e.key === "-" || e.key === "_")
127
+ zoomCenter(0.8);
128
+ else if (e.key === "0")
129
+ fit();
130
+ };
131
+ document.addEventListener("keydown", onKey);
132
+ return () => document.removeEventListener("keydown", onKey);
133
+ }, [onClose, zoomCenter, fit]);
134
+ // Lock body scroll while open.
135
+ useEffect(() => {
136
+ const prev = document.body.style.overflow;
137
+ document.body.style.overflow = "hidden";
138
+ return () => {
139
+ document.body.style.overflow = prev;
140
+ };
141
+ }, []);
142
+ // Re-fit on rotation / resize only while the view is untouched, so a resize
143
+ // never yanks content the user has deliberately panned into place.
144
+ useEffect(() => {
145
+ const onResize = () => {
146
+ if (!touched.current)
147
+ fit();
148
+ };
149
+ window.addEventListener("resize", onResize);
150
+ return () => window.removeEventListener("resize", onResize);
151
+ }, [fit]);
152
+ const onPointerDown = (e) => {
153
+ e.currentTarget.setPointerCapture(e.pointerId);
154
+ pointers.current.set(e.pointerId, stagePoint(e));
155
+ if (pointers.current.size === 2) {
156
+ const [a, b] = [...pointers.current.values()];
157
+ pinch.current = {
158
+ dist: Math.hypot(a.x - b.x, a.y - b.y),
159
+ center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
160
+ };
161
+ panFrom.current = null;
162
+ }
163
+ else if (pointers.current.size === 1) {
164
+ panFrom.current = { pointer: stagePoint(e), offset: view.current.offset };
165
+ }
166
+ };
167
+ const onPointerMove = (e) => {
168
+ if (!pointers.current.has(e.pointerId))
169
+ return;
170
+ const p = stagePoint(e);
171
+ pointers.current.set(e.pointerId, p);
172
+ if (pointers.current.size >= 2 && pinch.current) {
173
+ const [a, b] = [...pointers.current.values()];
174
+ const dist = Math.hypot(a.x - b.x, a.y - b.y);
175
+ const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
176
+ if (pinch.current.dist > 0) {
177
+ touched.current = true;
178
+ // Pan and zoom in one step: the midpoint drift moves the content, the
179
+ // distance ratio scales it about that same midpoint.
180
+ const drift = {
181
+ x: center.x - pinch.current.center.x,
182
+ y: center.y - pinch.current.center.y,
183
+ };
184
+ // Committed through apply(), not written straight to the ref: two
185
+ // fingers moving in parallel (or a pinch already clamped at min/max)
186
+ // produce no scale change, and zoomAt() returns early — the drift
187
+ // would then be stranded in the ref and nothing would pan.
188
+ apply({
189
+ scale: view.current.scale,
190
+ offset: {
191
+ x: view.current.offset.x + drift.x,
192
+ y: view.current.offset.y + drift.y,
193
+ },
194
+ });
195
+ zoomAt(dist / pinch.current.dist, center);
196
+ }
197
+ pinch.current = { dist, center };
198
+ return;
199
+ }
200
+ if (panFrom.current) {
201
+ touched.current = true;
202
+ apply({
203
+ scale: view.current.scale,
204
+ offset: {
205
+ x: panFrom.current.offset.x + (p.x - panFrom.current.pointer.x),
206
+ y: panFrom.current.offset.y + (p.y - panFrom.current.pointer.y),
207
+ },
208
+ });
209
+ }
210
+ };
211
+ const endPointer = (e) => {
212
+ pointers.current.delete(e.pointerId);
213
+ if (pointers.current.size >= 2) {
214
+ // Still pinching, but with a DIFFERENT pair (a stray third touch got
215
+ // lifted). Rebase the baseline, or the next move would compare the new
216
+ // pair's spread against the old pair's and jump.
217
+ const [a, b] = [...pointers.current.values()];
218
+ pinch.current = {
219
+ dist: Math.hypot(a.x - b.x, a.y - b.y),
220
+ center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
221
+ };
222
+ return;
223
+ }
224
+ pinch.current = null;
225
+ if (pointers.current.size === 1) {
226
+ // Lifting one finger of a pinch must not teleport the content: re-anchor
227
+ // the pan to whichever pointer is still down.
228
+ const [remaining] = [...pointers.current.values()];
229
+ panFrom.current = { pointer: remaining, offset: view.current.offset };
230
+ }
231
+ else if (pointers.current.size === 0) {
232
+ panFrom.current = null;
233
+ }
234
+ };
235
+ const onDoubleClick = (e) => {
236
+ touched.current = true;
237
+ // Compared against the FIT scale, not against 1: content that fits at 125%
238
+ // would otherwise read as "already zoomed" and double-click would re-fit it
239
+ // to exactly where it already was.
240
+ if (view.current.scale > fitScale.current * 1.05)
241
+ fit();
242
+ else
243
+ zoomAt(2, stagePoint(e));
244
+ };
245
+ const btn = "flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground";
246
+ // Portalled to <body>: message bubbles animate through framer-motion, and a
247
+ // transformed ancestor becomes the containing block for `fixed`, which would
248
+ // pin this overlay to the bubble instead of the viewport.
249
+ return createPortal(_jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": label, className: "fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2", style: { paddingTop: "calc(0.5rem + env(safe-area-inset-top))" }, children: [_jsxs("span", { className: "px-1 font-[family-name:var(--font-mono)] text-xs tabular-nums text-muted-foreground", children: [Math.round(scale * 100), "%"] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", title: "Zoom out", onClick: () => zoomCenter(0.8), className: btn, children: _jsx(Minus, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", title: "Zoom in", onClick: () => zoomCenter(1.25), className: btn, children: _jsx(Plus, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", title: "Fit to screen", onClick: fit, className: btn, children: _jsx(Maximize2, { className: "h-4 w-4" }) }), actions, _jsx("button", { type: "button", title: "Close", onClick: onClose, className: btn, children: _jsx(X, { className: "h-4 w-4" }) })] })] }), _jsx("div", { ref: stageRef, className: "relative flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing [&_img]:max-w-none [&_svg]:max-w-none", onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endPointer, onPointerCancel: endPointer, onDoubleClick: onDoubleClick, children: _jsx("div", { ref: contentRef, className: "absolute left-0 top-0 origin-top-left", style: {
250
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
251
+ visibility: ready ? "visible" : "hidden",
252
+ }, children: children }) })] }), document.body);
253
+ }
254
+ //# sourceMappingURL=zoom-viewer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zoom-viewer.js","sourceRoot":"","sources":["../../../src/components/viewer/zoom-viewer.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { type StoredShare } from "@schlessera/brain-ui-sdk/share-target";
2
+ export declare function useShareIntake(): {
3
+ confirm: (record: StoredShare) => Promise<void>;
4
+ dismiss: (record: StoredShare) => void;
5
+ };
6
+ //# sourceMappingURL=use-share-intake.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-share-intake.d.ts","sourceRoot":"","sources":["../../src/hooks/use-share-intake.ts"],"names":[],"mappings":"AAKA,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,uCAAuC,CAAC;AAsD/C,wBAAgB,cAAc,IAAI;IAChC,OAAO,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;CACxC,CA0HA"}
@@ -0,0 +1,153 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import { SHARE_STASH_TTL_MS, } from "@schlessera/brain-ui-sdk/protocol";
3
+ import { getShareStore, pruneStoredShares, readShareLaunchParams, } from "@schlessera/brain-ui-sdk/share-target";
4
+ import { useChatStore } from "../stores/chat-store.js";
5
+ import { useConnectionStore } from "../stores/connection-store.js";
6
+ import { useShareStore } from "../stores/share-store.js";
7
+ import { detectClientEnvironment } from "../lib/client-environment.js";
8
+ import { buildSharePrompt, describeShareError, describeUploadError, dropShareClaim, persistShareClaim, readShareClaims, shareImagesToAttachments, uploadShare, } from "../lib/share-intake.js";
9
+ import { sendClientMessage } from "./use-websocket.js";
10
+ /**
11
+ * Pick up shares the service worker stashed, and file them once confirmed.
12
+ *
13
+ * Deliberately thin: every decision lives in `lib/share-intake.ts`, which is
14
+ * testable without a DOM. What is left here is the browser-only part — reading
15
+ * the launch URL, rewriting it, and driving the stores.
16
+ */
17
+ /**
18
+ * Move `?share=<id>` out of the URL and into a durable claim.
19
+ *
20
+ * The parameter cannot stay: the shell reloads the page when a new service
21
+ * worker takes over, a reload preserves the query string, and a second pass
22
+ * over the same id while the first upload is in flight would file the share
23
+ * twice. (The claim is belt; `store.take()` is braces — it hands the record to
24
+ * exactly one caller.)
25
+ */
26
+ function claimFromLocation() {
27
+ if (typeof window === "undefined")
28
+ return { claimed: [] };
29
+ const { shareId, error } = readShareLaunchParams(window.location.href);
30
+ if (shareId) {
31
+ persistShareClaim(shareId);
32
+ const url = new URL(window.location.href);
33
+ url.searchParams.delete("share");
34
+ url.searchParams.delete("share_error");
35
+ history.replaceState(null, "", url.toString());
36
+ }
37
+ else if (error) {
38
+ const url = new URL(window.location.href);
39
+ url.searchParams.delete("share_error");
40
+ history.replaceState(null, "", url.toString());
41
+ }
42
+ return {
43
+ claimed: readShareClaims(),
44
+ ...(error ? { error: describeShareError(error) } : {}),
45
+ };
46
+ }
47
+ export function useShareIntake() {
48
+ const enqueue = useShareStore((s) => s.enqueue);
49
+ const remove = useShareStore((s) => s.remove);
50
+ const setBusy = useShareStore((s) => s.setBusy);
51
+ const setError = useShareStore((s) => s.setError);
52
+ const setNotes = useShareStore((s) => s.setNotes);
53
+ useEffect(() => {
54
+ let disposed = false;
55
+ const { claimed, error } = claimFromLocation();
56
+ if (error)
57
+ setError(error);
58
+ void (async () => {
59
+ const store = getShareStore();
60
+ try {
61
+ for (const id of claimed) {
62
+ const record = await store.take(id);
63
+ dropShareClaim(id);
64
+ if (record && !disposed)
65
+ enqueue(record);
66
+ }
67
+ // Orphans: a share whose landing page never ran (the app opened
68
+ // offline on the fallback page, or the tab was closed before the
69
+ // parameter was read) leaves a record nobody holds the id for. The
70
+ // stash is the only place it still exists.
71
+ for (const record of await store.list()) {
72
+ const taken = await store.take(record.id);
73
+ if (taken && !disposed)
74
+ enqueue(taken);
75
+ }
76
+ // The service worker's own prune runs after a stash, so it can never
77
+ // reach the newest record. This pass can.
78
+ await pruneStoredShares(store, SHARE_STASH_TTL_MS);
79
+ }
80
+ catch (err) {
81
+ if (!disposed) {
82
+ setError(err instanceof Error ? err.message : "Could not read stashed shares");
83
+ }
84
+ }
85
+ })();
86
+ return () => {
87
+ disposed = true;
88
+ };
89
+ }, [enqueue, setError]);
90
+ const confirm = useCallback(async (record) => {
91
+ const share = useShareStore.getState();
92
+ if (share.busy)
93
+ return;
94
+ if (useConnectionStore.getState().wsStatus !== "connected") {
95
+ setError("Not connected yet — this will work as soon as the app reconnects.");
96
+ return;
97
+ }
98
+ setBusy(true);
99
+ setError(null);
100
+ setNotes([]);
101
+ try {
102
+ const outcome = await uploadShare(record);
103
+ if (!outcome.ok) {
104
+ setError(describeUploadError(outcome));
105
+ return;
106
+ }
107
+ const { attachments, errors } = await shareImagesToAttachments(record.files);
108
+ if (errors.length)
109
+ setNotes(errors);
110
+ const prompt = buildSharePrompt(outcome.result);
111
+ const chat = useChatStore.getState();
112
+ // Always start from a fresh draft: `activeSessionId` is restored from
113
+ // localStorage at store creation, so on a cold boot it already points
114
+ // at whatever session was last open.
115
+ chat.clearMessages();
116
+ // Ownership of the preview URLs transfers to the message here — the
117
+ // chat store revokes them. Nothing below may revoke them again.
118
+ chat.addUserMessage(null, prompt, "typed", attachments.length
119
+ ? attachments.map((a) => ({
120
+ previewUrl: a.previewUrl,
121
+ mediaType: a.attachment.mediaType,
122
+ }))
123
+ : undefined);
124
+ chat.startAssistantMessage(null);
125
+ const sent = sendClientMessage({
126
+ type: "chat_message",
127
+ text: prompt,
128
+ ...(attachments.length
129
+ ? { attachments: attachments.map((a) => a.attachment) }
130
+ : {}),
131
+ ...(detectClientEnvironment() ? { client: detectClientEnvironment() } : {}),
132
+ });
133
+ if (!sent) {
134
+ setError("The connection dropped before that could be sent.");
135
+ return;
136
+ }
137
+ remove(record.id);
138
+ dropShareClaim(record.id);
139
+ }
140
+ finally {
141
+ setBusy(false);
142
+ }
143
+ }, [remove, setBusy, setError, setNotes]);
144
+ const dismiss = useCallback((record) => {
145
+ remove(record.id);
146
+ dropShareClaim(record.id);
147
+ setError(null);
148
+ // Already taken out of the stash when it was claimed; dropping the queue
149
+ // entry is what discards it.
150
+ }, [remove, setError]);
151
+ return { confirm, dismiss };
152
+ }
153
+ //# sourceMappingURL=use-share-intake.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-share-intake.js","sourceRoot":"","sources":["../../src/hooks/use-share-intake.ts"],"names":[],"mappings":"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"}
@@ -2,6 +2,19 @@ import type { ServerMessage, ClientMessage } from "@schlessera/brain-ui-sdk/prot
2
2
  /** Map a frame to the run-state its session should show in the session list. */
3
3
  export declare function runStateForFrame(msg: ServerMessage): "streaming" | "queued" | "idle";
4
4
  export declare function handleServerMessage(msg: ServerMessage): void;
5
+ /**
6
+ * Send on the live socket from outside a component.
7
+ *
8
+ * `useWebSocket()` OWNS the socket — its construction guard is a per-instance
9
+ * ref, so a second caller would build a second client, overwrite this
10
+ * singleton, and orphan the first; unmounting either one would then close the
11
+ * socket for both. Anything that needs to send but not to own (the share
12
+ * intake) goes through here instead of calling the hook again.
13
+ *
14
+ * Returns false when there is no open socket, since `WSClient.send` drops
15
+ * silently in that case and a caller that just staged an upload needs to know.
16
+ */
17
+ export declare function sendClientMessage(msg: ClientMessage): boolean;
5
18
  export declare function useWebSocket(): {
6
19
  send: (msg: ClientMessage) => void;
7
20
  };
@@ -1 +1 @@
1
- {"version":3,"file":"use-websocket.d.ts","sourceRoot":"","sources":["../../src/hooks/use-websocket.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EAId,MAAM,mCAAmC,CAAC;AA2E3C,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,aAAa,GAAG,WAAW,GAAG,QAAQ,GAAG,MAAM,CAOpF;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,QA8KrD;AA8GD,wBAAgB,YAAY;gBA2BK,aAAa;EAK7C"}
1
+ {"version":3,"file":"use-websocket.d.ts","sourceRoot":"","sources":["../../src/hooks/use-websocket.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EAId,MAAM,mCAAmC,CAAC;AA2E3C,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,aAAa,GAAG,WAAW,GAAG,QAAQ,GAAG,MAAM,CAOpF;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,QAkLrD;AA8GD;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAK7D;AAED,wBAAgB,YAAY;gBA2BK,aAAa;EAK7C"}
@@ -92,7 +92,10 @@ export function handleServerMessage(msg) {
92
92
  // session's pre-binding frames) apply to the buffer in view.
93
93
  const frameSessionId = msg.sessionId;
94
94
  if (frameSessionId) {
95
- state.setRunState(frameSessionId, runStateForFrame(msg));
95
+ // `detail` on a queued status is the host's queue-pressure note; it rides
96
+ // along so the session's badge can show WHY it is queued deep.
97
+ const queueNote = msg.type === "status" && msg.status === "queued" ? msg.detail : undefined;
98
+ state.setRunState(frameSessionId, runStateForFrame(msg), queueNote);
96
99
  }
97
100
  // Resolve the target buffer key for this frame.
98
101
  let key;
@@ -345,6 +348,26 @@ function handleStatusChange(status) {
345
348
  }
346
349
  // Singleton client - survives React re-renders
347
350
  let wsClient = null;
351
+ /**
352
+ * Send on the live socket from outside a component.
353
+ *
354
+ * `useWebSocket()` OWNS the socket — its construction guard is a per-instance
355
+ * ref, so a second caller would build a second client, overwrite this
356
+ * singleton, and orphan the first; unmounting either one would then close the
357
+ * socket for both. Anything that needs to send but not to own (the share
358
+ * intake) goes through here instead of calling the hook again.
359
+ *
360
+ * Returns false when there is no open socket, since `WSClient.send` drops
361
+ * silently in that case and a caller that just staged an upload needs to know.
362
+ */
363
+ export function sendClientMessage(msg) {
364
+ if (!wsClient)
365
+ return false;
366
+ if (useConnectionStore.getState().wsStatus !== "connected")
367
+ return false;
368
+ wsClient.send(msg);
369
+ return true;
370
+ }
348
371
  export function useWebSocket() {
349
372
  const initialized = useRef(false);
350
373
  useEffect(() => {