@volter-ai-dev/supercode-ui 0.1.22 → 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/composer.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/composer.jsx
2
- import { useEffect, useRef, useState } from "preact/hooks";
2
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "preact/hooks";
3
3
 
4
4
  // core.mjs
5
5
  var HARNESS_NAMES = Object.freeze({
@@ -129,10 +129,12 @@ function UiIcon({ name, size = 16, class: className = "" }) {
129
129
  }
130
130
 
131
131
  // src/context.jsx
132
- import { jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-runtime";
132
+ import { useEffect, useRef, useState } from "preact/hooks";
133
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-runtime";
133
134
  var MAX_CONTEXT_ITEMS = 32;
134
135
  var MAX_IMAGE_ITEMS = 4;
135
136
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
137
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
136
138
  var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
137
139
  function normalizeContext(value) {
138
140
  return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
@@ -268,18 +270,18 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
268
270
  }
269
271
  function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
270
272
  const remembered = composerMemory.get(memoryKey) ?? { draft: state.savedDraft, context: [], images: [], queue: [] };
271
- const [draft, setDraft] = useState(remembered.draft);
272
- const [context, setContext] = useState(remembered.context ?? []);
273
- const [images, setImages] = useState(remembered.images ?? []);
274
- const [queue, setQueue] = useState((remembered.queue ?? []).map((item) => ({ ...item, context: item.context ?? [], images: item.images ?? [] })));
275
- const [dispatching, setDispatching] = useState(false);
276
- const [picking, setPicking] = useState(false);
277
- const [dragging, setDragging] = useState(false);
278
- const [pickerError, setPickerError] = useState(null);
279
- const textarea = useRef(null);
273
+ const [draft, setDraft] = useState2(remembered.draft);
274
+ const [context, setContext] = useState2(remembered.context ?? []);
275
+ const [images, setImages] = useState2(remembered.images ?? []);
276
+ const [queue, setQueue] = useState2((remembered.queue ?? []).map((item) => ({ ...item, context: item.context ?? [], images: item.images ?? [] })));
277
+ const [dispatching, setDispatching] = useState2(false);
278
+ const [picking, setPicking] = useState2(false);
279
+ const [dragging, setDragging] = useState2(false);
280
+ const [pickerError, setPickerError] = useState2(null);
281
+ const textarea = useRef2(null);
280
282
  useAutosizeTextarea(textarea, draft);
281
283
  const remember = (nextDraft, nextContext, nextImages, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, images: nextImages, queue: nextQueue });
282
- useEffect(() => {
284
+ useEffect2(() => {
283
285
  remember(draft, context, images, queue);
284
286
  }, [draft, context, images, memoryKey, queue]);
285
287
  const updateQueue = (update) => setQueue((items) => {
@@ -289,7 +291,7 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
289
291
  });
290
292
  const queueBlocked = state.busy || pendingStatus !== null || dispatching;
291
293
  const queuesNewMessage = state.busy || pendingStatus === "sending" || pendingStatus === "failed" || dispatching;
292
- useEffect(() => {
294
+ useEffect2(() => {
293
295
  if (!queueBlocked && state.canSend && queue.length) {
294
296
  const [next, ...rest] = queue;
295
297
  setDispatching(true);
@@ -299,13 +301,13 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
299
301
  adapter.onIntent({ action: "send", text: next.text, ...next.context.length ? { context: next.context } : {}, ...next.images.length ? { images: next.images } : {} });
300
302
  }
301
303
  }, [adapter, draft, memoryKey, onPending, queue, queueBlocked, state.canSend]);
302
- useEffect(() => {
304
+ useEffect2(() => {
303
305
  if (pendingStatus !== null || state.busy) setDispatching(false);
304
306
  }, [pendingStatus, state.busy]);
305
- useEffect(() => {
307
+ useEffect2(() => {
306
308
  textarea.current?.focus({ preventScroll: true });
307
309
  }, [memoryKey]);
308
- useEffect(() => {
310
+ useEffect2(() => {
309
311
  if (!restoreDraft) return;
310
312
  setDraft(restoreDraft.text);
311
313
  const restoredContext = normalizeContext(restoreDraft.context);
@@ -316,7 +318,7 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
316
318
  textarea.current?.focus({ preventScroll: true });
317
319
  onDraftRestored?.(restoreDraft.id);
318
320
  }, [onDraftRestored, restoreDraft?.id]);
319
- useEffect(() => {
321
+ useEffect2(() => {
320
322
  const timer = setTimeout(() => adapter.onIntent({ action: "draft", text: draft }), 250);
321
323
  return () => clearTimeout(timer);
322
324
  }, [adapter, draft]);
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.d.ts CHANGED
@@ -15,7 +15,9 @@ export type {
15
15
  export {
16
16
  ActivityGroup,
17
17
  Conversation,
18
+ ImageViewer,
18
19
  LoadingStatus,
20
+ MessageImages,
19
21
  RequestCard,
20
22
  SessionDetails,
21
23
  TaskPlan,
package/conversation.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/conversation.jsx
2
- import { Fragment as Fragment2 } from "preact";
3
- import { useEffect as useEffect2, useId, useLayoutEffect, useRef as useRef2, useState } from "preact/hooks";
2
+ import { Fragment as Fragment3 } from "preact";
3
+ import { useEffect as useEffect3, useId, useLayoutEffect, useRef as useRef3, useState as useState2 } from "preact/hooks";
4
4
 
5
5
  // core.mjs
6
6
  var HARNESS_NAMES = Object.freeze({
@@ -622,18 +622,164 @@ function UiIcon({ name, size = 16, class: className = "" }) {
622
622
  }
623
623
 
624
624
  // src/context.jsx
625
- import { jsx as jsx3, jsxs as jsxs2 } from "preact/jsx-runtime";
625
+ import { useEffect as useEffect2, useRef as useRef2, useState } from "preact/hooks";
626
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "preact/jsx-runtime";
626
627
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
627
- function MessageImages({ items }) {
628
+ var MAX_RESOLVED_IMAGE_BYTES = 16 * 1024 * 1024;
629
+ function imageFilename(label) {
630
+ const value = label.trim().replace(/[\\/:*?"<>|]+/g, "-");
631
+ return value || "image";
632
+ }
633
+ function ImageViewer({ items, index, adapter, onChange, onClose }) {
634
+ const dialog = useRef2(null);
635
+ const reset = useRef2(null);
636
+ const resolutions = useRef2(/* @__PURE__ */ new Map());
637
+ const alive = useRef2(true);
638
+ const [, redraw] = useState(0);
639
+ const [copyState, setCopyState2] = useState("idle");
640
+ const item = items[index];
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
+ };
669
+ useEffect2(() => {
670
+ alive.current = true;
671
+ if (!dialog.current?.open) dialog.current?.showModal();
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
+ };
678
+ }, []);
679
+ useEffect2(() => {
680
+ clearTimeout(reset.current);
681
+ setCopyState2("idle");
682
+ resolve(item);
683
+ }, [index, item?.reference]);
684
+ if (!item || !item.url && (!item.reference || !adapter?.resolveImage)) return null;
685
+ const move = (amount) => onChange((index + amount + items.length) % items.length);
686
+ const copy = async () => {
687
+ try {
688
+ await adapter.copyText(imageUrl);
689
+ setCopyState2("copied");
690
+ } catch {
691
+ setCopyState2("failed");
692
+ }
693
+ clearTimeout(reset.current);
694
+ reset.current = setTimeout(() => setCopyState2("idle"), 1500);
695
+ };
696
+ const close = () => dialog.current?.close();
697
+ return /* @__PURE__ */ jsxs2(
698
+ "dialog",
699
+ {
700
+ ref: dialog,
701
+ class: "scui-image-viewer",
702
+ "aria-label": `Image preview: ${item.label}`,
703
+ onClose,
704
+ onClick: (event) => {
705
+ if (event.target === event.currentTarget) close();
706
+ },
707
+ onKeyDown: (event) => {
708
+ if (items.length < 2 || !["ArrowLeft", "ArrowRight"].includes(event.key)) return;
709
+ event.preventDefault();
710
+ move(event.key === "ArrowLeft" ? -1 : 1);
711
+ },
712
+ children: [
713
+ /* @__PURE__ */ jsxs2("header", { children: [
714
+ /* @__PURE__ */ jsxs2("span", { children: [
715
+ /* @__PURE__ */ jsx3("strong", { children: item.label }),
716
+ items.length > 1 ? /* @__PURE__ */ jsxs2("small", { children: [
717
+ index + 1,
718
+ " of ",
719
+ items.length
720
+ ] }) : null
721
+ ] }),
722
+ /* @__PURE__ */ jsxs2("nav", { "aria-label": "Image actions", children: [
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,
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,
725
+ /* @__PURE__ */ jsx3("button", { type: "button", "aria-label": "Close image preview", title: "Close", onClick: close, children: /* @__PURE__ */ jsx3(UiIcon, { name: "close", size: 17 }) })
726
+ ] })
727
+ ] }),
728
+ /* @__PURE__ */ jsxs2("figure", { children: [
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
+ ] }),
739
+ items.length > 1 ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
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 }) }),
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 }) })
742
+ ] }) : null
743
+ ] })
744
+ ]
745
+ }
746
+ );
747
+ }
748
+ function MessageImages({ items, adapter }) {
749
+ const [active, setActive] = useState(null);
750
+ const opener = useRef2(null);
628
751
  if (!items?.length) return null;
629
- return /* @__PURE__ */ jsx3("div", { class: "scui-message-images", children: items.map((item, index) => item.url ? /* @__PURE__ */ jsx3("img", { src: item.url, alt: item.label }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { children: [
630
- /* @__PURE__ */ jsx3(UiIcon, { name: "image", size: 14 }),
631
- item.label
632
- ] }, item.id ?? `${item.label}:${index}`)) });
752
+ const viewable = items.filter((item) => item.url || item.reference && adapter?.resolveImage);
753
+ const close = () => {
754
+ setActive(null);
755
+ requestAnimationFrame(() => opener.current?.focus({ preventScroll: true }));
756
+ };
757
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
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) => {
759
+ opener.current = event.currentTarget;
760
+ setActive(viewable.indexOf(item));
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: [
771
+ /* @__PURE__ */ jsx3(UiIcon, { name: "image", size: 14 }),
772
+ /* @__PURE__ */ jsxs2("span", { children: [
773
+ /* @__PURE__ */ jsx3("strong", { children: item.label }),
774
+ /* @__PURE__ */ jsx3("small", { children: "Preview unavailable" })
775
+ ] })
776
+ ] }, item.id ?? `${item.label}:${index}`)) }),
777
+ active !== null && viewable[active] ? /* @__PURE__ */ jsx3(ImageViewer, { items: viewable, index: active, adapter, onChange: setActive, onClose: close }) : null
778
+ ] });
633
779
  }
