@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/README.md CHANGED
@@ -41,7 +41,7 @@ const mounted = mountSupercodeMessenger(document.querySelector('#agent'), {
41
41
  host.send(intent);
42
42
  },
43
43
  async pickContext() {
44
- return host.pickTextFiles();
44
+ return host.pickFilesAndImages();
45
45
  },
46
46
  onClose() {
47
47
  panel.close();
@@ -124,9 +124,12 @@ errors, and owned/attached identities without reimplementing transcript or capab
124
124
  `SupercodeController` snapshots and persisted inventory into it, then handles `SupercodeUiIntent`.
125
125
  The browser cannot supply locators, credentials, policy, environment variables, or arbitrary
126
126
  materialization paths. Session keys and target harnesses must be revalidated by the host.
127
- `pickContext` is optional and host-owned: the default composer shows its attachment control only
128
- when this callback exists, bounds the returned text context, and keeps it associated with queued
129
- messages and send recovery. The host still chooses how a user selects and reads that context.
127
+ `pickContext` is optional and host-owned: the default composer shows one attachment control when
128
+ this callback exists and accepts either typed text context (`detail`) or native image inputs
129
+ (`url`). Images can also be pasted directly. The composer bounds images to four supported browser
130
+ formats at 5 MB each, previews them locally, and keeps both attachment kinds associated with queued
131
+ messages, retry, edit, and new-chat recovery. The host still chooses how explicit file selection
132
+ is exposed and must revalidate every returned item.
130
133
 
131
134
  An embedding product such as Vibewaiting should therefore be small: Lucarne owns its iframe and
132
135
  launcher lifecycle, Supercode owns this UI and the controller semantics, and Vibewaiting only
package/components.mjs CHANGED
@@ -501,6 +501,16 @@ function readTranscript(value) {
501
501
  }] : [];
502
502
  });
503
503
  }
504
+ if (Array.isArray(item.images)) {
505
+ entry.images = item.images.flatMap((raw) => {
506
+ const image = record(raw);
507
+ return image && typeof image.label === "string" ? [{
508
+ ...typeof image.id === "string" ? { id: image.id } : {},
509
+ label: image.label,
510
+ ...typeof image.url === "string" ? { url: image.url } : {}
511
+ }] : [];
512
+ }).slice(0, 4);
513
+ }
504
514
  const request = record(item.request);
505
515
  if (request && typeof request.requestKind === "string" && typeof request.payloadText === "string") {
506
516
  entry.request = {
@@ -774,6 +784,11 @@ var ICONS = {
774
784
  /* @__PURE__ */ jsx("path", { d: "M9 3.5v10" }),
775
785
  /* @__PURE__ */ jsx("path", { d: "m4.75 9.5 4.25 4 4.25-4" })
776
786
  ] }),
