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

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,24 @@ 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);
277
+ const [dragging, setDragging] = useState(false);
208
278
  const [pickerError, setPickerError] = useState(null);
209
279
  const textarea = useRef(null);
210
280
  useAutosizeTextarea(textarea, draft);
211
- const remember = (nextDraft, nextContext, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, queue: nextQueue });
281
+ const remember = (nextDraft, nextContext, nextImages, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, images: nextImages, queue: nextQueue });
282
+ useEffect(() => {
283
+ remember(draft, context, images, queue);
284
+ }, [draft, context, images, memoryKey, queue]);
212
285
  const updateQueue = (update) => setQueue((items) => {
213
286
  const next = update(items);
214
- remember(draft, context, next);
287
+ remember(draft, context, images, next);
215
288
  return next;
216
289
  });
217
290
  const queueBlocked = state.busy || pendingStatus !== null || dispatching;
@@ -221,9 +294,9 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
221
294
  const [next, ...rest] = queue;
222
295
  setDispatching(true);
223
296
  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 } : {} });
297
+ remember(draft, context, images, rest);
298
+ onPending?.(next.text, next.context, next.images);
299
+ adapter.onIntent({ action: "send", text: next.text, ...next.context.length ? { context: next.context } : {}, ...next.images.length ? { images: next.images } : {} });
227
300
  }
228
301
  }, [adapter, draft, memoryKey, onPending, queue, queueBlocked, state.canSend]);
229
302
  useEffect(() => {
@@ -236,8 +309,10 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
236
309
  if (!restoreDraft) return;
237
310
  setDraft(restoreDraft.text);
238
311
  const restoredContext = normalizeContext(restoreDraft.context);
312
+ const restoredImages = normalizeImages(restoreDraft.images);
239
313
  setContext(restoredContext);
240
- remember(restoreDraft.text, restoredContext, queue);
314
+ setImages(restoredImages);
315
+ remember(restoreDraft.text, restoredContext, restoredImages, queue);
241
316
  textarea.current?.focus({ preventScroll: true });
242
317
  onDraftRestored?.(restoreDraft.id);
243
318
  }, [onDraftRestored, restoreDraft?.id]);
@@ -246,30 +321,67 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
246
321
  return () => clearTimeout(timer);
247
322
  }, [adapter, draft]);
248
323
  const pickContext = () => {
249
- if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS) return;
324
+ if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
250
325
  setPicking(true);
251
326
  setPickerError(null);
252
327
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
328
+ const attachments = partitionAttachments(picked);
329
+ if (context.length + attachments.context.length > MAX_CONTEXT_ITEMS) throw new Error(`Attach at most ${MAX_CONTEXT_ITEMS} text items.`);
330
+ if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
253
331
  setContext((current) => {
254
- const next = mergeContext(current, picked);
255
- remember(draft, next, queue);
332
+ const next = mergeContext(current, attachments.context);
333
+ remember(draft, next, images, queue);
256
334
  return next;
257
335
  });
258
- }, (error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
336
+ setImages((current) => {
337
+ const next = mergeImages(current, attachments.images);
338
+ remember(draft, context, next, queue);
339
+ return next;
340
+ });
341
+ }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
342
+ };
343
+ const addImageFiles = (value, source) => {
344
+ const allFiles = Array.from(value ?? []);
345
+ if (!allFiles.length) return false;
346
+ const files = allFiles.filter((file) => file.type.startsWith("image/"));
347
+ if (files.length !== allFiles.length) {
348
+ setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
349
+ return true;
350
+ }
351
+ if (images.length + files.length > MAX_IMAGE_ITEMS) {
352
+ setPickerError(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
353
+ return true;
354
+ }
355
+ setPicking(true);
356
+ setPickerError(null);
357
+ imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
358
+ const next = mergeImages(current, picked);
359
+ remember(draft, context, next, queue);
360
+ return next;
361
+ }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
362
+ return true;
363
+ };
364
+ const pasteImages = (event) => {
365
+ if (addImageFiles(event.clipboardData?.files, "paste")) event.preventDefault();
366
+ };
367
+ const dropImages = (event) => {
368
+ setDragging(false);
369
+ if (addImageFiles(event.dataTransfer?.files, "drop")) event.preventDefault();
259
370
  };
