@volter-ai-dev/supercode-ui 0.1.20 → 0.1.21

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
@@ -98,6 +98,11 @@ var ICONS = {
98
98
  /* @__PURE__ */ jsx("path", { d: "M9 3.5v10" }),
99
99
  /* @__PURE__ */ jsx("path", { d: "m4.75 9.5 4.25 4 4.25-4" })
100
100
  ] }),
101
+ image: () => /* @__PURE__ */ jsxs(Fragment, { children: [
102
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "3.5", width: "12", height: "11", rx: "1.75" }),
103
+ /* @__PURE__ */ jsx("circle", { cx: "6.5", cy: "7", r: "1.25" }),
104
+ /* @__PURE__ */ jsx("path", { d: "m4.5 13 3.25-3 2.1 1.85 1.65-1.5L14 13" })
105
+ ] }),
101
106
  menu: () => /* @__PURE__ */ jsxs(Fragment, { children: [
102
107
  /* @__PURE__ */ jsx("circle", { cx: "4", cy: "9", r: "1.25", fill: "currentColor", stroke: "none" }),
103
108
  /* @__PURE__ */ jsx("circle", { cx: "9", cy: "9", r: "1.25", fill: "currentColor", stroke: "none" }),
@@ -126,6 +131,9 @@ function UiIcon({ name, size = 16, class: className = "" }) {
126
131
  // src/context.jsx
127
132
  import { jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-runtime";
128
133
  var MAX_CONTEXT_ITEMS = 32;
134
+ var MAX_IMAGE_ITEMS = 4;
135
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
136
+ var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
129
137
  function normalizeContext(value) {
130
138
  return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
131
139
  if (!item || typeof item.label !== "string" || typeof item.detail !== "string") return [];
@@ -152,6 +160,58 @@ function mergeContext(current, picked) {
152
160
  }
153
161
  return next;
154
162
  }
163
+ function normalizeImages(value) {
164
+ const seen = /* @__PURE__ */ new Set();
165
+ return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
166
+ if (!item || typeof item.label !== "string" || typeof item.url !== "string") return [];
167
+ const label = item.label.trim().slice(0, 200);
168
+ const url = item.url;
169
+ if (!label || seen.has(url) || !(url.startsWith("data:image/") || url.startsWith("https://") || url.startsWith("http://"))) return [];
170
+ seen.add(url);
171
+ return [{
172
+ ...typeof item.id === "string" && item.id ? { id: item.id.slice(0, 2e3) } : {},
173
+ label,
174
+ url
175
+ }];
176
+ }).slice(0, MAX_IMAGE_ITEMS);
177
+ }
178
+ function mergeImages(current, picked) {
179
+ const next = [...current];
180
+ const seen = new Set(current.map((item) => item.url));
181
+ for (const item of normalizeImages(picked)) {
182
+ if (seen.has(item.url)) continue;
183
+ seen.add(item.url);
184
+ next.push(item);
185
+ if (next.length === MAX_IMAGE_ITEMS) break;
186
+ }
187
+ return next;
188
+ }
189
+ function partitionAttachments(value) {
190
+ const values = Array.isArray(value) ? value : value ? [value] : [];
191
+ const context = [];
192
+ const images = [];
193
+ for (const item of values) {
194
+ if (item && typeof item.label === "string" && item.label.trim() && typeof item.detail === "string" && item.detail) context.push(item);
195
+ else if (item && typeof item.label === "string" && item.label.trim() && typeof item.url === "string" && (item.url.startsWith("data:image/") || item.url.startsWith("https://") || item.url.startsWith("http://"))) images.push(item);
196
+ else throw new Error("The attachment picker returned an invalid item.");
197
+ }
198
+ return { context, images };
199
+ }
200
+ async function imageAttachmentsFromFiles(value) {
201
+ const files = Array.from(value ?? []).filter((file) => file?.type?.startsWith("image/"));
202
+ if (files.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images at a time.`);
203
+ return Promise.all(files.map(async (file) => {
204
+ if (!IMAGE_TYPES.has(file.type)) throw new Error(`${file.name || "That image"} is not PNG, JPEG, GIF, or WebP.`);
205
+ if (file.size > MAX_IMAGE_BYTES) throw new Error(`${file.name || "That image"} is larger than 5 MB.`);
206
+ const url = await new Promise((resolve, reject) => {
207
+ const reader = new FileReader();
208
+ reader.onload = () => resolve(reader.result);
209
+ reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name || "image"}.`));
210
+ reader.readAsDataURL(file);
211
+ });
212
+ return { id: `${file.name}:${file.size}:${file.lastModified}`, label: file.name || "Pasted image", url };
213
+ }));
214
+ }
155
215
  function ContextTray({ items, onRemove }) {
156
216
  if (!items.length) return null;
157
217
  return /* @__PURE__ */ jsx2("div", { class: "scui-compose-context", "aria-label": "Attached context", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
@@ -160,6 +220,14 @@ function ContextTray({ items, onRemove }) {
160
220
  /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `Remove context ${item.label}`, onClick: () => onRemove(index), children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 12 }) })