634
780
 
635
781
  // src/conversation.jsx
636
- import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "preact/jsx-runtime";
782
+ import { Fragment as Fragment4, jsx as jsx4, jsxs as jsxs3 } from "preact/jsx-runtime";
637
783
  function LoadingStatus({ state, compact = false }) {
638
784
  const copy = {
639
785
  connecting: ["Connecting to coding agents", "Checking installed harnesses and capabilities.", 0],
@@ -682,9 +828,9 @@ function ContextDisclosure({ context }) {
682
828
  ] });
683
829
  }
684
830
  function MessageMeta({ entry, adapter }) {
685
- const [copyState, setCopyState2] = useState("idle");
686
- const reset = useRef2(null);
687
- useEffect2(() => () => clearTimeout(reset.current), []);
831
+ const [copyState, setCopyState2] = useState2("idle");
832
+ const reset = useRef3(null);
833
+ useEffect3(() => () => clearTimeout(reset.current), []);
688
834
  const date = entry.ts === null ? null : new Date(entry.ts);
689
835
  const validDate = date && Number.isFinite(date.valueOf()) ? date : null;
690
836
  if (!validDate && (!adapter?.copyText || !entry.text)) return null;
@@ -705,33 +851,33 @@ function MessageMeta({ entry, adapter }) {
705
851
  ] });