787
+ image: () => /* @__PURE__ */ jsxs(Fragment, { children: [
788
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "3.5", width: "12", height: "11", rx: "1.75" }),
789
+ /* @__PURE__ */ jsx("circle", { cx: "6.5", cy: "7", r: "1.25" }),
790
+ /* @__PURE__ */ jsx("path", { d: "m4.5 13 3.25-3 2.1 1.85 1.65-1.5L14 13" })
791
+ ] }),
777
792
  menu: () => /* @__PURE__ */ jsxs(Fragment, { children: [
778
793
  /* @__PURE__ */ jsx("circle", { cx: "4", cy: "9", r: "1.25", fill: "currentColor", stroke: "none" }),
779
794
  /* @__PURE__ */ jsx("circle", { cx: "9", cy: "9", r: "1.25", fill: "currentColor", stroke: "none" }),
@@ -802,6 +817,9 @@ function UiIcon({ name, size = 16, class: className = "" }) {
802
817
  // src/context.jsx
803
818
  import { jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-runtime";
804
819
  var MAX_CONTEXT_ITEMS = 32;
820
+ var MAX_IMAGE_ITEMS = 4;
821
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
822
+ var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
805
823
  function normalizeContext(value) {
806
824
  return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
807
825
  if (!item || typeof item.label !== "string" || typeof item.detail !== "string") return [];
@@ -828,6 +846,58 @@ function mergeContext(current, picked) {
828
846
  }
829
847
  return next;
830
848
  }
849
+ function normalizeImages(value) {
850
+ const seen = /* @__PURE__ */ new Set();
851
+ return (Array.isArray(value) ? value : value ? [value] : []).flatMap((item) => {
852
+ if (!item || typeof item.label !== "string" || typeof item.url !== "string") return [];
853
+ const label = item.label.trim().slice(0, 200);
854
+ const url = item.url;
855
+ if (!label || seen.has(url) || !(url.startsWith("data:image/") || url.startsWith("https://") || url.startsWith("http://"))) return [];
856
+ seen.add(url);
857
+ return [{
858
+ ...typeof item.id === "string" && item.id ? { id: item.id.slice(0, 2e3) } : {},
859
+ label,
860
+ url
861
+ }];
862
+ }).slice(0, MAX_IMAGE_ITEMS);
863
+ }
864
+ function mergeImages(current, picked) {
865
+ const next = [...current];
866
+ const seen = new Set(current.map((item) => item.url));
867
+ for (const item of normalizeImages(picked)) {
868
+ if (seen.has(item.url)) continue;
869
+ seen.add(item.url);
870
+ next.push(item);
871
+ if (next.length === MAX_IMAGE_ITEMS) break;
872
+ }
873
+ return next;
874
+ }
875
+ function partitionAttachments(value) {
876
+ const values = Array.isArray(value) ? value : value ? [value] : [];
877
+ const context = [];
878
+ const images = [];
879
+ for (const item of values) {
880
+ if (item && typeof item.label === "string" && item.label.trim() && typeof item.detail === "string" && item.detail) context.push(item);
881
+ 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);
882
+ else throw new Error("The attachment picker returned an invalid item.");
883
+ }
884
+ return { context, images };
885
+ }
886
+ async function imageAttachmentsFromFiles(value) {
887
+ const files = Array.from(value ?? []).filter((file) => file?.type?.startsWith("image/"));
888
+ if (files.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images at a time.`);
889
+ return Promise.all(files.map(async (file) => {
890
+ if (!IMAGE_TYPES.has(file.type)) throw new Error(`${file.name || "That image"} is not PNG, JPEG, GIF, or WebP.`);
891
+ if (file.size > MAX_IMAGE_BYTES) throw new Error(`${file.name || "That image"} is larger than 5 MB.`);
892
+ const url = await new Promise((resolve, reject) => {
893
+ const reader = new FileReader();
894
+ reader.onload = () => resolve(reader.result);
895
+ reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name || "image"}.`));
896
+ reader.readAsDataURL(file);
897
+ });
898
+ return { id: `${file.name}:${file.size}:${file.lastModified}`, label: file.name || "Pasted image", url };
899
+ }));
900
+ }
831
901
  function ContextTray({ items, onRemove }) {
832
902
  if (!items.length) return null;
833
903
  return /* @__PURE__ */ jsx2("div", { class: "scui-compose-context", "aria-label": "Attached context", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
@@ -836,6 +906,21 @@ function ContextTray({ items, onRemove }) {
836
906
  /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `Remove context ${item.label}`, onClick: () => onRemove(index), children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 12 }) })
837
907
  ] }, item.id ?? `${item.label}:${index}`)) });
838
908
  }
909
+ function ImageTray({ items, onRemove }) {
910
+ if (!items.length) return null;
911
+ return /* @__PURE__ */ jsx2("div", { class: "scui-compose-images", "aria-label": "Attached images", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
912
+ /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }),
913
+ /* @__PURE__ */ jsx2("strong", { children: item.label }),
914
+ onRemove ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `Remove image ${item.label}`, onClick: () => onRemove(index), children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 12 }) }) : null
915
+ ] }, item.id ?? `${item.label}:${index}`)) });
916
+ }
917
+ function MessageImages({ items }) {
918
+ if (!items?.length) return null;
919
+ return /* @__PURE__ */ jsx2("div", { class: "scui-message-images", children: items.map((item, index) => item.url ? /* @__PURE__ */ jsx2("img", { src: item.url, alt: item.label }, item.id ?? `${item.label}:${index}`) : /* @__PURE__ */ jsxs2("span", { children: [
920
+ /* @__PURE__ */ jsx2(UiIcon, { name: "image", size: 14 }),
921
+ item.label
922
+ ] }, item.id ?? `${item.label}:${index}`)) });
923
+ }
839
924
 
840
925
  // src/textarea.js
841
926
  import { useLayoutEffect } from "preact/hooks";
@@ -875,19 +960,23 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
875
960
  ] });
876
961
  }
877
962
  function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
878
- const remembered = composerMemory.get(memoryKey) ?? { draft: state.savedDraft, context: [], queue: [] };
963
+ const remembered = composerMemory.get(memoryKey) ?? { draft: state.savedDraft, context: [], images: [], queue: [] };
879
964
  const [draft, setDraft] = useState(remembered.draft);