161
221
  ] }, item.id ?? `${item.label}:${index}`)) });
162
222
  }
223
+ function ImageTray({ items, onRemove }) {
224
+ if (!items.length) return null;
225
+ return /* @__PURE__ */ jsx2("div", { class: "scui-compose-images", "aria-label": "Attached images", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
226
+ /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }),
227
+ /* @__PURE__ */ jsx2("strong", { children: item.label }),
228
+ onRemove ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `Remove image ${item.label}`, onClick: () => onRemove(index), children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 12 }) }) : null
229
+ ] }, item.id ?? `${item.label}:${index}`)) });
230
+ }
163
231
 
164
232
  // src/textarea.js
165
233
  import { useLayoutEffect } from "preact/hooks";
@@ -199,19 +267,23 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
199
267
  ] });
200
268
  }
201
269
  function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
202
- const remembered = composerMemory.get(memoryKey) ?? { draft: state.savedDraft, context: [], queue: [] };
270
+ const remembered = composerMemory.get(memoryKey) ?? { draft: state.savedDraft, context: [], images: [], queue: [] };
203
271
  const [draft, setDraft] = useState(remembered.draft);
204
- const [context, setContext] = useState(remembered.context);
205
- const [queue, setQueue] = useState(remembered.queue);
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 ?? [] })));
206
275
  const [dispatching, setDispatching] = useState(false);
207
276
  const [picking, setPicking] = useState(false);
208
277
  const [pickerError, setPickerError] = useState(null);
209
278
  const textarea = useRef(null);
210
279
  useAutosizeTextarea(textarea, draft);
211
- const remember = (nextDraft, nextContext, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, queue: nextQueue });
280
+ const remember = (nextDraft, nextContext, nextImages, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, images: nextImages, queue: nextQueue });
281
+ useEffect(() => {
282
+ remember(draft, context, images, queue);
283
+ }, [draft, context, images, memoryKey, queue]);
212
284
  const updateQueue = (update) => setQueue((items) => {
213
285
  const next = update(items);
214
- remember(draft, context, next);
286
+ remember(draft, context, images, next);
215
287
  return next;
216
288
  });
217
289
  const queueBlocked = state.busy || pendingStatus !== null || dispatching;
@@ -221,9 +293,9 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
221
293
  const [next, ...rest] = queue;
222
294
  setDispatching(true);
223
295
  setQueue(rest);
224
- remember(draft, context, rest);
225
- onPending?.(next.text, next.context);
226
- adapter.onIntent({ action: "send", text: next.text, ...next.context.length ? { context: next.context } : {} });
296
+ remember(draft, context, images, rest);
297
+ onPending?.(next.text, next.context, next.images);
298
+ adapter.onIntent({ action: "send", text: next.text, ...next.context.length ? { context: next.context } : {}, ...next.images.length ? { images: next.images } : {} });
227
299
  }
228
300
  }, [adapter, draft, memoryKey, onPending, queue, queueBlocked, state.canSend]);
229
301
  useEffect(() => {
@@ -236,8 +308,10 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
236
308
  if (!restoreDraft) return;
237
309
  setDraft(restoreDraft.text);
238
310
  const restoredContext = normalizeContext(restoreDraft.context);
311
+ const restoredImages = normalizeImages(restoreDraft.images);
239
312
  setContext(restoredContext);
240
- remember(restoreDraft.text, restoredContext, queue);
313
+ setImages(restoredImages);
314
+ remember(restoreDraft.text, restoredContext, restoredImages, queue);
241
315
  textarea.current?.focus({ preventScroll: true });
242
316
  onDraftRestored?.(restoreDraft.id);
243
317
  }, [onDraftRestored, restoreDraft?.id]);
@@ -246,30 +320,55 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
246
320
  return () => clearTimeout(timer);
247
321
  }, [adapter, draft]);