706
852
  }
707
853
  var TOOL_ICONS = {
708
- read: () => /* @__PURE__ */ jsxs3(Fragment3, { children: [
854
+ read: () => /* @__PURE__ */ jsxs3(Fragment4, { children: [
709
855
  /* @__PURE__ */ jsx4("path", { d: "M5 2.75h7.25L15 5.5v7.75A1.75 1.75 0 0 1 13.25 15h-8.5A1.75 1.75 0 0 1 3 13.25v-8.5A2 2 0 0 1 5 2.75Z" }),
710
856
  /* @__PURE__ */ jsx4("path", { d: "M12 2.9v3h2.85M6 9h6M6 12h4" })
711
857
  ] }),
712
- search: () => /* @__PURE__ */ jsxs3(Fragment3, { children: [
858
+ search: () => /* @__PURE__ */ jsxs3(Fragment4, { children: [
713
859
  /* @__PURE__ */ jsx4("circle", { cx: "8", cy: "8", r: "4.5" }),
714
860
  /* @__PURE__ */ jsx4("path", { d: "m11.5 11.5 3 3" })
715
861
  ] }),
716
- edit: () => /* @__PURE__ */ jsxs3(Fragment3, { children: [
862
+ edit: () => /* @__PURE__ */ jsxs3(Fragment4, { children: [
717
863
  /* @__PURE__ */ jsx4("path", { d: "m11.75 3.25 3 3-8.5 8.5-3.75.75.75-3.75 8.5-8.5Z" }),
718
864
  /* @__PURE__ */ jsx4("path", { d: "m10 5 3 3" })
719
865
  ] }),
720
- command: () => /* @__PURE__ */ jsx4(Fragment3, { children: /* @__PURE__ */ jsx4("path", { d: "m3 5 3 3-3 3M8 12h6" }) }),
721
- test: () => /* @__PURE__ */ jsxs3(Fragment3, { children: [
866
+ command: () => /* @__PURE__ */ jsx4(Fragment4, { children: /* @__PURE__ */ jsx4("path", { d: "m3 5 3 3-3 3M8 12h6" }) }),
867
+ test: () => /* @__PURE__ */ jsxs3(Fragment4, { children: [
722
868
  /* @__PURE__ */ jsx4("path", { d: "M6 2.5v3L3 12a2 2 0 0 0 1.8 3h8.4a2 2 0 0 0 1.8-3l-3-6.5v-3M5 9h8" }),
723
869
  /* @__PURE__ */ jsx4("path", { d: "M5 2.5h8" })
724
870
  ] }),
725
- web: () => /* @__PURE__ */ jsxs3(Fragment3, { children: [
871
+ web: () => /* @__PURE__ */ jsxs3(Fragment4, { children: [
726
872
  /* @__PURE__ */ jsx4("circle", { cx: "9", cy: "9", r: "6.5" }),
727
873
  /* @__PURE__ */ jsx4("path", { d: "M2.75 9h12.5M9 2.5c2 1.8 3 4 3 6.5s-1 4.7-3 6.5c-2-1.8-3-4-3-6.5s1-4.7 3-6.5Z" })
728
874
  ] }),
729
- agent: () => /* @__PURE__ */ jsxs3(Fragment3, { children: [
875
+ agent: () => /* @__PURE__ */ jsxs3(Fragment4, { children: [
730
876
  /* @__PURE__ */ jsx4("circle", { cx: "9", cy: "6", r: "2.5" }),
731
877
  /* @__PURE__ */ jsx4("path", { d: "M4 15c.4-3 2-4.5 5-4.5s4.6 1.5 5 4.5" })
732
878
  ] }),
733
- plan: () => /* @__PURE__ */ jsx4(Fragment3, { children: /* @__PURE__ */ jsx4("path", { d: "m3 5 1 1 2-2M3 9l1 1 2-2M3 13l1 1 2-2M8 5h7M8 9h7M8 13h7" }) }),
734
- other: () => /* @__PURE__ */ jsxs3(Fragment3, { children: [
879
+ plan: () => /* @__PURE__ */ jsx4(Fragment4, { children: /* @__PURE__ */ jsx4("path", { d: "m3 5 1 1 2-2M3 9l1 1 2-2M3 13l1 1 2-2M8 5h7M8 9h7M8 13h7" }) }),
880
+ other: () => /* @__PURE__ */ jsxs3(Fragment4, { children: [
735
881
  /* @__PURE__ */ jsx4("path", { d: "M9 2.5v3M9 12.5v3M2.5 9h3M12.5 9h3" }),
736
882
  /* @__PURE__ */ jsx4("circle", { cx: "9", cy: "9", r: "3.5" })
737
883
  ] })
@@ -797,9 +943,9 @@ function ToolMetrics({ presentation }) {
797
943
  }
798
944
  function PendingElapsed({ now }) {
799
945
  const clock = now ?? Date.now;
800
- const started = useRef2(clock());
801
- const [elapsed, setElapsed] = useState(0);
802
- useEffect2(() => {
946
+ const started = useRef3(clock());
947
+ const [elapsed, setElapsed] = useState2(0);
948
+ useEffect3(() => {
803
949
  const timer = setInterval(() => setElapsed(Math.max(0, clock() - started.current)), 1e3);
804
950
  return () => clearInterval(timer);
805
951
  }, [clock]);
@@ -840,7 +986,7 @@ function SearchPreview({ presentation }) {
840
986
  ] }) : null,
841
987
  lines.length ? /* @__PURE__ */ jsx4("ol", { children: lines.map((line, index) => {
842
988
  const match = /^(.*?):(\d+)(?::(\d+))?:(.*)$/.exec(line);
843
- return /* @__PURE__ */ jsx4("li", { children: match ? /* @__PURE__ */ jsxs3(Fragment3, { children: [
989
+ return /* @__PURE__ */ jsx4("li", { children: match ? /* @__PURE__ */ jsxs3(Fragment4, { children: [
844
990
  /* @__PURE__ */ jsx4("code", { children: match[1] }),
845
991
  /* @__PURE__ */ jsxs3("small", { children: [
846
992
  match[2],
@@ -873,9 +1019,9 @@ function ToolPreview({ presentation, entry }) {
873
1019
  return presentation.preview ? /* @__PURE__ */ jsx4("pre", { class: "scui-tool-output", "data-error": failed, children: stripAnsi(presentation.preview) }) : null;
874
1020
  }
875
1021
  function ToolActions({ presentation, adapter }) {
876
- const [copied, setCopied] = useState(false);
877
- const reset = useRef2(null);
878
- useEffect2(() => () => clearTimeout(reset.current), []);
1022
+ const [copied, setCopied] = useState2(false);
1023
+ const reset = useRef3(null);
1024
+ useEffect3(() => () => clearTimeout(reset.current), []);
879
1025
  if (!adapter?.copyText) return null;
880
1026
  const action = presentation.command ? ["Copy command", presentation.command] : presentation.path ? ["Copy path", presentation.path] : presentation.url ? ["Copy URL", presentation.url] : presentation.query ? ["Copy query", presentation.query] : null;
881
1027
  const copy = async () => {
@@ -922,7 +1068,7 @@ function TranscriptEntry({ entry, state, adapter }) {
922
1068
  }
923
1069
  if (entry.role === "notice" || entry.role === "system") return /* @__PURE__ */ jsx4("div", { class: "scui-notice", "data-code": entry.code, children: entry.text });
924
1070
  return /* @__PURE__ */ jsxs3("article", { class: "scui-message", "data-role": entry.role, "aria-label": `${entry.role === "user" ? "Your" : "Assistant"} message`, children: [
925
- /* @__PURE__ */ jsx4(MessageImages, { items: entry.images }),
1071
+ /* @__PURE__ */ jsx4(MessageImages, { items: entry.images, adapter }),
926
1072
  /* @__PURE__ */ jsx4(Markdown, { value: entry.text, copyText: adapter?.copyText }),
927
1073
  /* @__PURE__ */ jsx4(ContextDisclosure, { context: entry.context }),
928
1074
  entry.truncated ? /* @__PURE__ */ jsx4("small", { class: "scui-truncated", children: "Entry truncated by host \xB7 load native session for the complete value" }) : null,
@@ -931,14 +1077,14 @@ function TranscriptEntry({ entry, state, adapter }) {
931
1077
  }
932
1078
  function ToolRow({ entry, workspace, open = false, adapter }) {
933
1079
  const presentation = entry.presentation ?? createToolPresentation(entry);
934
- const [expanded, setExpanded] = useState(open || entry.status === "pending");
935
- useEffect2(() => {
1080
+ const [expanded, setExpanded] = useState2(open || entry.status === "pending");
1081
+ useEffect3(() => {
936
1082
  if (entry.status === "pending") setExpanded(true);
937
1083
  }, [entry.status]);
938
1084
  const target = compactToolTarget(presentation.target, workspace);
939
1085
  const hasDetail = Boolean(entry.arguments || entry.resultText || presentation.preview || presentation.fields.length);
940
1086
  const category = presentation.category ?? toolCategory(entry);
941
- const summary = /* @__PURE__ */ jsxs3(Fragment3, { children: [
1087
+ const summary = /* @__PURE__ */ jsxs3(Fragment4, { children: [
942
1088
  /* @__PURE__ */ jsx4(ToolIcon, { category }),
943
1089
  /* @__PURE__ */ jsx4("strong", { children: toolAction2(entry, category, presentation) }),
944
1090
  target ? /* @__PURE__ */ jsx4("code", { class: "scui-tool-target", title: presentation.target, children: target }) : null,
@@ -966,9 +1112,9 @@ function ToolRow({ entry, workspace, open = false, adapter }) {
966
1112
  function ActivityGroup({ entries, state, adapter }) {
967
1113
  if (entries.length === 1) return /* @__PURE__ */ jsx4("section", { class: "scui-activity", "data-single": "true", children: /* @__PURE__ */ jsx4(ToolRow, { entry: entries[0], workspace: state.workspace, adapter }) });
968
1114
  const active = entries.some((entry) => entry.status === "pending");
969
- const [open, setOpen] = useState(active);
1115
+ const [open, setOpen] = useState2(active);
970
1116
  const id = useId();
971
- useEffect2(() => {
1117
+ useEffect3(() => {
972
1118
  if (active) setOpen(true);
973
1119
  }, [active]);
974
1120
  return /* @__PURE__ */ jsxs3("section", { class: "scui-activity", children: [
@@ -1044,9 +1190,9 @@ function SessionDetails({ semantics }) {
1044
1190
  }
1045
1191
  var conversationMemory = /* @__PURE__ */ new Map();
1046
1192
  function ConversationAnnouncements({ state }) {
1047
- const previousBusy = useRef2(state.busy);
1048
- const [announcement, setAnnouncement] = useState("");
1049
- useEffect2(() => {
1193
+ const previousBusy = useRef3(state.busy);
1194
+ const [announcement, setAnnouncement] = useState2("");
1195
+ useEffect3(() => {
1050
1196
  if (previousBusy.current && !state.busy && !state.error) {
1051
1197
  setAnnouncement(`${harnessDisplayName(state.harness) || "Coding agent"} finished working`);
1052
1198
  }
@@ -1055,13 +1201,13 @@ function ConversationAnnouncements({ state }) {
1055
1201
  return /* @__PURE__ */ jsx4("span", { class: "scui-sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announcement });
1056
1202
  }
1057
1203
  function Conversation({ state, adapter, components = {}, slots = {}, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pending = null, unreadAfterMessages = null }) {
1058
- const scroller = useRef2(null);
1204
+ const scroller = useRef3(null);
1059
1205
  const remembered = conversationMemory.get(memoryKey) ?? { top: null, atBottom: true };
1060
- const [atBottom, setAtBottom] = useState(remembered.atBottom);
1061
- const earlierAnchor = useRef2(null);
1062
- const restored = useRef2(false);
1206
+ const [atBottom, setAtBottom] = useState2(remembered.atBottom);
1207
+ const earlierAnchor = useRef3(null);
1208
+ const restored = useRef3(false);
1063
1209
  const blocks = groupConversation(state.transcript);
1064
- const unreadBoundary = useRef2(Number.isSafeInteger(unreadAfterMessages) && unreadAfterMessages >= 0 ? unreadAfterMessages : null);
1210
+ const unreadBoundary = useRef3(Number.isSafeInteger(unreadAfterMessages) && unreadAfterMessages >= 0 ? unreadAfterMessages : null);
1065
1211
  const unreadBlock = unreadBoundary.current === null ? -1 : blocks.findIndex((block) => {
1066
1212
  const entries = block.kind === "activity" ? block.entries : [block.entry];
1067
1213
  return entries.some((entry) => Number.isSafeInteger(entry.messageIndex) && entry.messageIndex > unreadBoundary.current);
@@ -1120,12 +1266,12 @@ function Conversation({ state, adapter, components = {}, slots = {}, memoryKey =
1120
1266
  }, children: "Load earlier messages" }) : null,
1121
1267
  !blocks.length && state.startup !== "ready" ? /* @__PURE__ */ jsx4(LoadingStatus, { state }) : null,
1122
1268
  !blocks.length && state.startup === "ready" ? Empty ? /* @__PURE__ */ jsx4(Empty, { state, adapter, value: null }) : /* @__PURE__ */ jsx4("div", { class: "scui-empty", children: state.error ?? (state.harness ? `${harnessDisplayName(state.harness)} is listening. Say something.` : "No transcript yet.") }) : null,
1123
- blocks.map((block, index) => /* @__PURE__ */ jsxs3(Fragment2, { children: [
1269
+ blocks.map((block, index) => /* @__PURE__ */ jsxs3(Fragment3, { children: [
1124
1270
  index === unreadBlock ? /* @__PURE__ */ jsx4("div", { class: "scui-unread-divider", role: "separator", "aria-label": "New messages", children: /* @__PURE__ */ jsx4("span", { children: "New" }) }) : null,
1125
1271
  block.kind === "activity" ? /* @__PURE__ */ jsx4(Group, { value: block.entries, entries: block.entries, state, adapter }) : /* @__PURE__ */ jsx4(Entry, { value: block.entry, entry: block.entry, state, adapter })
1126
1272
  ] }, block.id)),
1127
1273
  pendingMessage ? /* @__PURE__ */ jsxs3("article", { class: "scui-message scui-pending", "data-role": "user", "data-status": pendingMessage.status, "aria-label": "Your pending message", children: [
1128
- /* @__PURE__ */ jsx4(MessageImages, { items: pendingMessage.images }),
1274
+ /* @__PURE__ */ jsx4(MessageImages, { items: pendingMessage.images, adapter }),
1129
1275
  /* @__PURE__ */ jsx4(Markdown, { value: pendingMessage.text, copyText: adapter?.copyText }),
1130
1276
  /* @__PURE__ */ jsx4(ContextDisclosure, { context: pendingMessage.context }),
1131
1277
  /* @__PURE__ */ jsxs3("footer", { children: [
@@ -1157,7 +1303,9 @@ function Conversation({ state, adapter, components = {}, slots = {}, memoryKey =
1157
1303
  export {
1158
1304
  ActivityGroup,
1159
1305
  Conversation,
1306
+ ImageViewer,
1160
1307
  LoadingStatus,
1308
+ MessageImages,
1161
1309
  RequestCard,
1162
1310
  SessionDetails,
1163
1311
  TaskPlan,