880
- const [context, setContext] = useState(remembered.context);
881
- const [queue, setQueue] = useState(remembered.queue);
965
+ const [context, setContext] = useState(remembered.context ?? []);
966
+ const [images, setImages] = useState(remembered.images ?? []);
967
+ const [queue, setQueue] = useState((remembered.queue ?? []).map((item) => ({ ...item, context: item.context ?? [], images: item.images ?? [] })));
882
968
  const [dispatching, setDispatching] = useState(false);
883
969
  const [picking, setPicking] = useState(false);
884
970
  const [pickerError, setPickerError] = useState(null);
885
971
  const textarea = useRef(null);
886
972
  useAutosizeTextarea(textarea, draft);
887
- const remember = (nextDraft, nextContext, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, queue: nextQueue });
973
+ const remember = (nextDraft, nextContext, nextImages, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, images: nextImages, queue: nextQueue });
974
+ useEffect(() => {
975
+ remember(draft, context, images, queue);
976
+ }, [draft, context, images, memoryKey, queue]);
888
977
  const updateQueue = (update) => setQueue((items) => {
889
978
  const next = update(items);
890
- remember(draft, context, next);
979
+ remember(draft, context, images, next);
891
980
  return next;
892
981
  });
893
982
  const queueBlocked = state.busy || pendingStatus !== null || dispatching;
@@ -897,9 +986,9 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
897
986
  const [next, ...rest] = queue;
898
987
  setDispatching(true);
899
988
  setQueue(rest);
900
- remember(draft, context, rest);
901
- onPending?.(next.text, next.context);
902
- adapter.onIntent({ action: "send", text: next.text, ...next.context.length ? { context: next.context } : {} });
989
+ remember(draft, context, images, rest);
990
+ onPending?.(next.text, next.context, next.images);
991
+ adapter.onIntent({ action: "send", text: next.text, ...next.context.length ? { context: next.context } : {}, ...next.images.length ? { images: next.images } : {} });
903
992
  }
904
993
  }, [adapter, draft, memoryKey, onPending, queue, queueBlocked, state.canSend]);
905
994
  useEffect(() => {
@@ -912,8 +1001,10 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
912
1001
  if (!restoreDraft) return;
913
1002
  setDraft(restoreDraft.text);
914
1003
  const restoredContext = normalizeContext(restoreDraft.context);
1004
+ const restoredImages = normalizeImages(restoreDraft.images);
915
1005
  setContext(restoredContext);
916
- remember(restoreDraft.text, restoredContext, queue);
1006
+ setImages(restoredImages);
1007
+ remember(restoreDraft.text, restoredContext, restoredImages, queue);
917
1008
  textarea.current?.focus({ preventScroll: true });
918
1009
  onDraftRestored?.(restoreDraft.id);
919
1010
  }, [onDraftRestored, restoreDraft?.id]);
@@ -922,30 +1013,55 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
922
1013
  return () => clearTimeout(timer);
923
1014
  }, [adapter, draft]);
924
1015
  const pickContext = () => {
925
- if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS) return;
1016
+ if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
926
1017
  setPicking(true);
927
1018
  setPickerError(null);
928
1019
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
1020
+ const attachments = partitionAttachments(picked);
1021
+ if (context.length + attachments.context.length > MAX_CONTEXT_ITEMS) throw new Error(`Attach at most ${MAX_CONTEXT_ITEMS} text items.`);
1022
+ if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
929
1023
  setContext((current) => {
930
- const next = mergeContext(current, picked);
931
- remember(draft, next, queue);
1024
+ const next = mergeContext(current, attachments.context);
1025
+ remember(draft, next, images, queue);
932
1026
  return next;
933
1027
  });
934
- }, (error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
1028
+ setImages((current) => {
1029
+ const next = mergeImages(current, attachments.images);
1030
+ remember(draft, context, next, queue);
1031
+ return next;
1032
+ });
1033
+ }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
1034
+ };
1035
+ const pasteImages = (event) => {
1036
+ const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/"));
1037
+ if (!files.length) return;
1038
+ event.preventDefault();
1039
+ if (images.length + files.length > MAX_IMAGE_ITEMS) {
1040
+ setPickerError(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
1041
+ return;
1042
+ }
1043
+ setPicking(true);
1044
+ setPickerError(null);
1045
+ imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
1046
+ const next = mergeImages(current, picked);
1047
+ remember(draft, context, next, queue);
1048
+ return next;
1049
+ }), (error) => setPickerError(error instanceof Error ? error.message : "Could not paste image.")).finally(() => setPicking(false));
935
1050
  };
