@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.
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +12 -5
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +2 -1
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/mermaid-viewer.d.ts +11 -1
- package/dist/components/chat/mermaid-viewer.d.ts.map +1 -1
- package/dist/components/chat/mermaid-viewer.js +8 -250
- package/dist/components/chat/mermaid-viewer.js.map +1 -1
- package/dist/components/chat/message-bubble.d.ts.map +1 -1
- package/dist/components/chat/message-bubble.js +2 -1
- package/dist/components/chat/message-bubble.js.map +1 -1
- package/dist/components/chat/session-drawer.d.ts.map +1 -1
- package/dist/components/chat/session-drawer.js +11 -3
- package/dist/components/chat/session-drawer.js.map +1 -1
- package/dist/components/chat/share-card.d.ts +9 -0
- package/dist/components/chat/share-card.d.ts.map +1 -0
- package/dist/components/chat/share-card.js +58 -0
- package/dist/components/chat/share-card.js.map +1 -0
- package/dist/components/dev/share-harness.d.ts +4 -0
- package/dist/components/dev/share-harness.d.ts.map +1 -0
- package/dist/components/dev/share-harness.js +104 -0
- package/dist/components/dev/share-harness.js.map +1 -0
- package/dist/components/files/file-viewer-binary.d.ts.map +1 -1
- package/dist/components/files/file-viewer-binary.js +2 -1
- package/dist/components/files/file-viewer-binary.js.map +1 -1
- package/dist/components/files/file-viewer.d.ts.map +1 -1
- package/dist/components/files/file-viewer.js +11 -2
- package/dist/components/files/file-viewer.js.map +1 -1
- package/dist/components/images/image-share.d.ts +18 -0
- package/dist/components/images/image-share.d.ts.map +1 -0
- package/dist/components/images/image-share.js +47 -0
- package/dist/components/images/image-share.js.map +1 -0
- package/dist/components/images/image-viewer.d.ts +17 -0
- package/dist/components/images/image-viewer.d.ts.map +1 -0
- package/dist/components/images/image-viewer.js +20 -0
- package/dist/components/images/image-viewer.js.map +1 -0
- package/dist/components/images/zoomable-image.d.ts +35 -0
- package/dist/components/images/zoomable-image.d.ts.map +1 -0
- package/dist/components/images/zoomable-image.js +40 -0
- package/dist/components/images/zoomable-image.js.map +1 -0
- package/dist/components/viewer/zoom-viewer.d.ts +26 -0
- package/dist/components/viewer/zoom-viewer.d.ts.map +1 -0
- package/dist/components/viewer/zoom-viewer.js +254 -0
- package/dist/components/viewer/zoom-viewer.js.map +1 -0
- package/dist/hooks/use-share-intake.d.ts +6 -0
- package/dist/hooks/use-share-intake.d.ts.map +1 -0
- package/dist/hooks/use-share-intake.js +153 -0
- package/dist/hooks/use-share-intake.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +13 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +24 -1
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/image-optimize.d.ts +62 -0
- package/dist/lib/image-optimize.d.ts.map +1 -0
- package/dist/lib/image-optimize.js +152 -0
- package/dist/lib/image-optimize.js.map +1 -0
- package/dist/lib/image-source.d.ts +13 -0
- package/dist/lib/image-source.d.ts.map +1 -0
- package/dist/lib/image-source.js +57 -0
- package/dist/lib/image-source.js.map +1 -0
- package/dist/lib/share-intake.d.ts +49 -0
- package/dist/lib/share-intake.d.ts.map +1 -0
- package/dist/lib/share-intake.js +203 -0
- package/dist/lib/share-intake.js.map +1 -0
- package/dist/stores/chat-store.d.ts +7 -1
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +13 -3
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/stores/share-store.d.ts +29 -0
- package/dist/stores/share-store.d.ts.map +1 -0
- package/dist/stores/share-store.js +19 -0
- package/dist/stores/share-store.js.map +1 -0
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/brain-markdown.tsx +15 -9
- package/src/components/chat/chat-page.tsx +6 -0
- package/src/components/chat/mermaid-viewer.tsx +15 -310
- package/src/components/chat/message-bubble.tsx +6 -2
- package/src/components/chat/session-drawer.tsx +19 -3
- package/src/components/chat/share-card.tsx +185 -0
- package/src/components/dev/share-harness.tsx +243 -0
- package/src/components/files/file-viewer-binary.tsx +10 -1
- package/src/components/files/file-viewer.tsx +11 -2
- package/src/components/images/image-share.ts +57 -0
- package/src/components/images/image-viewer.tsx +50 -0
- package/src/components/images/zoomable-image.tsx +93 -0
- package/src/components/viewer/zoom-viewer.tsx +354 -0
- package/src/hooks/use-share-intake.ts +190 -0
- package/src/hooks/use-websocket.ts +24 -1
- package/src/index.ts +21 -1
- package/src/lib/image-optimize.ts +195 -0
- package/src/lib/image-source.ts +53 -0
- package/src/lib/share-intake.ts +231 -0
- package/src/stores/chat-store.ts +25 -4
- package/src/stores/share-store.ts +54 -0
|
@@ -1,40 +1,19 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
+
}
|