248
322
  const pickContext = () => {
249
- if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS) return;
323
+ if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
250
324
  setPicking(true);
251
325
  setPickerError(null);
252
326
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
327
+ const attachments = partitionAttachments(picked);
328
+ if (context.length + attachments.context.length > MAX_CONTEXT_ITEMS) throw new Error(`Attach at most ${MAX_CONTEXT_ITEMS} text items.`);
329
+ if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
253
330
  setContext((current) => {
254
- const next = mergeContext(current, picked);
255
- remember(draft, next, queue);
331
+ const next = mergeContext(current, attachments.context);
332
+ remember(draft, next, images, queue);
333
+ return next;
334
+ });
335
+ setImages((current) => {
336
+ const next = mergeImages(current, attachments.images);
337
+ remember(draft, context, next, queue);
256
338
  return next;
257
339
  });
258
- }, (error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
340
+ }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
341
+ };
342
+ const pasteImages = (event) => {
343
+ const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/"));
344
+ if (!files.length) return;
345
+ event.preventDefault();
346
+ if (images.length + files.length > MAX_IMAGE_ITEMS) {
347
+ setPickerError(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
348
+ return;
349
+ }
350
+ setPicking(true);
351
+ setPickerError(null);
352
+ imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
353
+ const next = mergeImages(current, picked);
354
+ remember(draft, context, next, queue);
355
+ return next;
356
+ }), (error) => setPickerError(error instanceof Error ? error.message : "Could not paste image.")).finally(() => setPicking(false));
259
357
  };