936
1051
  const send = () => {
937
1052
  const text = draft.trim();
938
1053
  if (!text) return;
939
- const message = { text, context };
1054
+ const message = { text, context, images };
940
1055
  if (queuesNewMessage) updateQueue((items) => [...items, message]);
941
1056
  else if (state.canSend) {
942
1057
  if (onPending) setDispatching(true);
943
- onPending?.(text, context);
944
- adapter.onIntent({ action: "send", text, ...context.length ? { context } : {} });
1058
+ onPending?.(text, context, images);
1059
+ adapter.onIntent({ action: "send", text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
945
1060
  } else return;
946
1061
  setDraft("");
947
1062
  setContext([]);
948
- remember("", [], queuesNewMessage ? [...queue, message] : queue);
1063
+ setImages([]);
1064
+ remember("", [], [], queuesNewMessage ? [...queue, message] : queue);
949
1065
  };
950
1066
  return /* @__PURE__ */ jsxs3("div", { class: "scui-compose", children: [
951
1067
  queue.length ? /* @__PURE__ */ jsxs3("div", { class: "scui-queue", children: [
@@ -956,26 +1072,31 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
956
1072
  queue.map((item, index) => /* @__PURE__ */ jsxs3("span", { children: [
957
1073
  /* @__PURE__ */ jsxs3("span", { children: [
958
1074
  item.text,
959
- item.context.length ? /* @__PURE__ */ jsxs3("small", { children: [
960
- item.context.length,
1075
+ item.context.length + item.images.length ? /* @__PURE__ */ jsxs3("small", { children: [
1076
+ item.context.length + item.images.length,
961
1077
  " attached"
962
1078
  ] }) : null
963
1079
  ] }),
964
1080
  /* @__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 }) })
965
1081
  ] }, `${index}:${item.text}`))
966
1082
  ] }) : null,
1083
+ /* @__PURE__ */ jsx3(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
1084
+ const next = items.filter((_, itemIndex) => itemIndex !== index);
1085
+ remember(draft, context, next, queue);
1086
+ return next;
1087
+ }) }),
967
1088
  /* @__PURE__ */ jsx3(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
968
1089
  const next = items.filter((_, itemIndex) => itemIndex !== index);
969
- remember(draft, next, queue);
1090
+ remember(draft, next, images, queue);
970
1091
  return next;
971
1092
  }) }),
972
1093
  pickerError ? /* @__PURE__ */ jsx3("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
973
1094
  /* @__PURE__ */ jsxs3("div", { class: "scui-envelope", children: [
974
- 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,
975
- /* @__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) => {
1095
+ 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,
1096
+ /* @__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) => {
976
1097
  const value = event.currentTarget.value;
977
1098
  setDraft(value);
978
- remember(value, context, queue);
1099
+ remember(value, context, images, queue);
979
1100
  }, onKeyDown: (event) => {
980
1101
  if (isSendKey(event)) {
981
1102
  event.preventDefault();
@@ -1377,6 +1498,7 @@ function TranscriptEntry({ entry, state, adapter }) {
1377
1498
  }
1378
1499
  if (entry.role === "notice" || entry.role === "system") return /* @__PURE__ */ jsx5("div", { class: "scui-notice", "data-code": entry.code, children: entry.text });
1379
1500
  return /* @__PURE__ */ jsxs4("article", { class: "scui-message", "data-role": entry.role, "aria-label": `${entry.role === "user" ? "Your" : "Assistant"} message`, children: [
1501
+ /* @__PURE__ */ jsx5(MessageImages, { items: entry.images }),
1380
1502
  /* @__PURE__ */ jsx5(Markdown, { value: entry.text, copyText: adapter?.copyText }),
1381
1503
  /* @__PURE__ */ jsx5(ContextDisclosure, { context: entry.context }),
1382
1504
  entry.truncated ? /* @__PURE__ */ jsx5("small", { class: "scui-truncated", children: "Entry truncated by host \xB7 load native session for the complete value" }) : null,
@@ -1579,6 +1701,7 @@ function Conversation({ state, adapter, components = {}, slots = {}, memoryKey =
1579
1701
  block.kind === "activity" ? /* @__PURE__ */ jsx5(Group, { value: block.entries, entries: block.entries, state, adapter }) : /* @__PURE__ */ jsx5(Entry, { value: block.entry, entry: block.entry, state, adapter })
1580
1702
  ] }, block.id)),
1581
1703
  pendingMessage ? /* @__PURE__ */ jsxs4("article", { class: "scui-message scui-pending", "data-role": "user", "data-status": pendingMessage.status, "aria-label": "Your pending message", children: [
1704
+ /* @__PURE__ */ jsx5(MessageImages, { items: pendingMessage.images }),
1582
1705
  /* @__PURE__ */ jsx5(Markdown, { value: pendingMessage.text, copyText: adapter?.copyText }),
1583
1706
  /* @__PURE__ */ jsx5(ContextDisclosure, { context: pendingMessage.context }),
1584
1707
  /* @__PURE__ */ jsxs4("footer", { children: [
@@ -1923,9 +2046,10 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
1923
2046
  if (pending.status === "sending" && state.busy && !pending.seenBusy) setPending({ ...pending, seenBusy: true });
1924
2047
  else if (pending.status === "sending" && state.error && !state.busy && !state.operation && (pending.seenBusy || state.error !== pending.initialError)) setPending({ ...pending, status: "failed" });
1925
2048
  }, [pending, state.busy, state.error, state.operation, state.transcript]);
1926
- const beginPending = (text, context = []) => setPending({
2049
+ const beginPending = (text, context = [], images = []) => setPending({
1927
2050
  text,
1928
2051
  context,
2052
+ images,
1929
2053
  status: "sending",
1930
2054
  initialError: state.error,
1931
2055
  seenBusy: state.busy,
@@ -1933,13 +2057,13 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
1933
2057
  });
1934
2058
  const retryPending = () => {
1935
2059
  if (!pending) return;
1936
- adapter.onIntent({ action: "send", text: pending.text, ...pending.context?.length ? { context: pending.context } : {} });
2060
+ adapter.onIntent({ action: "send", text: pending.text, ...pending.context?.length ? { context: pending.context } : {}, ...pending.images?.length ? { images: pending.images } : {} });
1937
2061
  setPending({ ...pending, status: "sending", initialError: state.error, seenBusy: state.busy });
1938
2062
  };
1939
2063
  const editPending = () => {
1940
2064
  if (!pending) return;
1941
2065
  restoreSequence.current += 1;
1942
- setRestoreDraft({ id: restoreSequence.current, text: pending.text, context: pending.context });
2066
+ setRestoreDraft({ id: restoreSequence.current, text: pending.text, context: pending.context, images: pending.images });
1943
2067
  setPending({ ...pending, status: "editing" });
1944
2068
  };
1945
2069
  useEffect6(() => {
@@ -1985,7 +2109,7 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
1985
2109
  setPendingAction(null);
1986
2110
  }
1987
2111
  }, [pendingAction, state.error, state.operation]);
1988
- const pendingMessage = pending && pending.status !== "editing" ? { text: pending.text, context: pending.context, status: pending.status, onRetry: retryPending, onEdit: editPending } : null;
2112
+ const pendingMessage = pending && pending.status !== "editing" ? { text: pending.text, context: pending.context, images: pending.images, status: pending.status, onRetry: retryPending, onEdit: editPending } : null;
1989
2113
  const action = state.operation || pendingAction?.action || null;
1990
2114
  const actionLabel = operationLabel(action);
1991
2115
  const actionState = action ? { ...state, operation: action, canInterrupt: false, canRespond: false } : state;
@@ -2009,10 +2133,11 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2009
2133
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2010
2134
  const startable = state.harnesses.filter((item) => item.startable);
2011
2135
  const startableKey = startable.map((item) => item.id).join("\0");
2012
- const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [] };
2136
+ const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [] };
2013
2137
  const [harness, setHarness] = useState4(remembered.harness);
2014
2138
  const [draft, setDraft] = useState4(remembered.draft);
2015
2139
  const [context, setContext] = useState4(remembered.context);
2140
+ const [images, setImages] = useState4(remembered.images ?? []);
2016
2141
  const [starting, setStarting] = useState4(null);
2017
2142
  const [picking, setPicking] = useState4(false);
2018
2143
  const [pickerError, setPickerError] = useState4(null);
@@ -2023,7 +2148,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2023
2148
  if (startable.some((item) => item.id === harness)) return;
2024
2149
  const next = startable[0]?.id ?? "";
2025
2150
  setHarness(next);
2026
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context });
2151
+ boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2027
2152
  }, [harness, startableKey]);
2028
2153
  useEffect6(() => {
2029
2154
  if (!starting) return;
@@ -2040,16 +2165,40 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2040
2165
  textarea.current?.focus({ preventScroll: true });
2041
2166
  }, []);
2042
2167
  const pickContext = () => {
2043
- if (!adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS) return;
2168
+ if (!adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2044
2169
  setPicking(true);
2045
2170
  setPickerError(null);
2046
2171
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
2172
+ const attachments = partitionAttachments(picked);
2173
+ if (context.length + attachments.context.length > MAX_CONTEXT_ITEMS) throw new Error(`Attach at most ${MAX_CONTEXT_ITEMS} text items.`);
2174
+ if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2047
2175
  setContext((current) => {
2048
- const next = mergeContext(current, picked);
2049
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next });
2176
+ const next = mergeContext(current, attachments.context);
2177
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2050
2178
  return next;
2051
2179
  });
2052
- }, (error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
2180
+ setImages((current) => {
2181
+ const next = mergeImages(current, attachments.images);
2182
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2183
+ return next;
2184
+ });
2185
+ }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
2186
+ };
2187
+ const pasteImages = (event) => {
2188
+ const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/"));
2189
+ if (!files.length) return;
2190
+ event.preventDefault();
2191
+ if (images.length + files.length > MAX_IMAGE_ITEMS) {
2192
+ setPickerError(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2193
+ return;
2194
+ }
2195
+ setPicking(true);
2196
+ setPickerError(null);
2197
+ imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2198
+ const next = mergeImages(current, picked);
2199
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2200
+ return next;
2201
+ }), (error) => setPickerError(error instanceof Error ? error.message : "Could not paste image.")).finally(() => setPicking(false));
2053
2202
  };
2054
2203
  const send = () => {
2055
2204
  const text = draft.trim();
@@ -2057,7 +2206,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2057
2206
  const id = startSequence.current + 1;
2058
2207
  startSequence.current = id;
2059
2208
  setStarting({ id, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2060
- const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {} });
2209
+ const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2061
2210
  if (result && typeof result.then === "function") {
2062
2211
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2063
2212
  }
@@ -2087,24 +2236,29 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2087
2236
  /* @__PURE__ */ jsx8("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2088
2237
  const value = event.currentTarget.value;
2089
2238
  setHarness(value);
2090
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context });
2239
+ boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2091
2240
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs7("option", { value: item.id, disabled: !item.startable, children: [
2092
2241
  item.label,
2093
2242
  item.startable ? "" : " \xB7 unavailable"
2094
2243
  ] }, item.id)) })
2095
2244
  ] }),
2245
+ /* @__PURE__ */ jsx8(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2246
+ const next = items.filter((_, itemIndex) => itemIndex !== index);
2247
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2248
+ return next;
2249
+ }) }),
2096
2250
  /* @__PURE__ */ jsx8(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2097
2251
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2098
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next });
2252
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2099
2253
  return next;
