@volter-ai-dev/supercode-ui 0.1.23 → 0.1.24

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/README.md CHANGED
@@ -134,8 +134,12 @@ is exposed and must revalidate every returned item.
134
134
 
135
135
  Transcript images with a browser-safe URL open in a keyboard-accessible, viewport-bounded viewer;
136
136
  remote images expose copy-link and open-original actions, while local data images can be downloaded.
137
- If a host deliberately omits a large historical data URL from its bounded projection, the UI shows
138
- an honest unavailable-preview attachment instead of a broken thumbnail or a deceptive action.
137
+ `createClientProjection` keeps large historical data URLs in a projection-scoped host registry and
138
+ puts only stable metadata plus an opaque `reference` in `SupercodeUiState`. An embedding host may
139
+ implement `adapter.resolveImage` to fetch that reference only after a click and return a bounded
140
+ `Blob`; the viewer owns and revokes the resulting object URL and presents loading, failure, and
141
+ retry states. Without that adapter—or when native data is incomplete—the attachment remains an
142
+ honest unavailable-preview state rather than a broken thumbnail or deceptive action.
139
143
 
140
144
  An embedding product such as Vibewaiting should therefore be small: Lucarne owns its iframe and
141
145
  launcher lifecycle, Supercode owns this UI and the controller semantics, and Vibewaiting only
package/components.mjs CHANGED
@@ -507,7 +507,10 @@ function readTranscript(value) {
507
507
  return image && typeof image.label === "string" ? [{
508
508
  ...typeof image.id === "string" ? { id: image.id } : {},
509
509
  label: image.label,
510
- ...typeof image.url === "string" ? { url: image.url } : {}
510
+ ...typeof image.url === "string" ? { url: image.url } : {},
511
+ ...typeof image.reference === "string" && image.reference.length <= 4e3 ? { reference: image.reference } : {},
512
+ ...typeof image.mediaType === "string" && image.mediaType.startsWith("image/") && image.mediaType.length <= 100 ? { mediaType: image.mediaType } : {},
513
+ ...Number.isSafeInteger(image.byteSize) && image.byteSize >= 0 ? { byteSize: image.byteSize } : {}
511
514
  }] : [];
512
515
  }).slice(0, 4);
513
516
  }
@@ -820,6 +823,7 @@ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-ru
820
823
  var MAX_CONTEXT_ITEMS = 32;
821
824
  var MAX_IMAGE_ITEMS = 4;
822
825
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
826
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
823
827
  var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
824
828
  function normalizeContext(value) {
825
829
  return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
@@ -922,22 +926,59 @@ function imageFilename(label) {
922
926
  function ImageViewer({ items, index, adapter, onChange, onClose }) {
923
927
  const dialog = useRef(null);
924
928
  const reset = useRef(null);
929
+ const resolutions = useRef(/* @__PURE__ */ new Map());
930
+ const alive = useRef(true);
931
+ const [, redraw] = useState(0);
925
932
  const [copyState, setCopyState2] = useState("idle");
926
933
  const item = items[index];
927
- const remote = item?.url?.startsWith("http://") || item?.url?.startsWith("https://");
934
+ const key = item?.reference ?? item?.url ?? `${item?.id ?? ""}:${index}`;
935
+ const resolution = item?.url ? null : resolutions.current.get(key);
936
+ const imageUrl = item?.url ?? (resolution?.status === "ready" ? resolution.url : null);
937
+ const remote = imageUrl?.startsWith("http://") || imageUrl?.startsWith("https://");
938
+ const resolve = (candidate, force = false) => {
939
+ if (candidate?.url || !candidate?.reference || !adapter?.resolveImage) return;
940
+ const candidateKey = candidate.reference;
941
+ const current = resolutions.current.get(candidateKey);
942
+ if (!force && (current?.status === "loading" || current?.status === "ready")) return;
943
+ if (current?.url) URL.revokeObjectURL(current.url);
944
+ resolutions.current.set(candidateKey, { status: "loading" });
945
+ redraw((value) => value + 1);
946
+ Promise.resolve().then(() => adapter.resolveImage(candidate)).then((blob) => {
947
+ if (!(blob instanceof Blob) || !blob.type.startsWith("image/")) throw new Error("The host returned an invalid image.");
948
+ if (blob.size > MAX_RESOLVED_IMAGE_BYTES) throw new Error("This image is too large to preview safely.");
949
+ if (!alive.current) return;
950
+ const url = URL.createObjectURL(blob);
951
+ resolutions.current.set(candidateKey, { status: "ready", url });
952
+ redraw((value) => value + 1);
953
+ }).catch((error) => {
954
+ if (!alive.current) return;
955
+ resolutions.current.set(candidateKey, {
956
+ status: "error",
957
+ message: error instanceof Error && error.message ? error.message : "Could not load this image."
958
+ });
959
+ redraw((value) => value + 1);
960
+ });
961
+ };
928
962
  useEffect(() => {
963
+ alive.current = true;
929
964
  if (!dialog.current?.open) dialog.current?.showModal();
930
- return () => clearTimeout(reset.current);
965
+ return () => {
966
+ alive.current = false;
967
+ clearTimeout(reset.current);
968
+ for (const value of resolutions.current.values()) if (value.url) URL.revokeObjectURL(value.url);
969
+ resolutions.current.clear();
970
+ };
931
971
  }, []);
932
972
  useEffect(() => {
933
973
  clearTimeout(reset.current);
934
974
  setCopyState2("idle");
935
- }, [index]);
936
- if (!item?.url) return null;
975
+ resolve(item);
976
+ }, [index, item?.reference]);
977
+ if (!item || !item.url && (!item.reference || !adapter?.resolveImage)) return null;
937
978
  const move = (amount) => onChange((index + amount + items.length) % items.length);
938
979
  const copy = async () => {
939
980
  try {
940
- await adapter.copyText(item.url);
981
+ await adapter.copyText(imageUrl);
941
982
  setCopyState2("copied");
942
983
  } catch {
943
984
  setCopyState2("failed");
@@ -973,12 +1014,21 @@ function ImageViewer({ items, index, adapter, onChange, onClose }) {
973
1014
  ] }),
974
1015
  /* @__PURE__ */ jsxs2("nav", { "aria-label": "Image actions", children: [
975
1016
  remote && adapter?.copyText ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": copyState === "copied" ? "Image link copied" : copyState === "failed" ? "Could not copy image link" : "Copy image link", title: "Copy image link", "data-status": copyState, onClick: copy, children: /* @__PURE__ */ jsx2(UiIcon, { name: copyState === "copied" ? "check" : "copy", size: 16 }) }) : null,
976
- remote ? /* @__PURE__ */ jsx2("a", { href: item.url, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx2("a", { href: item.url, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "down", size: 16 }) }),
1017
+ imageUrl ? remote ? /* @__PURE__ */ jsx2("a", { href: imageUrl, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx2("a", { href: imageUrl, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "down", size: 16 }) }) : null,
977
1018
  /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Close image preview", title: "Close", onClick: close, children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 17 }) })
978
1019
  ] })
979
1020
  ] }),
980
1021
  /* @__PURE__ */ jsxs2("figure", { children: [
981
- /* @__PURE__ */ jsx2("img", { src: item.url, alt: item.label }),
1022
+ imageUrl ? /* @__PURE__ */ jsx2("img", { src: imageUrl, alt: item.label }) : resolution?.status === "error" ? /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "alert", children: [
1023
+ /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 28 }),
1024
+ /* @__PURE__ */ jsx2("strong", { children: "Could not load image" }),
1025
+ /* @__PURE__ */ jsx2("small", { children: resolution.message }),
1026
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => resolve(item, true), children: "Retry" })
1027
+ ] }) : /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "status", children: [
1028
+ /* @__PURE__ */ jsx2("i", { class: "scui-control-spinner" }),
1029
+ /* @__PURE__ */ jsx2("strong", { children: "Loading image\u2026" }),
1030
+ /* @__PURE__ */ jsx2("small", { children: "The original stays out of the transcript payload." })
1031
+ ] }),
982
1032
  items.length > 1 ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