260
371
  const send = () => {
261
372
  const text = draft.trim();
262
- if (!text) return;
263
- const message = { text, context };
373
+ if (!text && !images.length) return;
374
+ const message = { text, context, images };
264
375
  if (queuesNewMessage) updateQueue((items) => [...items, message]);
265
376
  else if (state.canSend) {
266
377
  if (onPending) setDispatching(true);
267
- onPending?.(text, context);
268
- adapter.onIntent({ action: "send", text, ...context.length ? { context } : {} });
378
+ onPending?.(text, context, images);
379
+ adapter.onIntent({ action: "send", text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
269
380
  } else return;
270
381
  setDraft("");
271
382
  setContext([]);
272
- remember("", [], queuesNewMessage ? [...queue, message] : queue);
383
+ setImages([]);
384
+ remember("", [], [], queuesNewMessage ? [...queue, message] : queue);
273
385
  };
274
386
  return /* @__PURE__ */ jsxs3("div", { class: "scui-compose", children: [
275
387
  queue.length ? /* @__PURE__ */ jsxs3("div", { class: "scui-queue", children: [
@@ -279,27 +391,41 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
279
391
  ] }),
280
392
  queue.map((item, index) => /* @__PURE__ */ jsxs3("span", { children: [
281
393
  /* @__PURE__ */ jsxs3("span", { children: [
282
- item.text,
283
- item.context.length ? /* @__PURE__ */ jsxs3("small", { children: [
284
- item.context.length,
394
+ item.text || "Image attachment",
395
+ item.context.length + item.images.length ? /* @__PURE__ */ jsxs3("small", { children: [
396
+ item.context.length + item.images.length,
285
397
  " attached"
286
398
  ] }) : null
287
399
  ] }),
288
400
  /* @__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
401
  ] }, `${index}:${item.text}`))
290
402
  ] }) : null,
403
+ /* @__PURE__ */ jsx3(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
404
+ const next = items.filter((_, itemIndex) => itemIndex !== index);
405
+ remember(draft, context, next, queue);
406
+ return next;
407
+ }) }),
291
408
  /* @__PURE__ */ jsx3(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
292
409
  const next = items.filter((_, itemIndex) => itemIndex !== index);
293
- remember(draft, next, queue);
410
+ remember(draft, next, images, queue);
294
411
  return next;
295
412
  }) }),
296
413
  pickerError ? /* @__PURE__ */ jsx3("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
297
- /* @__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) => {
414
+ /* @__PURE__ */ jsxs3("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
415
+ if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
416
+ event.preventDefault();
417
+ setDragging(true);
418
+ }
419
+ }, onDragOver: (event) => {
420
+ if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) event.preventDefault();
421
+ }, onDragLeave: (event) => {
422
+ if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
423
+ }, onDrop: dropImages, children: [
424
+ 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,
425
+ /* @__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
426
  const value = event.currentTarget.value;
301
427
  setDraft(value);
302
- remember(value, context, queue);
428
+ remember(value, context, images, queue);
303
429
  }, onKeyDown: (event) => {
304
430
  if (isSendKey(event)) {
305
431
  event.preventDefault();
@@ -308,7 +434,7 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
308
434
  } }),
309
435
  /* @__PURE__ */ jsxs3("span", { children: [
310
436
  state.busy ? /* @__PURE__ */ jsx3("button", { class: "scui-stop", type: "button", "aria-label": "Stop agent", disabled: !state.canInterrupt, onClick: () => adapter.onIntent({ action: "interrupt" }), children: /* @__PURE__ */ jsx3(UiIcon, { name: "stop", size: 15 }) }) : null,
311
- /* @__PURE__ */ jsx3("button", { class: "scui-send", type: "button", "aria-label": queuesNewMessage ? "Queue message" : "Send message", disabled: !draft.trim() || !queuesNewMessage && !state.canSend, onClick: send, children: /* @__PURE__ */ jsx3(UiIcon, { name: queuesNewMessage ? "plus" : "send", size: 17 }) })
437
+ /* @__PURE__ */ jsx3("button", { class: "scui-send", type: "button", "aria-label": queuesNewMessage ? "Queue message" : "Send message", disabled: !draft.trim() && !images.length || !queuesNewMessage && !state.canSend, onClick: send, children: /* @__PURE__ */ jsx3(UiIcon, { name: queuesNewMessage ? "plus" : "send", size: 17 }) })
312
438
  ] })
313
439
  ] })
314
440
  ] });
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 });