2100
2254
  }) }),
2101
2255
  pickerError ? /* @__PURE__ */ jsx8("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2102
2256
  /* @__PURE__ */ jsxs7("div", { class: "scui-envelope", children: [
2103
- adapter.pickContext ? /* @__PURE__ */ jsx8("button", { class: "scui-attach", type: "button", "aria-label": "Attach context", disabled: picking || context.length >= MAX_CONTEXT_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx8("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx8(UiIcon, { name: "attach", size: 17 }) }) : null,
2104
- /* @__PURE__ */ jsx8("textarea", { ref: textarea, rows: 3, "aria-label": "Message coding agent", placeholder: startable.length ? "What should the agent do?" : "No coding harness is available", value: draft, disabled: !startable.length || Boolean(starting), onInput: (event) => {
2257
+ adapter.pickContext ? /* @__PURE__ */ jsx8("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx8("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx8(UiIcon, { name: "attach", size: 17 }) }) : null,
2258
+ /* @__PURE__ */ jsx8("textarea", { ref: textarea, rows: 3, "aria-label": "Message coding agent", placeholder: startable.length ? "What should the agent do?" : "No coding harness is available", value: draft, disabled: !startable.length || Boolean(starting), onPaste: pasteImages, onInput: (event) => {
2105
2259
  const value = event.currentTarget.value;
2106
2260
  setDraft(value);
2107
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context });
2261
+ boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2108
2262
  }, onKeyDown: (event) => {
2109
2263
  if (isSendKey(event)) {
2110
2264
  event.preventDefault();