@crosery/dsh-viewer 0.0.0-stage → 0.3.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.
package/lib/client.js ADDED
@@ -0,0 +1,1136 @@
1
+ window.__ModuleLoader__.load({ id: "@crosery/dsh-viewer", factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+ "use strict";
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name2 in all)
10
+ __defProp(target, name2, { get: all[name2], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
21
+
22
+ // src/client/index.ts
23
+ var index_exports = {};
24
+ __export(index_exports, {
25
+ READ_IMAGE_PRIORITY: () => READ_IMAGE_PRIORITY,
26
+ TURN_TAIL_SLOT: () => TURN_TAIL_SLOT,
27
+ VIEWER_INPUT: () => VIEWER_INPUT,
28
+ VIEWER_NESTED: () => VIEWER_NESTED,
29
+ VIEWER_NS: () => VIEWER_NS,
30
+ VIEWER_TURN_DATA: () => VIEWER_TURN_DATA,
31
+ VIEWER_TURN_END: () => VIEWER_TURN_END,
32
+ apply: () => apply,
33
+ argumentPathOf: () => argumentPathOf,
34
+ cardModel: () => cardModel,
35
+ contentImageOf: () => contentImageOf,
36
+ contribute: () => contribute,
37
+ displayedValueOf: () => displayedValueOf,
38
+ foldSourceOf: () => foldSourceOf,
39
+ foldsCompletedTurns: () => foldsCompletedTurns,
40
+ hasInterleavedInput: () => hasInterleavedInput,
41
+ humanInputDefinition: () => humanInputDefinition,
42
+ imageLoaderFor: () => imageLoaderFor,
43
+ inject: () => inject,
44
+ isDesktopShell: () => isDesktopShell,
45
+ mediaSourceFor: () => mediaSourceFor,
46
+ name: () => name,
47
+ nestedDisplayDefinition: () => nestedDisplayDefinition,
48
+ tailDisplays: () => tailDisplays,
49
+ turnEndDefinition: () => turnEndDefinition,
50
+ turnStaysOpen: () => turnStaysOpen,
51
+ turnTailJoinable: () => turnTailJoinable,
52
+ viewerTurnDefinition: () => viewerTurnDefinition,
53
+ viewerTurnDefinitions: () => viewerTurnDefinitions
54
+ });
55
+ module.exports = __toCommonJS(index_exports);
56
+
57
+ // src/contract.ts
58
+ var DISPLAY_TOOL = "display_file";
59
+ var READ_IMAGE_TOOL = "read_image";
60
+ var ASSET_ROUTE = "/crosery/dsh-viewer/asset";
61
+ var MODEL_IMAGE_MEDIA_TYPES = [
62
+ "image/png",
63
+ "image/jpeg",
64
+ "image/webp",
65
+ "image/gif"
66
+ ];
67
+ var MEDIA_TABLE = {
68
+ // Rasters and vectors an <img> renders. The first four are also the exact
69
+ // set the durable attachment service admits, which is what lets them reach
70
+ // model context; the rest are display-only.
71
+ ".png": { kind: "image", mediaType: "image/png" },
72
+ ".jpg": { kind: "image", mediaType: "image/jpeg" },
73
+ ".jpeg": { kind: "image", mediaType: "image/jpeg" },
74
+ ".webp": { kind: "image", mediaType: "image/webp" },
75
+ ".gif": { kind: "image", mediaType: "image/gif" },
76
+ ".svg": { kind: "image", mediaType: "image/svg+xml" },
77
+ ".avif": { kind: "image", mediaType: "image/avif" },
78
+ ".bmp": { kind: "image", mediaType: "image/bmp" },
79
+ ".ico": { kind: "image", mediaType: "image/x-icon" },
80
+ ".apng": { kind: "image", mediaType: "image/apng" },
81
+ ".mp4": { kind: "video", mediaType: "video/mp4" },
82
+ ".m4v": { kind: "video", mediaType: "video/x-m4v" },
83
+ ".webm": { kind: "video", mediaType: "video/webm" },
84
+ ".ogv": { kind: "video", mediaType: "video/ogg" },
85
+ ".mov": { kind: "video", mediaType: "video/quicktime" },
86
+ ".mp3": { kind: "audio", mediaType: "audio/mpeg" },
87
+ ".m4a": { kind: "audio", mediaType: "audio/mp4" },
88
+ ".aac": { kind: "audio", mediaType: "audio/aac" },
89
+ ".wav": { kind: "audio", mediaType: "audio/wav" },
90
+ ".flac": { kind: "audio", mediaType: "audio/flac" },
91
+ ".ogg": { kind: "audio", mediaType: "audio/ogg" },
92
+ ".oga": { kind: "audio", mediaType: "audio/ogg" },
93
+ ".opus": { kind: "audio", mediaType: "audio/ogg" },
94
+ ".pdf": { kind: "pdf", mediaType: "application/pdf" },
95
+ // Office and OpenDocument. No browser renders any of these, so the Host
96
+ // converts them before the card ever sees them — `mediaType` here is the
97
+ // SOURCE type, and the served artifact's type replaces it in the outcome.
98
+ ".docx": { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
99
+ ".doc": { kind: "document", mediaType: "application/msword" },
100
+ ".rtf": { kind: "document", mediaType: "application/rtf" },
101
+ ".odt": { kind: "document", mediaType: "application/vnd.oasis.opendocument.text" },
102
+ ".xlsx": { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
103
+ ".xls": { kind: "document", mediaType: "application/vnd.ms-excel" },
104
+ ".ods": { kind: "document", mediaType: "application/vnd.oasis.opendocument.spreadsheet" },
105
+ ".pptx": { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
106
+ ".ppt": { kind: "document", mediaType: "application/vnd.ms-powerpoint" },
107
+ ".odp": { kind: "document", mediaType: "application/vnd.oasis.opendocument.presentation" },
108
+ ".html": { kind: "html", mediaType: "text/html" },
109
+ ".htm": { kind: "html", mediaType: "text/html" }
110
+ };
111
+ var UNKNOWN_MEDIA = { kind: "file", mediaType: "application/octet-stream" };
112
+ function extensionOf(filePath) {
113
+ const base = filePath.slice(Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")) + 1);
114
+ const dot = base.lastIndexOf(".");
115
+ if (dot <= 0) return void 0;
116
+ return base.slice(dot).toLowerCase();
117
+ }
118
+ function classifyPath(filePath) {
119
+ const extension = extensionOf(filePath);
120
+ if (extension === void 0) return UNKNOWN_MEDIA;
121
+ return Object.hasOwn(MEDIA_TABLE, extension) ? MEDIA_TABLE[extension] : UNKNOWN_MEDIA;
122
+ }
123
+ function modelImageFrom(value) {
124
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
125
+ const { attachmentId, mediaType, bytes, width, height, name: name2 } = value;
126
+ if (typeof attachmentId !== "string" || attachmentId.length === 0) return void 0;
127
+ if (typeof mediaType !== "string" || !MODEL_IMAGE_MEDIA_TYPES.includes(mediaType)) return void 0;
128
+ if (!isPositiveInteger(bytes) || !isPositiveInteger(width) || !isPositiveInteger(height)) return void 0;
129
+ if (name2 !== void 0 && typeof name2 !== "string") return void 0;
130
+ return {
131
+ attachmentId,
132
+ mediaType,
133
+ bytes,
134
+ width,
135
+ height,
136
+ ...name2 === void 0 ? {} : { name: name2 }
137
+ };
138
+ }
139
+ var VIEWER_KINDS = ["image", "video", "audio", "pdf", "document", "html", "file"];
140
+ function displayValueFrom(meta) {
141
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
142
+ const { path, kind, mediaType, bytes, assetUrl, image, inContext, unavailable } = meta;
143
+ if (typeof path !== "string" || path.length === 0) return void 0;
144
+ if (typeof kind !== "string" || !VIEWER_KINDS.includes(kind)) return void 0;
145
+ if (typeof mediaType !== "string" || mediaType.length === 0) return void 0;
146
+ if (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 0) return void 0;
147
+ if (typeof inContext !== "boolean") return void 0;
148
+ if (assetUrl !== void 0 && (typeof assetUrl !== "string" || !assetUrl.startsWith(`${ASSET_ROUTE}?`))) return void 0;
149
+ if (unavailable !== void 0 && typeof unavailable !== "string") return void 0;
150
+ const narrowedImage = image === void 0 ? void 0 : modelImageFrom(image);
151
+ if (image !== void 0 && narrowedImage === void 0) return void 0;
152
+ return {
153
+ path,
154
+ kind,
155
+ mediaType,
156
+ bytes,
157
+ inContext,
158
+ ...assetUrl === void 0 ? {} : { assetUrl },
159
+ ...narrowedImage === void 0 ? {} : { image: narrowedImage },
160
+ ...unavailable === void 0 ? {} : { unavailable }
161
+ };
162
+ }
163
+ function isPositiveInteger(value) {
164
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
165
+ }
166
+ function formatBytes(bytes) {
167
+ if (!Number.isFinite(bytes) || bytes <= 0) return "";
168
+ const units = ["B", "KB", "MB", "GB"];
169
+ let value = bytes;
170
+ let unit = 0;
171
+ while (value >= 1024 && unit < units.length - 1) {
172
+ value /= 1024;
173
+ unit += 1;
174
+ }
175
+ return `${unit === 0 ? value : value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
176
+ }
177
+
178
+ // src/client/ViewerCard.tsx
179
+ var import_react = require("react");
180
+ var import_react_dom = require("react-dom");
181
+
182
+ // src/client/card-model.ts
183
+ function isSettled(block) {
184
+ return "kind" in block && block.kind === "tool-result";
185
+ }
186
+ function argumentPathOf(block) {
187
+ const raw = isSettled(block) ? block.call?.argsRaw : block.argsRaw;
188
+ if (typeof raw !== "string" || raw.length === 0) return void 0;
189
+ try {
190
+ const parsed = JSON.parse(raw);
191
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
192
+ const { file_path: filePath } = parsed;
193
+ return typeof filePath === "string" && filePath.length > 0 ? filePath : void 0;
194
+ } catch {
195
+ return void 0;
196
+ }
197
+ }
198
+ function textOf(content) {
199
+ const parts = [];
200
+ for (const block of content) {
201
+ if (typeof block !== "object" || block === null) continue;
202
+ const { type, text } = block;
203
+ if (type === "text" && typeof text === "string") parts.push(text);
204
+ }
205
+ return parts.join("\n").trim();
206
+ }
207
+ function contentImageOf(content) {
208
+ for (const block of content) {
209
+ if (typeof block !== "object" || block === null) continue;
210
+ const { type, attachment } = block;
211
+ if (type !== "image") continue;
212
+ const image = modelImageFrom(attachment);
213
+ if (image !== void 0) return image;
214
+ }
215
+ return void 0;
216
+ }
217
+ function element(text, tag) {
218
+ const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text);
219
+ const value = match?.[1]?.trim();
220
+ return value !== void 0 && value.length > 0 ? value : void 0;
221
+ }
222
+ function envelopePathOf(content) {
223
+ return element(textOf(content), "path");
224
+ }
225
+ function envelopeValueOf(content, inContext) {
226
+ const text = textOf(content);
227
+ const path = element(text, "path");
228
+ const kind = element(text, "type");
229
+ const mediaType = element(text, "media");
230
+ const bytes = Number(element(text, "bytes") ?? Number.NaN);
231
+ if (path === void 0 || kind === void 0 || mediaType === void 0 || !Number.isInteger(bytes)) return void 0;
232
+ const assetUrl = element(text, "asset");
233
+ return displayValueFrom({
234
+ path,
235
+ kind,
236
+ mediaType,
237
+ bytes,
238
+ inContext,
239
+ ...assetUrl === void 0 ? {} : { assetUrl }
240
+ });
241
+ }
242
+ function cardModel(block, toolName) {
243
+ const path = argumentPathOf(block);
244
+ if (!isSettled(block)) return { phase: "running", path };
245
+ if (block.isError) {
246
+ const message = textOf(block.content);
247
+ return { phase: "failed", path, message: message.length > 0 ? message : block.error?.code ?? "failed" };
248
+ }
249
+ const image = contentImageOf(block.content);
250
+ if (toolName === DISPLAY_TOOL) {
251
+ const value = displayValueFrom(block.meta);
252
+ if (value !== void 0) return { phase: "ready", value };
253
+ const recovered = envelopeValueOf(block.content, image !== void 0);
254
+ if (recovered !== void 0) {
255
+ return { phase: "ready", value: { ...recovered, ...image === void 0 ? {} : { image } } };
256
+ }
257
+ }
258
+ if (image !== void 0) {
259
+ const displayPath = path ?? envelopePathOf(block.content) ?? image.name ?? "";
260
+ return {
261
+ phase: "ready",
262
+ value: {
263
+ path: displayPath,
264
+ kind: "image",
265
+ mediaType: image.mediaType,
266
+ bytes: image.bytes,
267
+ // No asset URL: this result was not minted by this plugin, so the bytes
268
+ // come over the session attachment channel instead.
269
+ image,
270
+ inContext: true
271
+ }
272
+ };
273
+ }
274
+ return {
275
+ phase: "bare",
276
+ path,
277
+ message: path === void 0 ? "" : classifyPath(path).mediaType
278
+ };
279
+ }
280
+
281
+ // src/client/host.ts
282
+ function isDesktopShell(globals = globalThis) {
283
+ return globals.__DSH_HOST_PATHS__ !== void 0 || globals.location?.protocol === "dsh-app:";
284
+ }
285
+ var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
286
+ function mediaSourceFor(assetUrl, globals = globalThis) {
287
+ if (!assetUrl.startsWith("/") || !isDesktopShell(globals)) return assetUrl;
288
+ const base = globals.__DSH_TRANSPORT__?.streamBaseUrl;
289
+ if (typeof base !== "string") return assetUrl;
290
+ try {
291
+ const url = new URL(base);
292
+ if (url.protocol !== "http:" && url.protocol !== "https:" || !LOOPBACK.has(url.hostname)) return assetUrl;
293
+ return new URL(assetUrl, url).href;
294
+ } catch {
295
+ return assetUrl;
296
+ }
297
+ }
298
+
299
+ // src/client/sources.ts
300
+ function imageLoaderFor(owner, fallback) {
301
+ if (typeof owner !== "function") return (image) => fallback(image.attachmentId);
302
+ return (image) => owner({ ...image });
303
+ }
304
+
305
+ // src/client/ViewerCard.tsx
306
+ var import_jsx_runtime = require("react/jsx-runtime");
307
+ var CSS = "dshview";
308
+ var KIND_TITLE = {
309
+ image: "title.image",
310
+ video: "title.video",
311
+ audio: "title.audio",
312
+ pdf: "title.pdf",
313
+ document: "title.document",
314
+ html: "title.html",
315
+ file: "title.file"
316
+ };
317
+ function PathLabel({ path }) {
318
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: `${CSS}-path`, title: path, children: [
319
+ "\u2068",
320
+ path,
321
+ "\u2069"
322
+ ] });
323
+ }
324
+ function KindIcon({ kind }) {
325
+ const paths = {
326
+ image: "M3 4.5h10v7H3zM3 10l2.5-2.5 2 2L10.5 6l2.5 3v2.5H3z",
327
+ video: "M2.5 4h7.5v8H2.5zM10.5 6.8l3-1.8v6l-3-1.8z",
328
+ audio: "M6 3.5v6.2a2 2 0 1 0 1.2 1.8V6h4V3.5z",
329
+ pdf: "M4 2h5l3 3v9H4zM9 2v3h3",
330
+ document: "M4 2h5l3 3v9H4zM9 2v3h3M5.5 8h5M5.5 10.5h5",
331
+ html: "M2.5 8 6 4.6l.9.9L4.3 8l2.6 2.5-.9.9zM13.5 8 10 11.4l-.9-.9L11.7 8 9.1 5.5l.9-.9z",
332
+ file: "M4 2h5l3 3v9H4zM9 2v3h3"
333
+ };
334
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `${CSS}-icon`, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: paths[kind] }) }) });
335
+ }
336
+ function useMediaSource(value, load) {
337
+ const [resolved, setResolved] = (0, import_react.useState)(void 0);
338
+ const [failed, setFailed] = (0, import_react.useState)(false);
339
+ const [attempt, setAttempt] = (0, import_react.useState)(0);
340
+ const retry = (0, import_react.useCallback)(() => {
341
+ setAttempt((a) => a + 1);
342
+ }, []);
343
+ const loader = (0, import_react.useRef)(load);
344
+ loader.current = load;
345
+ const image = value.image;
346
+ const attachmentId = image?.attachmentId;
347
+ const direct = value.assetUrl;
348
+ (0, import_react.useEffect)(() => {
349
+ if (direct !== void 0 || image === void 0) {
350
+ setResolved(void 0);
351
+ setFailed(false);
352
+ return;
353
+ }
354
+ let live = true;
355
+ setResolved(void 0);
356
+ setFailed(false);
357
+ void loader.current(image).then(
358
+ (url) => {
359
+ if (live) setResolved(url);
360
+ },
361
+ () => {
362
+ if (live) setFailed(true);
363
+ }
364
+ );
365
+ return () => {
366
+ live = false;
367
+ };
368
+ }, [direct, attachmentId, attempt]);
369
+ if (direct !== void 0) return { src: direct, failed: false, pending: false, retry };
370
+ if (attachmentId === void 0) return { src: void 0, failed: false, pending: false, retry };
371
+ return { src: resolved, failed, pending: resolved === void 0 && !failed, retry };
372
+ }
373
+ var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
374
+ function Lightbox({ src, alt, onClose, closeLabel }) {
375
+ const panel = (0, import_react.useRef)(null);
376
+ const close = (0, import_react.useRef)(null);
377
+ const origin = (0, import_react.useRef)(null);
378
+ (0, import_react.useEffect)(() => {
379
+ origin.current = document.activeElement;
380
+ close.current?.focus();
381
+ return () => {
382
+ const back = origin.current;
383
+ if (back?.isConnected) back.focus();
384
+ };
385
+ }, []);
386
+ (0, import_react.useEffect)(() => {
387
+ const onKey = (event) => {
388
+ if (event.key === "Escape") {
389
+ event.preventDefault();
390
+ event.stopPropagation();
391
+ onClose();
392
+ return;
393
+ }
394
+ if (event.key !== "Tab") return;
395
+ const items = panel.current?.querySelectorAll(FOCUSABLE);
396
+ if (items === void 0 || items.length === 0) return;
397
+ const first = items[0];
398
+ const last = items[items.length - 1];
399
+ if (first === void 0 || last === void 0) return;
400
+ const active = document.activeElement;
401
+ const outside = active === null || panel.current?.contains(active) !== true;
402
+ const atEdge = event.shiftKey ? active === first : active === last;
403
+ if (!outside && !atEdge) return;
404
+ event.preventDefault();
405
+ if (event.shiftKey) last.focus();
406
+ else first.focus();
407
+ };
408
+ window.addEventListener("keydown", onKey, true);
409
+ return () => {
410
+ window.removeEventListener("keydown", onKey, true);
411
+ };
412
+ }, [onClose]);
413
+ if (typeof document === "undefined") return null;
414
+ return (0, import_react_dom.createPortal)(
415
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ref: panel, className: `${CSS}-lightbox`, role: "dialog", "aria-modal": "true", "aria-label": alt, onClick: onClose, children: [
416
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
417
+ "img",
418
+ {
419
+ className: `${CSS}-lightboxImage`,
420
+ src,
421
+ alt,
422
+ onClick: (event) => event.stopPropagation()
423
+ }
424
+ ),
425
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
426
+ "button",
427
+ {
428
+ ref: close,
429
+ type: "button",
430
+ className: `${CSS}-lightboxClose`,
431
+ "aria-label": closeLabel,
432
+ title: closeLabel,
433
+ onClick: (event) => {
434
+ event.stopPropagation();
435
+ onClose();
436
+ },
437
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" }) })
438
+ }
439
+ )
440
+ ] }),
441
+ document.body
442
+ );
443
+ }
444
+ function OpenAction({ src, path, openFile, t }) {
445
+ if (isDesktopShell()) {
446
+ if (openFile === void 0) return null;
447
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: `${CSS}-action`, onClick: () => {
448
+ openFile(path);
449
+ }, children: t("action.openSidebar") });
450
+ }
451
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", { className: `${CSS}-link`, href: src, target: "_blank", rel: "noreferrer", children: t("action.openNew") });
452
+ }
453
+ function Viewer({ value, sources, t }) {
454
+ const { src, failed, pending, retry } = useMediaSource(value, sources.loadImage);
455
+ const [zoomed, setZoomed] = (0, import_react.useState)(false);
456
+ const closeZoom = (0, import_react.useCallback)(() => {
457
+ setZoomed(false);
458
+ }, []);
459
+ const label = value.image?.name ?? value.path;
460
+ if (failed) {
461
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: `${CSS}-retry`, onClick: retry, children: t("state.loadFailed") });
462
+ }
463
+ if (pending) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-note`, children: t("state.loading") });
464
+ if (src === void 0) {
465
+ if (value.unavailable !== void 0) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-note`, children: value.unavailable });
466
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-note`, children: t(value.kind === "image" ? "state.unsupported" : "state.unavailable") });
467
+ }
468
+ switch (value.kind) {
469
+ case "image":
470
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
471
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: `${CSS}-imageButton`, title: t("action.open"), "aria-label": t("action.open"), onClick: () => {
472
+ setZoomed(true);
473
+ }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { className: `${CSS}-image`, src, alt: label, loading: "lazy" }) }),
474
+ zoomed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Lightbox, { src, alt: label, onClose: closeZoom, closeLabel: t("action.close") })
475
+ ] });
476
+ case "video":
477
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("video", { className: `${CSS}-video`, src: mediaSourceFor(src), controls: true, preload: "metadata", children: t("media.noVideo") });
478
+ case "audio":
479
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("audio", { className: `${CSS}-audio`, src: mediaSourceFor(src), controls: true, preload: "metadata", children: t("media.noAudio") });
480
+ case "pdf":
481
+ case "document":
482
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
483
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("iframe", { className: `${CSS}-frame`, src, title: label }),
484
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(OpenAction, { src, path: value.path, openFile: sources.openFile, t })
485
+ ] });
486
+ case "html":
487
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
488
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
489
+ "iframe",
490
+ {
491
+ className: `${CSS}-frame`,
492
+ src,
493
+ title: label,
494
+ sandbox: "allow-scripts allow-forms allow-popups"
495
+ }
496
+ ),
497
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(OpenAction, { src, path: value.path, openFile: sources.openFile, t })
498
+ ] });
499
+ default:
500
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(OpenAction, { src, path: value.path, openFile: sources.openFile, t });
501
+ }
502
+ }
503
+ function Head({ kind, label, path, detail, badge, onToggle, expanded, t }) {
504
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
505
+ "button",
506
+ {
507
+ type: "button",
508
+ className: `${CSS}-head`,
509
+ onClick: onToggle,
510
+ "aria-expanded": onToggle === void 0 ? void 0 : expanded,
511
+ title: onToggle === void 0 ? void 0 : t(expanded ? "action.collapse" : "action.expand"),
512
+ children: [
513
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KindIcon, { kind }),
514
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `${CSS}-kind`, children: t(label) }),
515
+ path.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PathLabel, { path }),
516
+ detail.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `${CSS}-meta`, children: detail }),
517
+ badge !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `${CSS}-badge`, children: badge })
518
+ ]
519
+ }
520
+ );
521
+ }
522
+ function detailOf(value) {
523
+ const size = formatBytes(value.bytes);
524
+ const dimensions = value.image === void 0 ? "" : `${value.image.width}\xD7${value.image.height}`;
525
+ return [dimensions, size].filter((part) => part.length > 0).join(" \xB7 ");
526
+ }
527
+ function DisplayedFile({ value, toolName, sources, t }) {
528
+ const [open, setOpen] = (0, import_react.useState)(true);
529
+ const toggle = (0, import_react.useCallback)(() => {
530
+ setOpen((previous) => !previous);
531
+ }, []);
532
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: `${CSS}-card`, children: [
533
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
534
+ Head,
535
+ {
536
+ kind: value.kind,
537
+ label: headLabel(toolName, value.kind),
538
+ path: value.path,
539
+ detail: detailOf(value),
540
+ badge: t(value.inContext ? "badge.inContext" : "badge.screenOnly"),
541
+ expanded: open,
542
+ onToggle: toggle,
543
+ t
544
+ }
545
+ ),
546
+ open && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-body`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Viewer, { value, sources, t }) })
547
+ ] });
548
+ }
549
+ function ViewerCard({ toolName, block, t, openFile, loadImage, loadAttachment }) {
550
+ const state = cardModel(block, toolName);
551
+ if (state.phase === "ready") {
552
+ const sources = { loadImage: imageLoaderFor(loadImage, loadAttachment), openFile };
553
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DisplayedFile, { value: state.value, toolName, sources, t });
554
+ }
555
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(UnsettledCard, { state, toolName, t });
556
+ }
557
+ function UnsettledCard({ state, toolName, t }) {
558
+ const [open, setOpen] = (0, import_react.useState)(true);
559
+ const toggle = (0, import_react.useCallback)(() => {
560
+ setOpen((previous) => !previous);
561
+ }, []);
562
+ const kind = classifyKind(state.path);
563
+ const label = headLabel(toolName, kind);
564
+ const path = state.path ?? "";
565
+ if (state.phase === "running") {
566
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-card`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Head, { kind, label, path, detail: t("state.running"), expanded: false, t }) });
567
+ }
568
+ if (state.phase === "failed") {
569
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: `${CSS}-card`, children: [
570
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Head, { kind, label, path, detail: "", expanded: open, onToggle: toggle, t }),
571
+ open && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-body`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-note ${CSS}-error`, children: state.message }) })
572
+ ] });
573
+ }
574
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `${CSS}-card`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Head, { kind, label, path, detail: state.message, expanded: false, t }) });
575
+ }
576
+ function headLabel(toolName, kind) {
577
+ return toolName === READ_IMAGE_TOOL ? "title.readImage" : KIND_TITLE[kind];
578
+ }
579
+ function classifyKind(path) {
580
+ return path === void 0 ? "file" : classifyPath(path).kind;
581
+ }
582
+
583
+ // src/client/ViewerTail.tsx
584
+ var import_react2 = require("react");
585
+
586
+ // src/client/turn-tail.ts
587
+ var VIEWER_TURN_DATA = "crosery-viewer";
588
+ var VIEWER_NESTED = "crosery-viewer-nested";
589
+ var VIEWER_INPUT = "crosery-viewer-input";
590
+ var VIEWER_TURN_END = "crosery-viewer-turn-end";
591
+ var CHAT_TURN_PROCESS = "turn-process";
592
+ function field(value, key) {
593
+ return typeof value === "object" && value !== null ? value[key] : void 0;
594
+ }
595
+ function turnOf(event) {
596
+ const turn = field(event.data, "turn");
597
+ return typeof turn === "number" && Number.isFinite(turn) ? turn : void 0;
598
+ }
599
+ function isTranscriptResult(event) {
600
+ return event.surfaceOp === void 0 || event.surfaceOp === "append";
601
+ }
602
+ function locationTurn(location) {
603
+ const kind = field(location, "kind");
604
+ if (kind !== "turn" && kind !== "step") return void 0;
605
+ const turn = field(field(location, "turn"), "turn");
606
+ return typeof turn === "number" && Number.isFinite(turn) ? turn : void 0;
607
+ }
608
+ function resultCallId(event) {
609
+ const id = field(field(field(event.data, "message"), "source"), "callId");
610
+ return typeof id === "string" || typeof id === "number" ? String(id) : void 0;
611
+ }
612
+ function displayedValueOf(data) {
613
+ const message = field(data, "message");
614
+ if (field(message, "isError") === true) return void 0;
615
+ const fromMeta = displayValueFrom(field(data, "meta"));
616
+ if (fromMeta !== void 0) return fromMeta;
617
+ const content = field(message, "content");
618
+ if (!Array.isArray(content)) return void 0;
619
+ const image = contentImageOf(content);
620
+ const recovered = envelopeValueOf(content, image !== void 0);
621
+ return recovered === void 0 ? void 0 : { ...recovered, ...image === void 0 ? {} : { image } };
622
+ }
623
+ var viewerTurnDefinition = {
624
+ kind: VIEWER_TURN_DATA,
625
+ match(event) {
626
+ const turn = turnOf(event);
627
+ if (turn === void 0) return null;
628
+ if (event.type === "turn/start") return { id: String(turn), role: "start" };
629
+ if (event.type === "tool/call") return field(event.data, "name") === DISPLAY_TOOL ? { id: String(turn), role: "update" } : null;
630
+ if (event.type === "tool/result") return isTranscriptResult(event) ? { id: String(turn), role: "update" } : null;
631
+ return null;
632
+ },
633
+ start(_context, match) {
634
+ const turn = turnOf(match.event);
635
+ if (match.event.type !== "turn/start" || turn === void 0) throw new Error("dsh-viewer: a turn context starts at turn/start");
636
+ return { turn, calls: /* @__PURE__ */ new Set(), displayed: [] };
637
+ },
638
+ update(context, match) {
639
+ const { event } = match;
640
+ const state = context.state;
641
+ if (event.type === "tool/call") {
642
+ const id = field(event.data, "callId");
643
+ if (typeof id !== "string" && typeof id !== "number") return state;
644
+ const calls = new Set(state.calls);
645
+ calls.add(String(id));
646
+ return { ...state, calls };
647
+ }
648
+ if (event.type !== "tool/result") return state;
649
+ const callId = resultCallId(event);
650
+ if (callId === void 0 || !state.calls.has(callId)) return state;
651
+ const value = displayedValueOf(event.data);
652
+ if (value === void 0 || typeof event.seq !== "number") return state;
653
+ return { ...state, displayed: [...state.displayed, { seq: event.seq, callId, value }] };
654
+ },
655
+ buildLocationData(context, scope, previous) {
656
+ const state = context.state;
657
+ if (scope !== "turn" || state === void 0 || state.displayed.length === 0) return null;
658
+ if (previous?.kind === "turn" && previous.turn === state.turn && previous.key === VIEWER_TURN_DATA && previous.value?.displayed === state.displayed) {
659
+ return previous;
660
+ }
661
+ return { kind: "turn", turn: state.turn, key: VIEWER_TURN_DATA, value: { displayed: state.displayed } };
662
+ }
663
+ };
664
+ var nestedDisplayDefinition = {
665
+ kind: VIEWER_NESTED,
666
+ match(event) {
667
+ if (event.type !== "tool/ptc-dispatch" || field(event.data, "name") !== DISPLAY_TOOL) return null;
668
+ const id = field(event.data, "subCallId");
669
+ return typeof id === "string" || typeof id === "number" ? { id: String(id), role: "start" } : null;
670
+ },
671
+ start(_context, match, reader) {
672
+ const { event } = match;
673
+ const previous = reader?.previous?.(VIEWER_NESTED)?.state;
674
+ const value = displayedValueOf({ message: { isError: field(event.data, "isError"), content: field(event.data, "content") } });
675
+ return {
676
+ turn: locationTurn(match.location),
677
+ display: value === void 0 || typeof event.seq !== "number" ? void 0 : { seq: event.seq, callId: String(field(event.data, "subCallId")), value },
678
+ previous
679
+ };
680
+ },
681
+ update(context) {
682
+ return context.state;
683
+ },
684
+ publication() {
685
+ return "none";
686
+ }
687
+ };
688
+ var humanInputDefinition = {
689
+ kind: VIEWER_INPUT,
690
+ match(event) {
691
+ if (event.type !== "user/message" || event.surfaceOp !== "append") return null;
692
+ if (field(field(event.data, "source"), "kind") !== "user") return null;
693
+ const id = field(event.data, "id");
694
+ return typeof id === "string" || typeof id === "number" ? { id: String(id), role: "start" } : null;
695
+ },
696
+ start(_context, match) {
697
+ return { turn: locationTurn(match.location), seq: typeof match.event.seq === "number" ? match.event.seq : -1 };
698
+ },
699
+ update(context) {
700
+ return context.state;
701
+ },
702
+ publication() {
703
+ return "none";
704
+ }
705
+ };
706
+ var turnEndDefinition = {
707
+ kind: VIEWER_TURN_END,
708
+ match(event) {
709
+ if (event.type !== "turn/end") return null;
710
+ const turn = turnOf(event);
711
+ return turn === void 0 ? null : { id: String(turn), role: "start" };
712
+ },
713
+ start(_context, match, reader) {
714
+ const turn = turnOf(match.event);
715
+ if (match.event.type !== "turn/end" || turn === void 0) throw new Error("dsh-viewer: a turn-end context starts at turn/end");
716
+ const nested = [];
717
+ let node = reader?.previous?.(VIEWER_NESTED)?.state;
718
+ for (; node !== void 0 && node.turn === turn; node = node.previous) {
719
+ if (node.display !== void 0) nested.push(node.display);
720
+ }
721
+ const input = reader?.previous?.(VIEWER_INPUT)?.state;
722
+ return { turn, nested: nested.reverse(), lastInputSeq: input?.turn === turn ? input.seq : void 0 };
723
+ },
724
+ update(context) {
725
+ return context.state;
726
+ },
727
+ buildLocationData(context, scope, previous) {
728
+ const state = context.state;
729
+ if (scope !== "turn" || state === void 0) return null;
730
+ if (state.nested.length === 0 && state.lastInputSeq === void 0) return null;
731
+ if (previous?.kind === "turn" && previous.turn === state.turn && previous.key === VIEWER_TURN_END && previous.value?.nested === state.nested && previous.value.lastInputSeq === state.lastInputSeq) {
732
+ return previous;
733
+ }
734
+ return {
735
+ kind: "turn",
736
+ turn: state.turn,
737
+ key: VIEWER_TURN_END,
738
+ value: { nested: state.nested, ...state.lastInputSeq === void 0 ? {} : { lastInputSeq: state.lastInputSeq } }
739
+ };
740
+ }
741
+ };
742
+ var viewerTurnDefinitions = [viewerTurnDefinition, nestedDisplayDefinition, humanInputDefinition, turnEndDefinition];
743
+ function hasInterleavedInput(turn) {
744
+ const input = field(turn.data?.get?.(VIEWER_TURN_END), "lastInputSeq");
745
+ const anchor = field(turn.data?.get?.(CHAT_TURN_PROCESS), "controlAnchorSeq");
746
+ if (typeof input !== "number" || typeof anchor !== "number") return false;
747
+ if (anchor === field(turn.start, "seq")) return false;
748
+ return input > anchor;
749
+ }
750
+ function turnStaysOpen(turn, steeredStaysOpen = false) {
751
+ if (turn.status === "open") return true;
752
+ const reason = field(field(field(turn.end, "data"), "reason"), "kind");
753
+ if (reason === "aborted" || reason === "error") return true;
754
+ return steeredStaysOpen && hasInterleavedInput(turn);
755
+ }
756
+ function tailDisplays(turn, closingSeq, steeredStaysOpen = false) {
757
+ if (turnStaysOpen(turn, steeredStaysOpen)) return [];
758
+ const data = turn.data?.get?.(VIEWER_TURN_DATA);
759
+ const end = turn.data?.get?.(VIEWER_TURN_END);
760
+ const displayed = [
761
+ ...Array.isArray(data?.displayed) ? data.displayed : [],
762
+ ...Array.isArray(end?.nested) ? end.nested : []
763
+ ].sort((a, b) => a.seq - b.seq);
764
+ const byPath = /* @__PURE__ */ new Map();
765
+ for (const entry of displayed) {
766
+ if (entry.seq >= closingSeq) continue;
767
+ byPath.delete(entry.value.path);
768
+ byPath.set(entry.value.path, entry);
769
+ }
770
+ return [...byPath.values()];
771
+ }
772
+ function foldsCompletedTurns(mode) {
773
+ return mode !== "verbose";
774
+ }
775
+ function foldSourceOf(forms) {
776
+ const form = () => {
777
+ const service = forms();
778
+ if (typeof service?.get !== "function") return void 0;
779
+ try {
780
+ const found = service.get("ui-chat");
781
+ return typeof found?.getSnapshot === "function" && typeof found.subscribe === "function" ? found : void 0;
782
+ } catch {
783
+ return void 0;
784
+ }
785
+ };
786
+ return {
787
+ getSnapshot: () => foldsCompletedTurns(form()?.getSnapshot()?.value?.transcriptView),
788
+ subscribe: (listener) => form()?.subscribe(listener) ?? (() => {
789
+ }),
790
+ keepsSteeredTurnsOpen: () => form() !== void 0
791
+ };
792
+ }
793
+
794
+ // src/client/ViewerTail.tsx
795
+ var import_jsx_runtime2 = require("react/jsx-runtime");
796
+ var never = () => () => {
797
+ };
798
+ function ViewerTail({ turn, seq, openFile, folds, loadAttachment, t }) {
799
+ const folding = (0, import_react2.useSyncExternalStore)(folds?.subscribe ?? never, folds?.getSnapshot ?? (() => true));
800
+ if (!folding) return null;
801
+ const displays = tailDisplays(turn, seq, folds?.keepsSteeredTurnsOpen?.() ?? false);
802
+ if (displays.length === 0) return null;
803
+ const sources = { loadImage: imageLoaderFor(void 0, loadAttachment), openFile };
804
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { className: "dshview-tail", "aria-label": t("tail.label"), children: displays.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(DisplayedFile, { value: entry.value, toolName: DISPLAY_TOOL, sources, t }, entry.callId)) });
805
+ }
806
+
807
+ // src/client/locales.ts
808
+ var zh = {
809
+ "title.image": "\u56FE\u7247",
810
+ "title.video": "\u89C6\u9891",
811
+ "title.audio": "\u97F3\u9891",
812
+ "title.pdf": "PDF",
813
+ "title.document": "\u6587\u6863",
814
+ "title.html": "\u7F51\u9875",
815
+ "title.file": "\u6587\u4EF6",
816
+ "title.readImage": "\u8BFB\u5165\u56FE\u7247",
817
+ "state.running": "\u8BFB\u53D6\u4E2D",
818
+ "state.loading": "\u52A0\u8F7D\u4E2D",
819
+ "state.loadFailed": "\u52A0\u8F7D\u5931\u8D25\uFF0C\u70B9\u51FB\u91CD\u8BD5",
820
+ "state.retry": "\u91CD\u8BD5",
821
+ "state.unavailable": "\u6B64\u6587\u4EF6\u7CFB\u7EDF\u540E\u7AEF\u4E0D\u63D0\u4F9B\u53EF\u9884\u89C8\u7684\u672C\u5730\u8DEF\u5F84",
822
+ "state.unsupported": "\u6CA1\u6709\u53EF\u7528\u7684\u9884\u89C8\u5185\u5BB9",
823
+ "badge.inContext": "\u5DF2\u8FDB\u5165\u6A21\u578B\u4E0A\u4E0B\u6587",
824
+ "badge.screenOnly": "\u4EC5\u5728\u9875\u9762\u663E\u793A",
825
+ "action.open": "\u67E5\u770B\u539F\u56FE",
826
+ "action.openNew": "\u5728\u65B0\u6807\u7B7E\u6253\u5F00",
827
+ "action.openSidebar": "\u5728\u4FA7\u8FB9\u680F\u9884\u89C8",
828
+ "action.close": "\u5173\u95ED",
829
+ "action.expand": "\u5C55\u5F00",
830
+ "action.collapse": "\u6536\u8D77",
831
+ "media.noVideo": "\u5F53\u524D\u6D4F\u89C8\u5668\u65E0\u6CD5\u64AD\u653E\u8BE5\u89C6\u9891\u683C\u5F0F",
832
+ "media.noAudio": "\u5F53\u524D\u6D4F\u89C8\u5668\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u9891\u683C\u5F0F",
833
+ "tail.label": "\u672C\u8F6E\u5C55\u793A\u7684\u6587\u4EF6"
834
+ };
835
+ var en = {
836
+ "title.image": "Image",
837
+ "title.video": "Video",
838
+ "title.audio": "Audio",
839
+ "title.pdf": "PDF",
840
+ "title.document": "Document",
841
+ "title.html": "Page",
842
+ "title.file": "File",
843
+ "title.readImage": "Image read",
844
+ "state.running": "Reading",
845
+ "state.loading": "Loading",
846
+ "state.loadFailed": "Failed to load \u2014 click to retry",
847
+ "state.retry": "Retry",
848
+ "state.unavailable": "This filesystem backend exposes no previewable local path",
849
+ "state.unsupported": "Nothing to preview",
850
+ "badge.inContext": "in model context",
851
+ "badge.screenOnly": "shown to you only",
852
+ "action.open": "View full size",
853
+ "action.openNew": "Open in a new tab",
854
+ "action.openSidebar": "Preview in sidebar",
855
+ "action.close": "Close",
856
+ "action.expand": "Expand",
857
+ "action.collapse": "Collapse",
858
+ "media.noVideo": "This browser cannot play that video format",
859
+ "media.noAudio": "This browser cannot play that audio format",
860
+ "tail.label": "Files shown in this turn"
861
+ };
862
+
863
+ // src/client/styles.ts
864
+ var PLUGIN_ID = "@crosery/dsh-viewer";
865
+ var SHEET = `
866
+ .dshview-card { display: flex; flex-direction: column; gap: 6px; margin: 2px 0; min-width: 0; }
867
+
868
+ .dshview-head {
869
+ display: flex; align-items: center; gap: 6px; min-width: 0;
870
+ padding: 2px 0; border: 0; background: transparent; text-align: left;
871
+ color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; line-height: 20px;
872
+ cursor: pointer;
873
+ }
874
+ .dshview-head:hover { color: var(--dsw-alias-label-primary); }
875
+ .dshview-icon { flex: none; display: inline-flex; color: var(--dsw-alias-label-secondary); }
876
+ .dshview-kind { flex: none; color: var(--dsw-alias-label-primary); font-weight: 500; }
877
+ .dshview-path {
878
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
879
+ direction: rtl; text-align: left;
880
+ }
881
+ .dshview-meta { flex: none; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
882
+ .dshview-badge {
883
+ flex: none; padding: 0 6px; border-radius: 999px; font-size: 11px; line-height: 16px;
884
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
885
+ }
886
+
887
+ .dshview-body {
888
+ display: flex; flex-direction: column; gap: 6px;
889
+ padding: 8px; border-radius: 10px;
890
+ border: 1px solid var(--dsw-alias-border-l1);
891
+ background: var(--dsw-alias-bg-layer-1);
892
+ min-width: 0; overflow: hidden;
893
+ }
894
+
895
+ .dshview-imageButton {
896
+ display: block; padding: 0; border: 0; background: transparent; cursor: zoom-in;
897
+ line-height: 0; max-width: 100%;
898
+ }
899
+ /* A transparent PNG on a themed panel is unreadable without a backdrop; the
900
+ checkerboard is the conventional one and reads in both palettes. Shared by
901
+ the card thumbnail and the zoomed preview so the two cannot drift apart. */
902
+ .dshview-image,
903
+ .dshview-lightboxImage {
904
+ background-color: var(--dsw-alias-bg-layer-2);
905
+ background-image:
906
+ linear-gradient(45deg, rgb(128 128 128 / .16) 25%, transparent 25%),
907
+ linear-gradient(-45deg, rgb(128 128 128 / .16) 25%, transparent 25%),
908
+ linear-gradient(45deg, transparent 75%, rgb(128 128 128 / .16) 75%),
909
+ linear-gradient(-45deg, transparent 75%, rgb(128 128 128 / .16) 75%);
910
+ background-size: 16px 16px;
911
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
912
+ }
913
+ .dshview-image {
914
+ display: block; max-width: 100%; max-height: 420px; width: auto; height: auto;
915
+ border-radius: 6px; object-fit: contain;
916
+ }
917
+ .dshview-video { display: block; max-width: 100%; max-height: 420px; border-radius: 6px; background: #000; }
918
+ .dshview-audio { display: block; width: 100%; }
919
+ .dshview-frame {
920
+ display: block; width: 100%; height: 460px; border: 0; border-radius: 6px;
921
+ background: var(--dsw-alias-bg-layer-2);
922
+ }
923
+
924
+ .dshview-note { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
925
+ .dshview-error { color: var(--dsw-alias-state-error-primary); }
926
+ .dshview-retry {
927
+ align-self: flex-start; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 12px;
928
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
929
+ background: transparent; color: var(--dsw-alias-state-error-primary);
930
+ }
931
+ .dshview-link {
932
+ align-self: flex-start; font-size: 12px; color: var(--dsw-alias-brand-primary);
933
+ text-decoration: none;
934
+ }
935
+ .dshview-link:hover { text-decoration: underline; }
936
+ .dshview-action {
937
+ align-self: flex-start; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 12px;
938
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
939
+ background: transparent; color: var(--dsw-alias-brand-primary);
940
+ }
941
+ .dshview-action:hover { background: var(--dsw-alias-bg-layer-2); }
942
+ .dshview-head:focus-visible,
943
+ .dshview-imageButton:focus-visible,
944
+ .dshview-action:focus-visible,
945
+ .dshview-retry:focus-visible,
946
+ .dshview-lightboxClose:focus-visible {
947
+ outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px;
948
+ }
949
+
950
+ /* A completed turn's displays, below its closing reply (see turn-tail.ts). */
951
+ .dshview-tail { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 8px; min-width: 0; }
952
+
953
+ .dshview-lightbox {
954
+ position: fixed; inset: 0; z-index: 2000; display: flex;
955
+ align-items: center; justify-content: center;
956
+ padding: 32px; background: rgb(0 0 0 / .72);
957
+ backdrop-filter: blur(4px);
958
+ cursor: zoom-out;
959
+ }
960
+ .dshview-lightboxImage {
961
+ position: relative;
962
+ max-width: min(100%, 1600px); max-height: calc(100vh - 64px);
963
+ object-fit: contain; border-radius: 8px;
964
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
965
+ cursor: default;
966
+ }
967
+ .dshview-lightboxClose {
968
+ position: fixed; top: 20px; right: 20px; z-index: 2001;
969
+ display: flex; align-items: center; justify-content: center;
970
+ width: 36px; height: 36px; border-radius: 999px;
971
+ border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, rgba(255, 255, 255, 0.2));
972
+ background: var(--dsw-specific-input-major, rgba(0, 0, 0, 0.5));
973
+ color: var(--dsw-alias-label-primary, #fff);
974
+ cursor: pointer;
975
+ transition: background 0.15s ease, transform 0.15s ease;
976
+ }
977
+ /* Hover inverts the button using the alias pair that exists for exactly this:
978
+ --dsw-alias-label-primary-foreground is by definition the readable colour on
979
+ --dsw-alias-label-primary, and the two swap between palettes. A literal black
980
+ here put a near-black glyph on black in the light theme. */
981
+ .dshview-lightboxClose:hover {
982
+ background: var(--dsw-alias-label-primary, #fff);
983
+ color: var(--dsw-alias-label-primary-foreground, #0f1115);
984
+ transform: scale(1.05);
985
+ }
986
+ `;
987
+ function installViewerStyles(ctx) {
988
+ if (typeof document === "undefined") return;
989
+ ctx.effect(() => {
990
+ const tag = document.createElement("style");
991
+ tag.dataset.plugin = PLUGIN_ID;
992
+ tag.dataset.pluginCss = `${PLUGIN_ID}/viewer-card.css`;
993
+ tag.textContent = SHEET;
994
+ document.head.appendChild(tag);
995
+ return () => {
996
+ tag.remove();
997
+ };
998
+ }, "@crosery/dsh-viewer: card stylesheet");
999
+ }
1000
+
1001
+ // src/client/registration.ts
1002
+ var VIEWER_NS = "tool.viewer";
1003
+ var READ_IMAGE_PRIORITY = 1;
1004
+ var TURN_TAIL_SLOT = "conversation.chat.turnTail";
1005
+ function contribute(slots, key, register, warn = console.warn) {
1006
+ slots.inject(key, () => {
1007
+ try {
1008
+ return register();
1009
+ } catch (error) {
1010
+ warn(`[dsh-viewer] could not register into "${key}"; the rest of the viewer is unaffected`, error);
1011
+ return () => {
1012
+ };
1013
+ }
1014
+ });
1015
+ }
1016
+ function turnTailJoinable(spec) {
1017
+ return spec?.kind === void 0 || spec.kind === "list";
1018
+ }
1019
+
1020
+ // src/client/index.ts
1021
+ var AttachmentUrls = class {
1022
+ /** @param ctx - client context used to reach the sessions service. */
1023
+ constructor(ctx) {
1024
+ this.ctx = ctx;
1025
+ }
1026
+ ctx;
1027
+ pending = /* @__PURE__ */ new Map();
1028
+ created = /* @__PURE__ */ new Set();
1029
+ disposed = false;
1030
+ /**
1031
+ * Resolve one attachment to a URL this page can load.
1032
+ * @param sessionId - the session authorizing the read.
1033
+ * @param attachmentId - the opaque durable id.
1034
+ * @returns a URL valid until this plugin unloads.
1035
+ */
1036
+ resolve(sessionId, attachmentId) {
1037
+ if (this.disposed) return Promise.reject(new Error("dsh-viewer: the plugin was unloaded"));
1038
+ const key = `${sessionId}:${attachmentId}`;
1039
+ const cached = this.pending.get(key);
1040
+ if (cached !== void 0) return cached;
1041
+ const sessions = this.ctx.get("sessions");
1042
+ const session = sessions?.binding(sessionId)?.session;
1043
+ if (session === void 0) return Promise.reject(new Error(`dsh-viewer: unknown session "${sessionId}"`));
1044
+ const request = session.readAttachment(attachmentId).then((result) => {
1045
+ if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
1046
+ if (this.disposed) throw new Error("dsh-viewer: the plugin unloaded before the image arrived");
1047
+ const { data, attachment } = result.value;
1048
+ if (typeof URL.createObjectURL !== "function") {
1049
+ return `data:${attachment.mediaType};base64,${base64Of(data)}`;
1050
+ }
1051
+ const bytes = Uint8Array.from(data);
1052
+ const url = URL.createObjectURL(new Blob([bytes.buffer], { type: attachment.mediaType }));
1053
+ this.created.add(url);
1054
+ return url;
1055
+ }).catch((error) => {
1056
+ this.pending.delete(key);
1057
+ throw error;
1058
+ });
1059
+ this.pending.set(key, request);
1060
+ return request;
1061
+ }
1062
+ /** Revoke every URL this cache minted. */
1063
+ dispose() {
1064
+ this.disposed = true;
1065
+ this.pending.clear();
1066
+ for (const url of this.created) URL.revokeObjectURL(url);
1067
+ this.created.clear();
1068
+ }
1069
+ };
1070
+ function base64Of(data) {
1071
+ let binary = "";
1072
+ const chunk = 32768;
1073
+ for (let offset = 0; offset < data.length; offset += chunk) {
1074
+ binary += String.fromCharCode(...data.subarray(offset, offset + chunk));
1075
+ }
1076
+ return btoa(binary);
1077
+ }
1078
+ var inject = ["slots", "locale"];
1079
+ var name = "@crosery/dsh-viewer";
1080
+ function apply(ctx) {
1081
+ installViewerStyles(ctx);
1082
+ const urls = new AttachmentUrls(ctx);
1083
+ ctx.effect(() => () => {
1084
+ urls.dispose();
1085
+ }, "@crosery/dsh-viewer: attachment URLs");
1086
+ ctx.effect(() => ctx.locale.register(VIEWER_NS, { zh, en }), "@crosery/dsh-viewer: card dictionaries");
1087
+ const injected = (sessionId) => ({
1088
+ loadAttachment: (attachmentId) => urls.resolve(sessionId, attachmentId)
1089
+ });
1090
+ const slots = ctx.slots;
1091
+ contribute(slots, "tool.call.toolview", () => ctx.slots.register({
1092
+ name: "tool.call.toolview",
1093
+ key: DISPLAY_TOOL,
1094
+ locale: VIEWER_NS,
1095
+ inject: injected
1096
+ }, ViewerCard));
1097
+ contribute(slots, "tool.call.toolview", () => ctx.slots.register({
1098
+ name: "tool.call.toolview",
1099
+ key: READ_IMAGE_TOOL,
1100
+ priority: READ_IMAGE_PRIORITY,
1101
+ locale: VIEWER_NS,
1102
+ inject: injected
1103
+ }, ViewerCard));
1104
+ const folds = foldSourceOf(() => ctx.get("configForms"));
1105
+ ctx.inject(["uiConversation"], (scoped) => {
1106
+ const conversation = scoped.uiConversation;
1107
+ const register = conversation?.events?.register;
1108
+ if (typeof register !== "function") return;
1109
+ const [own, ...supplementary] = viewerTurnDefinitions;
1110
+ try {
1111
+ register.call(conversation?.events, own);
1112
+ } catch (error) {
1113
+ console.warn("[dsh-viewer] could not register the turn-tail data; completed turns keep their displays folded", error);
1114
+ return;
1115
+ }
1116
+ for (const definition of supplementary) {
1117
+ try {
1118
+ register.call(conversation?.events, definition);
1119
+ } catch (error) {
1120
+ console.warn(`[dsh-viewer] could not register ${definition.kind}; the turn tail may miss some displays`, error);
1121
+ }
1122
+ }
1123
+ const scopedSlots = scoped.slots;
1124
+ contribute(scopedSlots, TURN_TAIL_SLOT, () => {
1125
+ if (!turnTailJoinable(scopedSlots.spec?.(TURN_TAIL_SLOT))) return () => {
1126
+ };
1127
+ return scopedSlots.register({
1128
+ name: TURN_TAIL_SLOT,
1129
+ id: name,
1130
+ locale: VIEWER_NS,
1131
+ inject: (sessionId) => ({ ...injected(sessionId), folds })
1132
+ }, ViewerTail);
1133
+ });
1134
+ });
1135
+ }
1136
+ return module.exports; } });