983
1033
  /* @__PURE__ */ jsx2("button", { type: "button", class: "scui-image-previous", "aria-label": "Previous image", onClick: () => move(-1), children: /* @__PURE__ */ jsx2(UiIcon, { name: "chevron", size: 19 }) }),
984
1034
  /* @__PURE__ */ jsx2("button", { type: "button", class: "scui-image-next", "aria-label": "Next image", onClick: () => move(1), children: /* @__PURE__ */ jsx2(UiIcon, { name: "chevron", size: 19 }) })
@@ -992,7 +1042,7 @@ function MessageImages({ items, adapter }) {
992
1042
  const [active, setActive] = useState(null);
993
1043
  const opener = useRef(null);
994
1044
  if (!items?.length) return null;
995
- const viewable = items.filter((item) => item.url);
1045
+ const viewable = items.filter((item) => item.url || item.reference && adapter?.resolveImage);
996
1046
  const close = () => {
997
1047
  setActive(null);
998
1048
  requestAnimationFrame(() => opener.current?.focus({ preventScroll: true }));
@@ -1001,7 +1051,16 @@ function MessageImages({ items, adapter }) {
1001
1051
  /* @__PURE__ */ jsx2("div", { class: "scui-message-images", "aria-label": "Message images", children: items.map((item, index) => item.url ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `View image ${item.label}`, onClick: (event) => {
1002
1052
  opener.current = event.currentTarget;
1003
1053
  setActive(viewable.indexOf(item));
1004
- }, children: /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
1054
+ }, children: /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : item.reference && adapter?.resolveImage ? /* @__PURE__ */ jsxs2("button", { type: "button", "data-lazy": "true", "aria-label": `Load image ${item.label}`, onClick: (event) => {
1055
+ opener.current = event.currentTarget;
1056
+ setActive(viewable.indexOf(item));
1057
+ }, children: [
1058
+ /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }),
1059
+ /* @__PURE__ */ jsxs2("span", { children: [
1060
+ /* @__PURE__ */ jsx2("strong", { children: item.label }),
1061
+ /* @__PURE__ */ jsx2("small", { children: "Load preview" })
1062
+ ] })
1063
+ ] }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
1005
1064
  /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 14 }),
1006
1065
  /* @__PURE__ */ jsxs2("span", { children: [
1007
1066
  /* @__PURE__ */ jsx2("strong", { children: item.label }),
package/composer.mjs CHANGED
@@ -134,6 +134,7 @@ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-ru
134
134
  var MAX_CONTEXT_ITEMS = 32;
135
135
  var MAX_IMAGE_ITEMS = 4;
136
136
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
137
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
137
138
  var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
138
139
  function normalizeContext(value) {
139
140
  return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
package/controller.d.ts CHANGED
@@ -52,6 +52,25 @@ export function projectClientSnapshot(
52
52
  options?: ClientProjectionOptions,
53
53
  ): SupercodeUiState;
54
54
 
55
+ export interface ResolvableTranscriptImage {
56
+ id?: string;
57
+ label: string;
58
+ url: string;
59
+ mediaType?: string;
60
+ byteSize?: number;
61
+ }
62
+
63
+ export interface ClientProjection {
64
+ state: SupercodeUiState;
65
+ /** Returns only an image admitted to this projection's bounded transcript window. */
66
+ resolveImage(reference: string): ResolvableTranscriptImage | null;
67
+ }
68
+
69
+ export function createClientProjection(
70
+ snapshot: SupercodeClientSnapshot,
71
+ options?: ClientProjectionOptions,
72
+ ): ClientProjection;
73
+
55
74
  export interface ControllerBindingOptions {
56
75
  projection?: () => ClientProjectionOptions;
57
76
  onIntent?: (intent: SupercodeUiIntent) => void | Promise<void>;
@@ -63,6 +82,7 @@ export interface ControllerBindingOptions {
63
82
  onLoadSessions?: () => void | Promise<void>;
64
83
  onLoadEarlier?: () => void | Promise<void>;
65
84
  copyText?: UiAdapter['copyText'];
85
+ resolveImage?: UiAdapter['resolveImage'];
66
86
  }
67
87
 
68
88
  export interface SupercodeUiBinding {
package/controller.mjs CHANGED
@@ -99,18 +99,53 @@ function projectContext(context) {
99
99
  return projected.length ? projected : undefined;
100
100
  }
101
101
 
102
- function projectImages(images) {
102
+ function imageMetadata(url) {
103
+ if (!url.startsWith('data:image/')) return {};
104
+ const header = url.slice(5, 256);
105
+ const comma = header.indexOf(',');
106
+ if (comma < 0) return {};
107
+ const declaration = header.slice(0, comma);
108
+ const mediaType = declaration.split(';', 1)[0].toLowerCase();
109
+ if (!mediaType.startsWith('image/')) return {};
110
+ if (!declaration.toLowerCase().endsWith(';base64')) return { mediaType };
111
+ const payloadLength = url.length - 5 - comma - 1;
112
+ const padding = url.endsWith('==') ? 2 : url.endsWith('=') ? 1 : 0;
113
+ return { mediaType, byteSize: Math.max(0, Math.floor(payloadLength * 3 / 4) - padding) };
114
+ }
115
+
116
+ function imageReference(entryId, item, index) {
117
+ // The host treats this only as a key into the projection-scoped registry below. Keeping the key
118
+ // deterministic prevents a streamed transcript refresh from restarting an image already open in
119
+ // the viewer, while the registry still rejects references outside the admitted render window.
120
+ return JSON.stringify([entryId, typeof item.id === 'string' ? item.id : null, index]);
121
+ }
122
+
123
+ function projectImages(images, entryId, imageRegistry) {
103
124
  if (!Array.isArray(images)) return undefined;
104
- const projected = images.flatMap((item) => {
125
+ const projected = images.flatMap((item, index) => {
105
126
  if (!item || typeof item !== 'object' || typeof item.label !== 'string') return [];
106
- const url = typeof item.url === 'string' && !item.url.endsWith('\n…')
107
- && (!item.url.startsWith('data:image/') || item.url.length <= 256_000)
108
- ? item.url
127
+ const sourceUrl = typeof item.url === 'string' ? item.url : null;
128
+ const complete = sourceUrl !== null && !sourceUrl.endsWith('\n…');
129
+ const metadata = complete ? imageMetadata(sourceUrl) : {};
130
+ const url = complete && (!sourceUrl.startsWith('data:image/') || sourceUrl.length <= 256_000)
131
+ ? sourceUrl
109
132
  : null;
133
+ let reference = null;
134
+ if (complete && sourceUrl.startsWith('data:image/') && !url && imageRegistry && Number.isSafeInteger(metadata.byteSize)) {
135
+ reference = imageReference(entryId, item, index);
136
+ imageRegistry.set(reference, {
137
+ ...(typeof item.id === 'string' ? { id: item.id } : {}),
138
+ label: item.label,
139
+ url: sourceUrl,
140
+ ...metadata,
141
+ });
142
+ }
110
143
  return [{
111
144
  ...(typeof item.id === 'string' ? { id: item.id } : {}),
112
145
  label: item.label,
146
+ ...metadata,
113
147
  ...(url ? { url } : {}),
148
+ ...(reference ? { reference } : {}),
114
149
  }];
115
150
  }).slice(0, 4);
116
151
  return projected.length ? projected : undefined;
@@ -124,13 +159,13 @@ function requestSummary(entry) {
124
159
  return options ? `${entry.requestKind}: ${options}${resolution}` : `${entry.requestKind ?? 'request'}${resolution}`;
125
160
  }
126
161
 
127
- function projectConversationEntry(entry, maxEntryChars) {
162
+ function projectConversationEntry(entry, maxEntryChars, imageRegistry) {
128
163
  if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string') return null;
129
164
  if (entry.kind === 'message') {
130
165
  if (entry.visibility === 'context') return null;
131
166
  const body = truncate(entry.text, maxEntryChars);
132
167
  const context = projectContext(entry.context);
133
- const images = projectImages(entry.images);
168
+ const images = projectImages(entry.images, entry.id, imageRegistry);
134
169
  return {
135
170
  id: entry.id,
136
171
  role: entry.role,
@@ -201,7 +236,7 @@ function projectConversationEntry(entry, maxEntryChars) {
201
236
  return null;
202
237
  }
203
238
 
204
- function projectConversation(conversation, options) {
239
+ function projectConversation(conversation, options, imageRegistry) {
205
240
  if (!Array.isArray(conversation)) return [];
206
241
  const maxEntries = positiveInteger(options.maxEntries, DEFAULT_MAX_ENTRIES);
207
242
  const maxEntryChars = positiveInteger(options.maxEntryChars, DEFAULT_MAX_ENTRY_CHARS);
@@ -215,7 +250,7 @@ function projectConversation(conversation, options) {
215
250
  for (;
216
251
  index >= 0 && rows.length < maxEntries && scanned < maxScanEntries;
217
252
  index -= 1, scanned += 1) {
218
- const row = projectConversationEntry(conversation[index], maxEntryChars);
253
+ const row = projectConversationEntry(conversation[index], maxEntryChars, imageRegistry);
219
254
  if (row) rows.push(row);
220
255
  }
221
256
 
@@ -229,7 +264,7 @@ function projectConversation(conversation, options) {
229
264
  rows.length = oldestVisibleUser + 1;
230
265
  } else {
231
266
  for (; index >= 0 && scanned < maxScanEntries; index -= 1, scanned += 1) {
232
- const row = projectConversationEntry(conversation[index], maxEntryChars);
267
+ const row = projectConversationEntry(conversation[index], maxEntryChars, imageRegistry);
233
268
  if (row?.role !== 'user') continue;
234
269
  if (rows.length === maxEntries) rows[rows.length - 1] = row;
235
270
  else rows.push(row);
@@ -300,7 +335,7 @@ function projectPill(snapshot) {
300
335
  return { tone: 'live', label: text(`${label(harness)} ready`) };
301
336
  }
302
337
 
303
- export function projectClientSnapshot(snapshot, options = {}) {
338
+ function projectClientSnapshotInternal(snapshot, options, imageRegistry) {
304
339
  const now = Number.isFinite(options.now) ? options.now : Date.now();
305
340
  const busy = ['running', 'interrupting', 'reconciling'].includes(snapshot.turn?.state);
306
341
  const actions = snapshot.availableActions ?? {};
@@ -325,7 +360,7 @@ export function projectClientSnapshot(snapshot, options = {}) {
325
360
  ? snapshot.connection?.ownsRuntime ? active : null
326
361
  : options.owned;
327
362
  const maxEntries = positiveInteger(options.maxEntries, DEFAULT_MAX_ENTRIES);
328
- const transcript = projectConversation(snapshot.conversation ?? [], options);
363
+ const transcript = projectConversation(snapshot.conversation ?? [], options, imageRegistry);
329
364
  const startup = snapshot.availability === 'loading'
330
365
  ? snapshot.operation === 'start' ? 'starting' : snapshot.operation === 'refresh' ? 'discovering' : 'connecting'
331
366
  : 'ready';
@@ -390,6 +425,27 @@ export function projectClientSnapshot(snapshot, options = {}) {
390
425
  });
391
426
  }
392
427
 
428
+ export function projectClientSnapshot(snapshot, options = {}) {
429
+ return projectClientSnapshotInternal(snapshot, options, null);
430
+ }
431
+
432
+ /**
433
+ * Project a bounded UI state together with a host-only resolver for data images omitted from that
434
+ * state. The resolver knows only the images admitted by this exact projection, so a browser can
435
+ * neither force a whole-history scan nor use a guessed reference to read an unrelated session.
436
+ */
437
+ export function createClientProjection(snapshot, options = {}) {
438
+ const images = new Map();
439
+ const state = projectClientSnapshotInternal(snapshot, options, images);
440
+ return {
441
+ state,
442
+ resolveImage(reference) {
443
+ const image = typeof reference === 'string' ? images.get(reference) : null;
444
+ return image ? { ...image } : null;
445
+ },
446
+ };
447
+ }
448
+
393
449
  async function dispatchStandard(controller, intent, options) {
394
450
  const snapshot = controller.getSnapshot();
395
451
  const active = snapshot.activeSessionKey;
@@ -433,6 +489,7 @@ export function createControllerBinding(controller, options = {}) {
433
489
  .then(() => undefined);
434
490
  },
435
491
  ...(options.copyText ? { copyText: options.copyText } : {}),
492
+ ...(options.resolveImage ? { resolveImage: options.resolveImage } : {}),
436
493
  };
437
494
  return {
438
495
  adapter,
package/conversation.mjs CHANGED
@@ -625,6 +625,7 @@ function UiIcon({ name, size = 16, class: className = "" }) {
625
625
  import { useEffect as useEffect2, useRef as useRef2, useState } from "preact/hooks";
626
626
  import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "preact/jsx-runtime";
627
627
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
628
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
628
629
  function imageFilename(label) {
629
630
  const value = label.trim().replace(/[\\/:*?"<>|]+/g, "-");
630
631
  return value || "image";
@@ -632,22 +633,59 @@ function imageFilename(label) {
632
633
  function ImageViewer({ items, index, adapter, onChange, onClose }) {
633
634
  const dialog = useRef2(null);
634
635
  const reset = useRef2(null);
636
+ const resolutions = useRef2(/* @__PURE__ */ new Map());
637
+ const alive = useRef2(true);
638
+ const [, redraw] = useState(0);
635
639
  const [copyState, setCopyState2] = useState("idle");
636
640
  const item = items[index];
637
- const remote = item?.url?.startsWith("http://") || item?.url?.startsWith("https://");
641
+ const key = item?.reference ?? item?.url ?? `${item?.id ?? ""}:${index}`;
642
+ const resolution = item?.url ? null : resolutions.current.get(key);
643
+ const imageUrl = item?.url ?? (resolution?.status === "ready" ? resolution.url : null);
644
+ const remote = imageUrl?.startsWith("http://") || imageUrl?.startsWith("https://");
645
+ const resolve = (candidate, force = false) => {
646
+ if (candidate?.url || !candidate?.reference || !adapter?.resolveImage) return;
647
+ const candidateKey = candidate.reference;
648
+ const current = resolutions.current.get(candidateKey);
649
+ if (!force && (current?.status === "loading" || current?.status === "ready")) return;
650
+ if (current?.url) URL.revokeObjectURL(current.url);
651
+ resolutions.current.set(candidateKey, { status: "loading" });
652
+ redraw((value) => value + 1);
653
+ Promise.resolve().then(() => adapter.resolveImage(candidate)).then((blob) => {
654
+ if (!(blob instanceof Blob) || !blob.type.startsWith("image/")) throw new Error("The host returned an invalid image.");
655
+ if (blob.size > MAX_RESOLVED_IMAGE_BYTES) throw new Error("This image is too large to preview safely.");
656
+ if (!alive.current) return;
657
+ const url = URL.createObjectURL(blob);
658
+ resolutions.current.set(candidateKey, { status: "ready", url });
659
+ redraw((value) => value + 1);
660
+ }).catch((error) => {
661
+ if (!alive.current) return;
662
+ resolutions.current.set(candidateKey, {
663
+ status: "error",
664
+ message: error instanceof Error && error.message ? error.message : "Could not load this image."
665
+ });
666
+ redraw((value) => value + 1);
667
+ });
668
+ };
638
669
  useEffect2(() => {
670
+ alive.current = true;
639
671
  if (!dialog.current?.open) dialog.current?.showModal();
640
- return () => clearTimeout(reset.current);
672
+ return () => {
673
+ alive.current = false;
674
+ clearTimeout(reset.current);
675
+ for (const value of resolutions.current.values()) if (value.url) URL.revokeObjectURL(value.url);
676
+ resolutions.current.clear();
677
+ };
641
678
  }, []);
642
679
  useEffect2(() => {
643
680
  clearTimeout(reset.current);
644
681
  setCopyState2("idle");
645
- }, [index]);
646
- if (!item?.url) return null;
682
+ resolve(item);
683
+ }, [index, item?.reference]);
684
+ if (!item || !item.url && (!item.reference || !adapter?.resolveImage)) return null;
647
685
  const move = (amount) => onChange((index + amount + items.length) % items.length);
648
686
  const copy = async () => {
649
687
  try {
650
- await adapter.copyText(item.url);
688
+ await adapter.copyText(imageUrl);
651
689
  setCopyState2("copied");
652
690
  } catch {
653
691
  setCopyState2("failed");
@@ -683,12 +721,21 @@ function ImageViewer({ items, index, adapter, onChange, onClose }) {
683
721
  ] }),
684
722
  /* @__PURE__ */ jsxs2("nav", { "aria-label": "Image actions", children: [
685
723
  remote && adapter?.copyText ? /* @__PURE__ */ jsx3("button", { type: "button", "aria-label": copyState === "copied" ? "Image link copied" : copyState === "failed" ? "Could not copy image link" : "Copy image link", title: "Copy image link", "data-status": copyState, onClick: copy, children: /* @__PURE__ */ jsx3(UiIcon, { name: copyState === "copied" ? "check" : "copy", size: 16 }) }) : null,
686
- remote ? /* @__PURE__ */ jsx3("a", { href: item.url, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx3(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx3("a", { href: item.url, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx3(UiIcon, { name: "down", size: 16 }) }),
724
+ imageUrl ? remote ? /* @__PURE__ */ jsx3("a", { href: imageUrl, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx3(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx3("a", { href: imageUrl, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx3(UiIcon, { name: "down", size: 16 }) }) : null,
687
725
  /* @__PURE__ */ jsx3("button", { type: "button", "aria-label": "Close image preview", title: "Close", onClick: close, children: /* @__PURE__ */ jsx3(UiIcon, { name: "close", size: 17 }) })
688
726
  ] })
689
727
  ] }),
690
728
  /* @__PURE__ */ jsxs2("figure", { children: [
691
- /* @__PURE__ */ jsx3("img", { src: item.url, alt: item.label }),
729
+ imageUrl ? /* @__PURE__ */ jsx3("img", { src: imageUrl, alt: item.label }) : resolution?.status === "error" ? /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "alert", children: [
730
+ /* @__PURE__ */ jsx3(UiIcon, { name: "image", size: 28 }),
731
+ /* @__PURE__ */ jsx3("strong", { children: "Could not load image" }),
732
+ /* @__PURE__ */ jsx3("small", { children: resolution.message }),
733
+ /* @__PURE__ */ jsx3("button", { type: "button", onClick: () => resolve(item, true), children: "Retry" })
734
+ ] }) : /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "status", children: [
735
+ /* @__PURE__ */ jsx3("i", { class: "scui-control-spinner" }),
736
+ /* @__PURE__ */ jsx3("strong", { children: "Loading image\u2026" }),
737
+ /* @__PURE__ */ jsx3("small", { children: "The original stays out of the transcript payload." })
738
+ ] }),
692
739
  items.length > 1 ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
693
740
  /* @__PURE__ */ jsx3("button", { type: "button", class: "scui-image-previous", "aria-label": "Previous image", onClick: () => move(-1), children: /* @__PURE__ */ jsx3(UiIcon, { name: "chevron", size: 19 }) }),
694
741
  /* @__PURE__ */ jsx3("button", { type: "button", class: "scui-image-next", "aria-label": "Next image", onClick: () => move(1), children: /* @__PURE__ */ jsx3(UiIcon, { name: "chevron", size: 19 }) })
@@ -702,7 +749,7 @@ function MessageImages({ items, adapter }) {
702
749
  const [active, setActive] = useState(null);
703
750
  const opener = useRef2(null);
704
751
  if (!items?.length) return null;
705
- const viewable = items.filter((item) => item.url);
752
+ const viewable = items.filter((item) => item.url || item.reference && adapter?.resolveImage);
706
753
  const close = () => {
707
754
  setActive(null);
708
755
  requestAnimationFrame(() => opener.current?.focus({ preventScroll: true }));
@@ -711,7 +758,16 @@ function MessageImages({ items, adapter }) {
711
758
  /* @__PURE__ */ jsx3("div", { class: "scui-message-images", "aria-label": "Message images", children: items.map((item, index) => item.url ? /* @__PURE__ */ jsx3("button", { type: "button", "aria-label": `View image ${item.label}`, onClick: (event) => {
712
759
  opener.current = event.currentTarget;
713
760
  setActive(viewable.indexOf(item));
714
- }, children: /* @__PURE__ */ jsx3("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
761
+ }, children: /* @__PURE__ */ jsx3("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : item.reference && adapter?.resolveImage ? /* @__PURE__ */ jsxs2("button", { type: "button", "data-lazy": "true", "aria-label": `Load image ${item.label}`, onClick: (event) => {
762
+ opener.current = event.currentTarget;
763
+ setActive(viewable.indexOf(item));
764
+ }, children: [
765
+ /* @__PURE__ */ jsx3(UiIcon, { name: "image", size: 16 }),
766
+ /* @__PURE__ */ jsxs2("span", { children: [
767
+ /* @__PURE__ */ jsx3("strong", { children: item.label }),
768
+ /* @__PURE__ */ jsx3("small", { children: "Load preview" })
769
+ ] })
770
+ ] }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
715
771
  /* @__PURE__ */ jsx3(UiIcon, { name: "image", size: 14 }),
716
772
  /* @__PURE__ */ jsxs2("span", { children: [
717
773
  /* @__PURE__ */ jsx3("strong", { children: item.label }),
package/core.mjs CHANGED
@@ -499,6 +499,9 @@ function readTranscript(value) {
499
499
  ...(typeof image.id === 'string' ? { id: image.id } : {}),
500
500
  label: image.label,
501
501
  ...(typeof image.url === 'string' ? { url: image.url } : {}),
502
+ ...(typeof image.reference === 'string' && image.reference.length <= 4_000 ? { reference: image.reference } : {}),
503
+ ...(typeof image.mediaType === 'string' && image.mediaType.startsWith('image/') && image.mediaType.length <= 100 ? { mediaType: image.mediaType } : {}),
504
+ ...(Number.isSafeInteger(image.byteSize) && image.byteSize >= 0 ? { byteSize: image.byteSize } : {}),
502
505
  }]
503
506
  : [];
504
507
  }).slice(0, 4);
package/embed.mjs CHANGED
@@ -510,7 +510,10 @@ function readTranscript(value) {
510
510
  return image && typeof image.label === "string" ? [{
511
511
  ...typeof image.id === "string" ? { id: image.id } : {},
512
512
  label: image.label,
513
- ...typeof image.url === "string" ? { url: image.url } : {}
513
+ ...typeof image.url === "string" ? { url: image.url } : {},
514
+ ...typeof image.reference === "string" && image.reference.length <= 4e3 ? { reference: image.reference } : {},
515
+ ...typeof image.mediaType === "string" && image.mediaType.startsWith("image/") && image.mediaType.length <= 100 ? { mediaType: image.mediaType } : {},
516
+ ...Number.isSafeInteger(image.byteSize) && image.byteSize >= 0 ? { byteSize: image.byteSize } : {}
514
517
  }] : [];
515
518
  }).slice(0, 4);
516
519
  }
@@ -826,6 +829,7 @@ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-ru
826
829
  var MAX_CONTEXT_ITEMS = 32;
827
830
  var MAX_IMAGE_ITEMS = 4;
828
831
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
832
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
829
833
  var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
830
834
  function normalizeContext(value) {
831
835
  return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
@@ -928,22 +932,59 @@ function imageFilename(label) {
928
932
  function ImageViewer({ items, index, adapter, onChange, onClose }) {
929
933
  const dialog = useRef(null);
930
934
  const reset = useRef(null);
935
+ const resolutions = useRef(/* @__PURE__ */ new Map());
936
+ const alive = useRef(true);
937
+ const [, redraw] = useState(0);
931
938
  const [copyState, setCopyState2] = useState("idle");
932
939
  const item = items[index];
933
- const remote = item?.url?.startsWith("http://") || item?.url?.startsWith("https://");
940
+ const key = item?.reference ?? item?.url ?? `${item?.id ?? ""}:${index}`;
941
+ const resolution = item?.url ? null : resolutions.current.get(key);
942
+ const imageUrl = item?.url ?? (resolution?.status === "ready" ? resolution.url : null);
943
+ const remote = imageUrl?.startsWith("http://") || imageUrl?.startsWith("https://");
944
+ const resolve = (candidate, force = false) => {
945
+ if (candidate?.url || !candidate?.reference || !adapter?.resolveImage) return;
946
+ const candidateKey = candidate.reference;
947
+ const current = resolutions.current.get(candidateKey);
948
+ if (!force && (current?.status === "loading" || current?.status === "ready")) return;
949
+ if (current?.url) URL.revokeObjectURL(current.url);
950
+ resolutions.current.set(candidateKey, { status: "loading" });
951
+ redraw((value) => value + 1);
952
+ Promise.resolve().then(() => adapter.resolveImage(candidate)).then((blob) => {
953
+ if (!(blob instanceof Blob) || !blob.type.startsWith("image/")) throw new Error("The host returned an invalid image.");
954
+ if (blob.size > MAX_RESOLVED_IMAGE_BYTES) throw new Error("This image is too large to preview safely.");
955
+ if (!alive.current) return;
956
+ const url = URL.createObjectURL(blob);
957
+ resolutions.current.set(candidateKey, { status: "ready", url });
958
+ redraw((value) => value + 1);
959
+ }).catch((error) => {
960
+ if (!alive.current) return;
961
+ resolutions.current.set(candidateKey, {
962
+ status: "error",
963
+ message: error instanceof Error && error.message ? error.message : "Could not load this image."
964
+ });
965
+ redraw((value) => value + 1);
966
+ });
967
+ };
934
968
  useEffect(() => {
969
+ alive.current = true;
935
970
  if (!dialog.current?.open) dialog.current?.showModal();
936
- return () => clearTimeout(reset.current);
971
+ return () => {
972
+ alive.current = false;
973
+ clearTimeout(reset.current);
974
+ for (const value of resolutions.current.values()) if (value.url) URL.revokeObjectURL(value.url);
975
+ resolutions.current.clear();
976
+ };
937
977
  }, []);
938
978
  useEffect(() => {
939
979
  clearTimeout(reset.current);
940
980
  setCopyState2("idle");
941
- }, [index]);
942
- if (!item?.url) return null;
981
+ resolve(item);
982
+ }, [index, item?.reference]);
983
+ if (!item || !item.url && (!item.reference || !adapter?.resolveImage)) return null;
943
984
  const move = (amount) => onChange((index + amount + items.length) % items.length);
944
985
  const copy = async () => {
945
986
  try {
946
- await adapter.copyText(item.url);
987
+ await adapter.copyText(imageUrl);
947
988
  setCopyState2("copied");
948
989
  } catch {
949
990
  setCopyState2("failed");
@@ -979,12 +1020,21 @@ function ImageViewer({ items, index, adapter, onChange, onClose }) {
979
1020
  ] }),
980
1021
  /* @__PURE__ */ jsxs2("nav", { "aria-label": "Image actions", children: [
981
1022
  remote && adapter?.copyText ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": copyState === "copied" ? "Image link copied" : copyState === "failed" ? "Could not copy image link" : "Copy image link", title: "Copy image link", "data-status": copyState, onClick: copy, children: /* @__PURE__ */ jsx2(UiIcon, { name: copyState === "copied" ? "check" : "copy", size: 16 }) }) : null,
982
- remote ? /* @__PURE__ */ jsx2("a", { href: item.url, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx2("a", { href: item.url, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "down", size: 16 }) }),
1023
+ imageUrl ? remote ? /* @__PURE__ */ jsx2("a", { href: imageUrl, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx2("a", { href: imageUrl, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "down", size: 16 }) }) : null,
983
1024
  /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Close image preview", title: "Close", onClick: close, children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 17 }) })
984
1025
  ] })
985
1026
  ] }),
986
1027
  /* @__PURE__ */ jsxs2("figure", { children: [
987
- /* @__PURE__ */ jsx2("img", { src: item.url, alt: item.label }),
1028
+ imageUrl ? /* @__PURE__ */ jsx2("img", { src: imageUrl, alt: item.label }) : resolution?.status === "error" ? /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "alert", children: [
1029
+ /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 28 }),
1030
+ /* @__PURE__ */ jsx2("strong", { children: "Could not load image" }),
1031
+ /* @__PURE__ */ jsx2("small", { children: resolution.message }),
1032
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => resolve(item, true), children: "Retry" })
1033
+ ] }) : /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "status", children: [
1034
+ /* @__PURE__ */ jsx2("i", { class: "scui-control-spinner" }),
1035
+ /* @__PURE__ */ jsx2("strong", { children: "Loading image\u2026" }),
1036
+ /* @__PURE__ */ jsx2("small", { children: "The original stays out of the transcript payload." })
1037
+ ] }),
988
1038
  items.length > 1 ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
989
1039
  /* @__PURE__ */ jsx2("button", { type: "button", class: "scui-image-previous", "aria-label": "Previous image", onClick: () => move(-1), children: /* @__PURE__ */ jsx2(UiIcon, { name: "chevron", size: 19 }) }),
990
1040
  /* @__PURE__ */ jsx2("button", { type: "button", class: "scui-image-next", "aria-label": "Next image", onClick: () => move(1), children: /* @__PURE__ */ jsx2(UiIcon, { name: "chevron", size: 19 }) })
@@ -998,7 +1048,7 @@ function MessageImages({ items, adapter }) {
998
1048
  const [active, setActive] = useState(null);
999
1049
  const opener = useRef(null);
1000
1050
  if (!items?.length) return null;
1001
- const viewable = items.filter((item) => item.url);
1051
+ const viewable = items.filter((item) => item.url || item.reference && adapter?.resolveImage);
1002
1052
  const close = () => {
1003
1053
  setActive(null);
1004
1054
  requestAnimationFrame(() => opener.current?.focus({ preventScroll: true }));
@@ -1007,7 +1057,16 @@ function MessageImages({ items, adapter }) {
1007
1057
  /* @__PURE__ */ jsx2("div", { class: "scui-message-images", "aria-label": "Message images", children: items.map((item, index) => item.url ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `View image ${item.label}`, onClick: (event) => {
1008
1058
  opener.current = event.currentTarget;
1009
1059
  setActive(viewable.indexOf(item));
1010
- }, children: /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
1060
+ }, children: /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : item.reference && adapter?.resolveImage ? /* @__PURE__ */ jsxs2("button", { type: "button", "data-lazy": "true", "aria-label": `Load image ${item.label}`, onClick: (event) => {
1061
+ opener.current = event.currentTarget;
1062
+ setActive(viewable.indexOf(item));
1063
+ }, children: [
1064
+ /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }),
1065
+ /* @__PURE__ */ jsxs2("span", { children: [
1066
+ /* @__PURE__ */ jsx2("strong", { children: item.label }),
1067
+ /* @__PURE__ */ jsx2("small", { children: "Load preview" })
1068
+ ] })
1069
+ ] }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
1011
1070
  /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 14 }),
1012
1071
  /* @__PURE__ */ jsxs2("span", { children: [
1013
1072
  /* @__PURE__ */ jsx2("strong", { children: item.label }),
package/index.d.ts CHANGED
@@ -50,6 +50,10 @@ export interface TranscriptImage {
50
50
  id?: string;
51
51
  label: string;
52
52
  url?: string;
53
+ /** Opaque host-owned handle for an image deliberately omitted from the initial UI payload. */
54
+ reference?: string;
55
+ mediaType?: string;
56
+ byteSize?: number;
53
57
  }
54
58
 
55
59
  export type TranscriptAttachment = TranscriptContext | (TranscriptImage & { url: string });
@@ -238,6 +242,8 @@ export interface UiAdapter {
238
242
  onIntent(intent: SupercodeUiIntent): void | Promise<void>;
239
243
  /** Ask the embedding host for explicit user-selected text or image context. */
240
244
  pickContext?(): TranscriptAttachment | TranscriptAttachment[] | null | Promise<TranscriptAttachment | TranscriptAttachment[] | null>;
245
+ /** Resolve one projected historical image on demand. The UI owns and revokes its object URL. */
246
+ resolveImage?(image: TranscriptImage): Blob | Promise<Blob>;
241
247
  onClose?(): void;
242
248
  onOpen?(): void;
243
249
  copyText?(value: string): void | Promise<void>;
@@ -357,8 +363,8 @@ export function HarnessLogo(props: { id: HarnessId; activity?: SessionActivity;
357
363
  export type UiIconName = 'attach' | 'back' | 'check' | 'chevron' | 'close' | 'copy' | 'down' | 'menu' | 'plus' | 'search' | 'send' | 'stop';
358
364
  export function UiIcon(props: { name: UiIconName; size?: number; class?: string }): VNode | null;
359
365
  export function hasHarnessLogo(id: string): boolean;
360
- export function ImageViewer(props: { items: Array<TranscriptImage & { url: string }>; index: number; adapter?: Pick<UiAdapter, 'copyText'>; onChange(index: number): void; onClose(): void }): VNode | null;
361
- export function MessageImages(props: { items?: TranscriptImage[]; adapter?: Pick<UiAdapter, 'copyText'> }): VNode | null;
366
+ export function ImageViewer(props: { items: TranscriptImage[]; index: number; adapter?: Pick<UiAdapter, 'copyText' | 'resolveImage'>; onChange(index: number): void; onClose(): void }): VNode | null;
367
+ export function MessageImages(props: { items?: TranscriptImage[]; adapter?: Pick<UiAdapter, 'copyText' | 'resolveImage'> }): VNode | null;
362
368
  export function LoadingStatus(props: { state: SupercodeUiState; compact?: boolean }): VNode;
363
369
  export function RequestCard(props: { entry: TranscriptEntryModel; adapter: UiAdapter; canRespond: boolean }): VNode | null;
364
370
  export function TranscriptEntry(props: { entry: TranscriptEntryModel; state: SupercodeUiState; adapter: UiAdapter }): VNode;
package/messenger.mjs CHANGED
@@ -507,7 +507,10 @@ function readTranscript(value) {
507
507
  return image && typeof image.label === "string" ? [{
508
508
  ...typeof image.id === "string" ? { id: image.id } : {},
509
509
  label: image.label,
510
- ...typeof image.url === "string" ? { url: image.url } : {}
510
+ ...typeof image.url === "string" ? { url: image.url } : {},
511
+ ...typeof image.reference === "string" && image.reference.length <= 4e3 ? { reference: image.reference } : {},
512
+ ...typeof image.mediaType === "string" && image.mediaType.startsWith("image/") && image.mediaType.length <= 100 ? { mediaType: image.mediaType } : {},
513
+ ...Number.isSafeInteger(image.byteSize) && image.byteSize >= 0 ? { byteSize: image.byteSize } : {}
511
514
  }] : [];
512
515
  }).slice(0, 4);
513
516
  }
@@ -823,6 +826,7 @@ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-ru
823
826
  var MAX_CONTEXT_ITEMS = 32;
824
827
  var MAX_IMAGE_ITEMS = 4;
825
828
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
829
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
826
830
  var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
827
831
  function normalizeContext(value) {
828
832
  return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
@@ -925,22 +929,59 @@ function imageFilename(label) {
925
929
  function ImageViewer({ items, index, adapter, onChange, onClose }) {
926
930
  const dialog = useRef(null);
927
931
  const reset = useRef(null);
932
+ const resolutions = useRef(/* @__PURE__ */ new Map());
933
+ const alive = useRef(true);
934
+ const [, redraw] = useState(0);
928
935
  const [copyState, setCopyState2] = useState("idle");
929
936
  const item = items[index];
930
- const remote = item?.url?.startsWith("http://") || item?.url?.startsWith("https://");
937
+ const key = item?.reference ?? item?.url ?? `${item?.id ?? ""}:${index}`;
938
+ const resolution = item?.url ? null : resolutions.current.get(key);
939
+ const imageUrl = item?.url ?? (resolution?.status === "ready" ? resolution.url : null);
940
+ const remote = imageUrl?.startsWith("http://") || imageUrl?.startsWith("https://");
941
+ const resolve = (candidate, force = false) => {
942
+ if (candidate?.url || !candidate?.reference || !adapter?.resolveImage) return;
943
+ const candidateKey = candidate.reference;
944
+ const current = resolutions.current.get(candidateKey);
945
+ if (!force && (current?.status === "loading" || current?.status === "ready")) return;
946
+ if (current?.url) URL.revokeObjectURL(current.url);
947
+ resolutions.current.set(candidateKey, { status: "loading" });
948
+ redraw((value) => value + 1);
949
+ Promise.resolve().then(() => adapter.resolveImage(candidate)).then((blob) => {
950
+ if (!(blob instanceof Blob) || !blob.type.startsWith("image/")) throw new Error("The host returned an invalid image.");
951
+ if (blob.size > MAX_RESOLVED_IMAGE_BYTES) throw new Error("This image is too large to preview safely.");
952
+ if (!alive.current) return;
953
+ const url = URL.createObjectURL(blob);
954
+ resolutions.current.set(candidateKey, { status: "ready", url });
955
+ redraw((value) => value + 1);
956
+ }).catch((error) => {
957
+ if (!alive.current) return;
958
+ resolutions.current.set(candidateKey, {
959
+ status: "error",
960
+ message: error instanceof Error && error.message ? error.message : "Could not load this image."
961
+ });
962
+ redraw((value) => value + 1);
963
+ });
964
+ };
931
965
  useEffect(() => {
966
+ alive.current = true;
932
967
  if (!dialog.current?.open) dialog.current?.showModal();
933
- return () => clearTimeout(reset.current);
968
+ return () => {
969
+ alive.current = false;
970
+ clearTimeout(reset.current);
971
+ for (const value of resolutions.current.values()) if (value.url) URL.revokeObjectURL(value.url);
972
+ resolutions.current.clear();
973
+ };
934
974
  }, []);
935
975
  useEffect(() => {
936
976
  clearTimeout(reset.current);
937
977
  setCopyState2("idle");
938
- }, [index]);
939
- if (!item?.url) return null;
978
+ resolve(item);
979
+ }, [index, item?.reference]);
980
+ if (!item || !item.url && (!item.reference || !adapter?.resolveImage)) return null;
940
981
  const move = (amount) => onChange((index + amount + items.length) % items.length);
941
982
  const copy = async () => {
942
983
  try {
943
- await adapter.copyText(item.url);
984
+ await adapter.copyText(imageUrl);
944
985
  setCopyState2("copied");
945
986
  } catch {
946
987
  setCopyState2("failed");
@@ -976,12 +1017,21 @@ function ImageViewer({ items, index, adapter, onChange, onClose }) {
976
1017
  ] }),
977
1018
  /* @__PURE__ */ jsxs2("nav", { "aria-label": "Image actions", children: [
978
1019
  remote && adapter?.copyText ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": copyState === "copied" ? "Image link copied" : copyState === "failed" ? "Could not copy image link" : "Copy image link", title: "Copy image link", "data-status": copyState, onClick: copy, children: /* @__PURE__ */ jsx2(UiIcon, { name: copyState === "copied" ? "check" : "copy", size: 16 }) }) : null,
979
- remote ? /* @__PURE__ */ jsx2("a", { href: item.url, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx2("a", { href: item.url, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "down", size: 16 }) }),
1020
+ imageUrl ? remote ? /* @__PURE__ */ jsx2("a", { href: imageUrl, target: "_blank", rel: "noreferrer", "aria-label": "Open original image", title: "Open original image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }) }) : /* @__PURE__ */ jsx2("a", { href: imageUrl, download: imageFilename(item.label), "aria-label": "Download image", title: "Download image", children: /* @__PURE__ */ jsx2(UiIcon, { name: "down", size: 16 }) }) : null,
980
1021
  /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Close image preview", title: "Close", onClick: close, children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 17 }) })
981
1022
  ] })
982
1023
  ] }),
983
1024
  /* @__PURE__ */ jsxs2("figure", { children: [
984
- /* @__PURE__ */ jsx2("img", { src: item.url, alt: item.label }),
1025
+ imageUrl ? /* @__PURE__ */ jsx2("img", { src: imageUrl, alt: item.label }) : resolution?.status === "error" ? /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "alert", children: [
1026
+ /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 28 }),
1027
+ /* @__PURE__ */ jsx2("strong", { children: "Could not load image" }),
1028
+ /* @__PURE__ */ jsx2("small", { children: resolution.message }),
1029
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => resolve(item, true), children: "Retry" })
1030
+ ] }) : /* @__PURE__ */ jsxs2("div", { class: "scui-image-resolution", role: "status", children: [
1031
+ /* @__PURE__ */ jsx2("i", { class: "scui-control-spinner" }),
1032
+ /* @__PURE__ */ jsx2("strong", { children: "Loading image\u2026" }),
1033
+ /* @__PURE__ */ jsx2("small", { children: "The original stays out of the transcript payload." })
1034
+ ] }),
985
1035
  items.length > 1 ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
986
1036
  /* @__PURE__ */ jsx2("button", { type: "button", class: "scui-image-previous", "aria-label": "Previous image", onClick: () => move(-1), children: /* @__PURE__ */ jsx2(UiIcon, { name: "chevron", size: 19 }) }),
987
1037
  /* @__PURE__ */ jsx2("button", { type: "button", class: "scui-image-next", "aria-label": "Next image", onClick: () => move(1), children: /* @__PURE__ */ jsx2(UiIcon, { name: "chevron", size: 19 }) })
@@ -995,7 +1045,7 @@ function MessageImages({ items, adapter }) {
995
1045
  const [active, setActive] = useState(null);
996
1046
  const opener = useRef(null);
997
1047
  if (!items?.length) return null;
998
- const viewable = items.filter((item) => item.url);
1048
+ const viewable = items.filter((item) => item.url || item.reference && adapter?.resolveImage);
999
1049
  const close = () => {
1000
1050
  setActive(null);
1001
1051
  requestAnimationFrame(() => opener.current?.focus({ preventScroll: true }));
@@ -1004,7 +1054,16 @@ function MessageImages({ items, adapter }) {
1004
1054
  /* @__PURE__ */ jsx2("div", { class: "scui-message-images", "aria-label": "Message images", children: items.map((item, index) => item.url ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `View image ${item.label}`, onClick: (event) => {
1005
1055
  opener.current = event.currentTarget;
1006
1056
  setActive(viewable.indexOf(item));
1007
- }, children: /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
1057
+ }, children: /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }) }, item.id ?? `${item.label}:${index}`) : item.reference && adapter?.resolveImage ? /* @__PURE__ */ jsxs2("button", { type: "button", "data-lazy": "true", "aria-label": `Load image ${item.label}`, onClick: (event) => {
1058
+ opener.current = event.currentTarget;
1059
+ setActive(viewable.indexOf(item));
1060
+ }, children: [
1061
+ /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 16 }),
1062
+ /* @__PURE__ */ jsxs2("span", { children: [
1063
+ /* @__PURE__ */ jsx2("strong", { children: item.label }),
1064
+ /* @__PURE__ */ jsx2("small", { children: "Load preview" })
1065
+ ] })
1066
+ ] }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { "data-unavailable": "true", children: [
1008
1067
  /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 14 }),
1009
1068
  /* @__PURE__ */ jsxs2("span", { children: [
1010
1069
  /* @__PURE__ */ jsx2("strong", { children: item.label }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter-ai-dev/supercode-ui",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "description": "Composable default UI kit for Supercode-powered coding-agent experiences",
6
6
  "exports": {
package/sessions.mjs CHANGED
@@ -196,6 +196,7 @@ function UiIcon({ name, size = 16, class: className = "" }) {
196
196
  import { useEffect as useEffect2, useRef as useRef2, useState } from "preact/hooks";
197
197
  import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "preact/jsx-runtime";
198
198
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
199
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
199
200
 
200
201
  // src/conversation.jsx
201
202
  import { Fragment as Fragment4, jsx as jsx4, jsxs as jsxs3 } from "preact/jsx-runtime";
package/styles.css CHANGED
@@ -175,8 +175,8 @@
175
175
  .scui-attach { display:grid; flex:0 0 34px; width:34px; height:34px; place-items:center; border:0; border-radius:50%; background:transparent; color:var(--scui-muted) }.scui-attach:hover:not(:disabled) { background:var(--scui-fill); color:var(--scui-fg) }
176
176
  .scui-compose-context { display:flex; gap:5px; margin-bottom:6px; overflow-x:auto; scrollbar-width:thin }.scui-compose-context > span { display:flex; flex:0 0 auto; max-width:210px; align-items:center; gap:5px; padding:4px 5px 4px 7px; border:1px solid var(--scui-border); border-radius:999px; background:var(--scui-fill); font-size:10px }.scui-compose-context strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }.scui-compose-context button { display:grid; width:18px; height:18px; padding:0; place-items:center; border:0; border-radius:50%; background:transparent }.scui-compose-context button:hover { background:color-mix(in srgb,var(--scui-fg) 9%,transparent) }.scui-context-error { display:block; margin:0 2px 6px; color:var(--scui-danger) }
177
177
  .scui-compose-images { display:flex; gap:6px; margin-bottom:6px; overflow-x:auto; scrollbar-width:thin }.scui-compose-images > span { position:relative; display:grid; flex:0 0 58px; width:58px; height:58px; overflow:hidden; border:1px solid var(--scui-border); border-radius:8px; background:var(--scui-fill); place-items:center }.scui-compose-images img { width:100%; height:100%; object-fit:cover }.scui-compose-images strong { position:absolute; right:2px; bottom:2px; left:2px; overflow:hidden; padding:2px 3px; border-radius:4px; background:rgba(0,0,0,.65); color:#fff; font-size:8px; font-weight:500; text-overflow:ellipsis; white-space:nowrap }.scui-compose-images button { position:absolute; z-index:1; top:2px; right:2px; display:grid; width:19px; height:19px; padding:0; border:1px solid rgba(255,255,255,.32); border-radius:50%; background:rgba(0,0,0,.68); color:#fff; place-items:center; cursor:pointer }
178
- .scui-message-images { display:flex; max-width:280px; gap:5px; margin-bottom:6px; flex-wrap:wrap }.scui-message-images > button { display:block; max-width:100%; padding:0; overflow:hidden; border:1px solid var(--scui-border); border-radius:8px; background:var(--scui-fill); cursor:zoom-in }.scui-message-images > button:hover,.scui-message-images > button:focus-visible { border-color:var(--scui-border-strong); box-shadow:0 0 0 2px color-mix(in srgb,var(--scui-accent) 16%,transparent) }.scui-message-images img { display:block; width:auto; min-width:72px; max-width:100%; height:auto; max-height:210px; object-fit:contain }.scui-message-images > span { display:flex; align-items:center; gap:6px; padding:5px 7px; border:1px solid var(--scui-border); border-radius:7px; color:var(--scui-muted); font-size:10px }.scui-message-images > span > span { display:grid; min-width:0 }.scui-message-images > span strong { max-width:180px; overflow:hidden; color:var(--scui-fg); font-size:10px; font-weight:550; text-overflow:ellipsis; white-space:nowrap }.scui-message-images > span small { color:var(--scui-muted); font-size:8.5px }
179
- .scui-image-viewer { box-sizing:border-box; width:min(620px,calc(100vw - 16px)); max-width:none; height:min(620px,calc(100dvh - 16px)); max-height:none; margin:auto; padding:0; overflow:hidden; border:1px solid var(--scui-border-strong); border-radius:12px; background:var(--scui-bg); color:var(--scui-fg); box-shadow:0 18px 50px rgba(0,0,0,.28) }.scui-image-viewer::backdrop { background:rgba(10,12,15,.68); backdrop-filter:blur(2px) }.scui-image-viewer > header { display:flex; min-height:43px; align-items:center; gap:8px; padding:0 6px 0 11px; border-bottom:1px solid var(--scui-border); background:var(--scui-bg-raised) }.scui-image-viewer > header > span { display:grid; min-width:0; flex:1 }.scui-image-viewer > header strong { overflow:hidden; font-size:11px; font-weight:600; text-overflow:ellipsis; white-space:nowrap }.scui-image-viewer > header small { color:var(--scui-muted); font-size:9px }.scui-image-viewer nav { display:flex; flex:none; gap:2px }.scui-image-viewer nav button,.scui-image-viewer nav a { display:grid; box-sizing:border-box; width:31px; height:31px; padding:0; place-items:center; border:0; border-radius:7px; background:transparent; color:var(--scui-muted); cursor:pointer }.scui-image-viewer nav button:hover,.scui-image-viewer nav button:focus-visible,.scui-image-viewer nav a:hover,.scui-image-viewer nav a:focus-visible { background:var(--scui-fill); color:var(--scui-fg) }.scui-image-viewer nav button[data-status="copied"] { color:var(--scui-positive) }.scui-image-viewer nav button[data-status="failed"] { color:var(--scui-danger) }.scui-image-viewer figure { position:relative; display:grid; box-sizing:border-box; height:calc(100% - 43px); margin:0; padding:12px; overflow:hidden; place-items:center; background:color-mix(in srgb,var(--scui-fg) 4%,var(--scui-bg)) }.scui-image-viewer figure > img { display:block; max-width:100%; max-height:100%; object-fit:contain }.scui-image-viewer figure > button { position:absolute; top:50%; display:grid; width:34px; height:34px; padding:0; place-items:center; border:1px solid color-mix(in srgb,var(--scui-fg) 18%,transparent); border-radius:50%; background:color-mix(in srgb,var(--scui-bg) 82%,transparent); color:var(--scui-fg); box-shadow:0 2px 8px rgba(0,0,0,.16); cursor:pointer; transform:translateY(-50%); backdrop-filter:blur(5px) }.scui-image-viewer figure > button:hover,.scui-image-viewer figure > button:focus-visible { background:var(--scui-bg) }.scui-image-previous { left:10px }.scui-image-previous svg { transform:rotate(180deg) }.scui-image-next { right:10px }
178
+ .scui-message-images { display:flex; max-width:280px; gap:5px; margin-bottom:6px; flex-wrap:wrap }.scui-message-images > button { display:block; max-width:100%; padding:0; overflow:hidden; border:1px solid var(--scui-border); border-radius:8px; background:var(--scui-fill); cursor:zoom-in }.scui-message-images > button:hover,.scui-message-images > button:focus-visible { border-color:var(--scui-border-strong); box-shadow:0 0 0 2px color-mix(in srgb,var(--scui-accent) 16%,transparent) }.scui-message-images img { display:block; width:auto; min-width:72px; max-width:100%; height:auto; max-height:210px; object-fit:contain }.scui-message-images > span,.scui-message-images > button[data-lazy="true"] { display:flex; align-items:center; gap:6px; padding:5px 7px; border:1px solid var(--scui-border); border-radius:7px; color:var(--scui-muted); font-size:10px }.scui-message-images > button[data-lazy="true"] { text-align:left; cursor:pointer }.scui-message-images > span > span,.scui-message-images > button[data-lazy="true"] > span { display:grid; min-width:0 }.scui-message-images > span strong,.scui-message-images > button[data-lazy="true"] strong { max-width:180px; overflow:hidden; color:var(--scui-fg); font-size:10px; font-weight:550; text-overflow:ellipsis; white-space:nowrap }.scui-message-images > span small,.scui-message-images > button[data-lazy="true"] small { color:var(--scui-muted); font-size:8.5px }
179
+ .scui-image-viewer { box-sizing:border-box; width:min(620px,calc(100vw - 16px)); max-width:none; height:min(620px,calc(100dvh - 16px)); max-height:none; margin:auto; padding:0; overflow:hidden; border:1px solid var(--scui-border-strong); border-radius:12px; background:var(--scui-bg); color:var(--scui-fg); box-shadow:0 18px 50px rgba(0,0,0,.28) }.scui-image-viewer::backdrop { background:rgba(10,12,15,.68); backdrop-filter:blur(2px) }.scui-image-viewer > header { display:flex; min-height:43px; align-items:center; gap:8px; padding:0 6px 0 11px; border-bottom:1px solid var(--scui-border); background:var(--scui-bg-raised) }.scui-image-viewer > header > span { display:grid; min-width:0; flex:1 }.scui-image-viewer > header strong { overflow:hidden; font-size:11px; font-weight:600; text-overflow:ellipsis; white-space:nowrap }.scui-image-viewer > header small { color:var(--scui-muted); font-size:9px }.scui-image-viewer nav { display:flex; flex:none; gap:2px }.scui-image-viewer nav button,.scui-image-viewer nav a { display:grid; box-sizing:border-box; width:31px; height:31px; padding:0; place-items:center; border:0; border-radius:7px; background:transparent; color:var(--scui-muted); cursor:pointer }.scui-image-viewer nav button:hover,.scui-image-viewer nav button:focus-visible,.scui-image-viewer nav a:hover,.scui-image-viewer nav a:focus-visible { background:var(--scui-fill); color:var(--scui-fg) }.scui-image-viewer nav button[data-status="copied"] { color:var(--scui-positive) }.scui-image-viewer nav button[data-status="failed"] { color:var(--scui-danger) }.scui-image-viewer figure { position:relative; display:grid; box-sizing:border-box; height:calc(100% - 43px); margin:0; padding:12px; overflow:hidden; place-items:center; background:color-mix(in srgb,var(--scui-fg) 4%,var(--scui-bg)) }.scui-image-viewer figure > img { display:block; max-width:100%; max-height:100%; object-fit:contain }.scui-image-viewer figure > button { position:absolute; top:50%; display:grid; width:34px; height:34px; padding:0; place-items:center; border:1px solid color-mix(in srgb,var(--scui-fg) 18%,transparent); border-radius:50%; background:color-mix(in srgb,var(--scui-bg) 82%,transparent); color:var(--scui-fg); box-shadow:0 2px 8px rgba(0,0,0,.16); cursor:pointer; transform:translateY(-50%); backdrop-filter:blur(5px) }.scui-image-viewer figure > button:hover,.scui-image-viewer figure > button:focus-visible { background:var(--scui-bg) }.scui-image-previous { left:10px }.scui-image-previous svg { transform:rotate(180deg) }.scui-image-next { right:10px }.scui-image-resolution { display:grid; max-width:280px; justify-items:center; gap:7px; color:var(--scui-muted); text-align:center }.scui-image-resolution strong { color:var(--scui-fg); font-size:12px }.scui-image-resolution small { font-size:10px }.scui-image-resolution > button { position:static; width:auto; height:auto; padding:6px 10px; border:1px solid var(--scui-border-strong); border-radius:7px; background:var(--scui-bg); color:var(--scui-fg); transform:none; backdrop-filter:none }
180
180
  .scui-send,.scui-stop { display:grid; width:29px; height:29px; padding:0; place-items:center; border:0; border-radius:8px; background:var(--scui-accent); color:#fff; cursor:pointer }.scui-stop { background:var(--scui-danger) }.scui-send:disabled,.scui-stop:disabled { opacity:.4; cursor:default }.scui-control-spinner { box-sizing:border-box; width:13px; height:13px; border:1.5px solid currentColor; border-right-color:transparent; border-radius:50%; animation:scui-spin .75s linear infinite }
181
181
  .scui-queue { display:grid; gap:4px; max-height:85px; margin-bottom:6px; overflow:auto; font-size:10.5px }.scui-queue > span { display:flex; gap:6px; padding:4px 6px; border-radius:5px; background:var(--scui-fill) }.scui-queue > span > span { display:grid; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }.scui-queue small { color:var(--scui-muted) }.scui-queue > span button { margin-left:auto; border:0; background:transparent }
182
182
  .scui-harness-picker { display:flex; align-items:center; gap:7px; margin-bottom:7px }.scui-harness-picker select { margin-left:auto; max-width:55%; padding:4px; border:1px solid var(--scui-border); border-radius:6px; background:var(--scui-bg); color:var(--scui-fg) }