260
358
  const send = () => {
261
359
  const text = draft.trim();
262
360
  if (!text) return;
263
- const message = { text, context };
361
+ const message = { text, context, images };
264
362
  if (queuesNewMessage) updateQueue((items) => [...items, message]);
265
363
  else if (state.canSend) {
266
364
  if (onPending) setDispatching(true);
267
- onPending?.(text, context);
268
- adapter.onIntent({ action: "send", text, ...context.length ? { context } : {} });
365
+ onPending?.(text, context, images);
366
+ adapter.onIntent({ action: "send", text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
269
367
  } else return;
270
368
  setDraft("");
271
369
  setContext([]);
272
- remember("", [], queuesNewMessage ? [...queue, message] : queue);
370
+ setImages([]);
371
+ remember("", [], [], queuesNewMessage ? [...queue, message] : queue);
273
372
  };
274
373
  return /* @__PURE__ */ jsxs3("div", { class: "scui-compose", children: [
275
374
  queue.length ? /* @__PURE__ */ jsxs3("div", { class: "scui-queue", children: [
@@ -280,26 +379,31 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
280
379
  queue.map((item, index) => /* @__PURE__ */ jsxs3("span", { children: [
281
380
  /* @__PURE__ */ jsxs3("span", { children: [
282
381
  item.text,
283
- item.context.length ? /* @__PURE__ */ jsxs3("small", { children: [
284
- item.context.length,
382
+ item.context.length + item.images.length ? /* @__PURE__ */ jsxs3("small", { children: [
383
+ item.context.length + item.images.length,
285
384
  " attached"
286
385
  ] }) : null
287
386
  ] }),
288
387
  /* @__PURE__ */ jsx3("button", { type: "button", "aria-label": `Remove queued message ${index + 1}`, onClick: () => updateQueue((items) => items.filter((_, itemIndex) => itemIndex !== index)), children: /* @__PURE__ */ jsx3(UiIcon, { name: "close", size: 13 }) })
289
388
  ] }, `${index}:${item.text}`))
290
389
  ] }) : null,
390
+ /* @__PURE__ */ jsx3(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
391
+ const next = items.filter((_, itemIndex) => itemIndex !== index);
392
+ remember(draft, context, next, queue);
393
+ return next;
394
+ }) }),
291
395
  /* @__PURE__ */ jsx3(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
292
396
  const next = items.filter((_, itemIndex) => itemIndex !== index);
293
- remember(draft, next, queue);
397
+ remember(draft, next, images, queue);
294
398
  return next;
295
399
  }) }),
296
400
  pickerError ? /* @__PURE__ */ jsx3("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
297
401
  /* @__PURE__ */ jsxs3("div", { class: "scui-envelope", children: [
298
- adapter.pickContext && state.mode === "control" ? /* @__PURE__ */ jsx3("button", { class: "scui-attach", type: "button", "aria-label": "Attach context", disabled: picking || context.length >= MAX_CONTEXT_ITEMS, onClick: pickContext, children: picking ? /* @__PURE__ */ jsx3("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx3(UiIcon, { name: "attach", size: 17 }) }) : null,
299
- /* @__PURE__ */ jsx3("textarea", { ref: textarea, rows: 1, "aria-label": `Message ${harnessDisplayName(state.harness) || "agent"}`, placeholder: state.startup !== "ready" ? "Connecting\u2026" : pendingStatus === "failed" ? "Retry or edit the unsent message\u2026" : pendingStatus === "editing" ? "Edit and resend\u2026" : queueBlocked ? "Queue a follow-up\u2026" : labels.askAgent, value: draft, disabled: state.mode !== "control" && !state.canSend, onInput: (event) => {
402
+ adapter.pickContext && state.mode === "control" ? /* @__PURE__ */ jsx3("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS, onClick: pickContext, children: picking ? /* @__PURE__ */ jsx3("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx3(UiIcon, { name: "attach", size: 17 }) }) : null,
403
+ /* @__PURE__ */ jsx3("textarea", { ref: textarea, rows: 1, "aria-label": `Message ${harnessDisplayName(state.harness) || "agent"}`, placeholder: state.startup !== "ready" ? "Connecting\u2026" : pendingStatus === "failed" ? "Retry or edit the unsent message\u2026" : pendingStatus === "editing" ? "Edit and resend\u2026" : queueBlocked ? "Queue a follow-up\u2026" : labels.askAgent, value: draft, disabled: state.mode !== "control" && !state.canSend, onPaste: pasteImages, onInput: (event) => {
300
404
  const value = event.currentTarget.value;
301
405
  setDraft(value);
302
- remember(value, context, queue);
406
+ remember(value, context, images, queue);
303
407
  }, onKeyDown: (event) => {
304
408
  if (isSendKey(event)) {
305
409
  event.preventDefault();
package/controller.mjs CHANGED
@@ -99,6 +99,23 @@ function projectContext(context) {
99
99
  return projected.length ? projected : undefined;
100
100
  }
101
101
 
102
+ function projectImages(images) {
103
+ if (!Array.isArray(images)) return undefined;
104
+ const projected = images.flatMap((item) => {
105
+ 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
109
+ : null;
110
+ return [{
111
+ ...(typeof item.id === 'string' ? { id: item.id } : {}),
112
+ label: item.label,
113
+ ...(url ? { url } : {}),
114
+ }];
115
+ }).slice(0, 4);
116
+ return projected.length ? projected : undefined;
117
+ }
118
+
102
119
  function requestSummary(entry) {
103
120
  const options = Array.isArray(entry.options)
104
121
  ? entry.options.map((option) => option?.name).filter(Boolean).join(' / ')
@@ -113,6 +130,7 @@ function projectConversationEntry(entry, maxEntryChars) {
113
130
  if (entry.visibility === 'context') return null;
114
131
  const body = truncate(entry.text, maxEntryChars);
115
132
  const context = projectContext(entry.context);
133
+ const images = projectImages(entry.images);
116
134
  return {
117
135
  id: entry.id,
118
136
  role: entry.role,
@@ -121,6 +139,7 @@ function projectConversationEntry(entry, maxEntryChars) {
121
139
  ts: timestampFromMetadata(entry.metadata),
122
140
  truncated: body.truncated,
123
141
  ...(context ? { context } : {}),
142
+ ...(images ? { images } : {}),
124
143
  };
125
144
  }
126
145
  if (entry.kind === 'tool') {
@@ -376,10 +395,10 @@ async function dispatchStandard(controller, intent, options) {
376
395
  const active = snapshot.activeSessionKey;
377
396
  if (intent.action === 'mounted') return;
378
397
  if (intent.action === 'attach') return controller.dispatch({ type: 'observe', sessionKey: intent.key });
379
- if (intent.action === 'send') return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}) });
398
+ if (intent.action === 'send') return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}), ...(intent.images?.length ? { images: intent.images } : {}) });
380
399
  if (intent.action === 'new') {
381
400
  await controller.dispatch({ type: 'start', harness: intent.harness });
382
- return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}) });
401
+ return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}), ...(intent.images?.length ? { images: intent.images } : {}) });
383
402
  }
384
403
  if (intent.action === 'resume' && active) return controller.dispatch({ type: 'resume', sessionKey: active });
385
404
  if (intent.action === 'join' && active) return controller.dispatch({ type: 'attach', sessionKey: active });