@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/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,13 @@ 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 or dropped directly, and an image-only turn is sent without
130
+ inventing fallback prompt text. The composer bounds images to four supported browser formats at
131
+ 5 MB each, previews them locally, and keeps both attachment kinds associated with queued
132
+ messages, retry, edit, and new-chat recovery. The host still chooses how explicit file selection
133
+ is exposed and must revalidate every returned item.
130
134
 
131
135
  An embedding product such as Vibewaiting should therefore be small: Lucarne owns its iframe and
132
136
  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,24 @@ 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);
970
+ const [dragging, setDragging] = useState(false);
884
971
  const [pickerError, setPickerError] = useState(null);
885
972
  const textarea = useRef(null);
886
973
  useAutosizeTextarea(textarea, draft);
887
- const remember = (nextDraft, nextContext, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, queue: nextQueue });
974
+ const remember = (nextDraft, nextContext, nextImages, nextQueue) => boundedSet(composerMemory, memoryKey, { draft: nextDraft, context: nextContext, images: nextImages, queue: nextQueue });
975
+ useEffect(() => {
976
+ remember(draft, context, images, queue);
977
+ }, [draft, context, images, memoryKey, queue]);
888
978
  const updateQueue = (update) => setQueue((items) => {
889
979
  const next = update(items);
890
- remember(draft, context, next);
980
+ remember(draft, context, images, next);
891
981
  return next;
892
982
  });
893
983
  const queueBlocked = state.busy || pendingStatus !== null || dispatching;
@@ -897,9 +987,9 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
897
987
  const [next, ...rest] = queue;
898
988
  setDispatching(true);
899
989
  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 } : {} });
990
+ remember(draft, context, images, rest);
991
+ onPending?.(next.text, next.context, next.images);
992
+ adapter.onIntent({ action: "send", text: next.text, ...next.context.length ? { context: next.context } : {}, ...next.images.length ? { images: next.images } : {} });
903
993
  }
904
994
  }, [adapter, draft, memoryKey, onPending, queue, queueBlocked, state.canSend]);
905
995
  useEffect(() => {
@@ -912,8 +1002,10 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
912
1002
  if (!restoreDraft) return;
913
1003
  setDraft(restoreDraft.text);
914
1004
  const restoredContext = normalizeContext(restoreDraft.context);
1005
+ const restoredImages = normalizeImages(restoreDraft.images);
915
1006
  setContext(restoredContext);
916
- remember(restoreDraft.text, restoredContext, queue);
1007
+ setImages(restoredImages);
1008
+ remember(restoreDraft.text, restoredContext, restoredImages, queue);
917
1009
  textarea.current?.focus({ preventScroll: true });
918
1010
  onDraftRestored?.(restoreDraft.id);
919
1011
  }, [onDraftRestored, restoreDraft?.id]);
@@ -922,30 +1014,67 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
922
1014
  return () => clearTimeout(timer);
923
1015
  }, [adapter, draft]);
924
1016
  const pickContext = () => {
925
- if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS) return;
1017
+ if (!adapter.pickContext || state.mode !== "control" || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
926
1018
  setPicking(true);
927
1019
  setPickerError(null);
928
1020
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
1021
+ const attachments = partitionAttachments(picked);
1022
+ if (context.length + attachments.context.length > MAX_CONTEXT_ITEMS) throw new Error(`Attach at most ${MAX_CONTEXT_ITEMS} text items.`);
1023
+ if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
929
1024
  setContext((current) => {
930
- const next = mergeContext(current, picked);
931
- remember(draft, next, queue);
1025
+ const next = mergeContext(current, attachments.context);
1026
+ remember(draft, next, images, queue);
932
1027
  return next;
933
1028
  });
934
- }, (error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
1029
+ setImages((current) => {
1030
+ const next = mergeImages(current, attachments.images);
1031
+ remember(draft, context, next, queue);
1032
+ return next;
1033
+ });
1034
+ }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
1035
+ };
1036
+ const addImageFiles = (value, source) => {
1037
+ const allFiles = Array.from(value ?? []);
1038
+ if (!allFiles.length) return false;
1039
+ const files = allFiles.filter((file) => file.type.startsWith("image/"));
1040
+ if (files.length !== allFiles.length) {
1041
+ setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
1042
+ return true;
1043
+ }
1044
+ if (images.length + files.length > MAX_IMAGE_ITEMS) {
1045
+ setPickerError(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
1046
+ return true;
1047
+ }
1048
+ setPicking(true);
1049
+ setPickerError(null);
1050
+ imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
1051
+ const next = mergeImages(current, picked);
1052
+ remember(draft, context, next, queue);
1053
+ return next;
1054
+ }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
1055
+ return true;
1056
+ };
1057
+ const pasteImages = (event) => {
1058
+ if (addImageFiles(event.clipboardData?.files, "paste")) event.preventDefault();
1059
+ };
1060
+ const dropImages = (event) => {
1061
+ setDragging(false);
1062
+ if (addImageFiles(event.dataTransfer?.files, "drop")) event.preventDefault();
935
1063
  };
936
1064
  const send = () => {
937
1065
  const text = draft.trim();
938
- if (!text) return;
939
- const message = { text, context };
1066
+ if (!text && !images.length) return;
1067
+ const message = { text, context, images };
940
1068
  if (queuesNewMessage) updateQueue((items) => [...items, message]);
941
1069
  else if (state.canSend) {
942
1070
  if (onPending) setDispatching(true);
943
- onPending?.(text, context);
944
- adapter.onIntent({ action: "send", text, ...context.length ? { context } : {} });
1071
+ onPending?.(text, context, images);
1072
+ adapter.onIntent({ action: "send", text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
945
1073
  } else return;
946
1074
  setDraft("");
947
1075
  setContext([]);
948
- remember("", [], queuesNewMessage ? [...queue, message] : queue);
1076
+ setImages([]);
1077
+ remember("", [], [], queuesNewMessage ? [...queue, message] : queue);
949
1078
  };
950
1079
  return /* @__PURE__ */ jsxs3("div", { class: "scui-compose", children: [
951
1080
  queue.length ? /* @__PURE__ */ jsxs3("div", { class: "scui-queue", children: [
@@ -955,27 +1084,41 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
955
1084
  ] }),
956
1085
  queue.map((item, index) => /* @__PURE__ */ jsxs3("span", { children: [
957
1086
  /* @__PURE__ */ jsxs3("span", { children: [
958
- item.text,
959
- item.context.length ? /* @__PURE__ */ jsxs3("small", { children: [
960
- item.context.length,
1087
+ item.text || "Image attachment",
1088
+ item.context.length + item.images.length ? /* @__PURE__ */ jsxs3("small", { children: [
1089
+ item.context.length + item.images.length,
961
1090
  " attached"
962
1091
  ] }) : null
963
1092
  ] }),
964
1093
  /* @__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
1094
  ] }, `${index}:${item.text}`))
966
1095
  ] }) : null,
1096
+ /* @__PURE__ */ jsx3(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
1097
+ const next = items.filter((_, itemIndex) => itemIndex !== index);
1098
+ remember(draft, context, next, queue);
1099
+ return next;
1100
+ }) }),
967
1101
  /* @__PURE__ */ jsx3(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
968
1102
  const next = items.filter((_, itemIndex) => itemIndex !== index);
969
- remember(draft, next, queue);
1103
+ remember(draft, next, images, queue);
970
1104
  return next;
971
1105
  }) }),
972
1106
  pickerError ? /* @__PURE__ */ jsx3("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
973
- /* @__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) => {
1107
+ /* @__PURE__ */ jsxs3("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
1108
+ if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
1109
+ event.preventDefault();
1110
+ setDragging(true);
1111
+ }
1112
+ }, onDragOver: (event) => {
1113
+ if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) event.preventDefault();
1114
+ }, onDragLeave: (event) => {
1115
+ if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
1116
+ }, onDrop: dropImages, children: [
1117
+ 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,
1118
+ /* @__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
1119
  const value = event.currentTarget.value;
977
1120
  setDraft(value);
978
- remember(value, context, queue);
1121
+ remember(value, context, images, queue);
979
1122
  }, onKeyDown: (event) => {
980
1123
  if (isSendKey(event)) {
981
1124
  event.preventDefault();
@@ -984,7 +1127,7 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
984
1127
  } }),
985
1128
  /* @__PURE__ */ jsxs3("span", { children: [
986
1129
  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,
987
- /* @__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 }) })
1130
+ /* @__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 }) })
988
1131
  ] })
989
1132
  ] })
990
1133
  ] });
@@ -1377,6 +1520,7 @@ function TranscriptEntry({ entry, state, adapter }) {
1377
1520
  }
1378
1521
  if (entry.role === "notice" || entry.role === "system") return /* @__PURE__ */ jsx5("div", { class: "scui-notice", "data-code": entry.code, children: entry.text });
1379
1522
  return /* @__PURE__ */ jsxs4("article", { class: "scui-message", "data-role": entry.role, "aria-label": `${entry.role === "user" ? "Your" : "Assistant"} message`, children: [
1523
+ /* @__PURE__ */ jsx5(MessageImages, { items: entry.images }),
1380
1524
  /* @__PURE__ */ jsx5(Markdown, { value: entry.text, copyText: adapter?.copyText }),
1381
1525
  /* @__PURE__ */ jsx5(ContextDisclosure, { context: entry.context }),
1382
1526
  entry.truncated ? /* @__PURE__ */ jsx5("small", { class: "scui-truncated", children: "Entry truncated by host \xB7 load native session for the complete value" }) : null,
@@ -1579,6 +1723,7 @@ function Conversation({ state, adapter, components = {}, slots = {}, memoryKey =
1579
1723
  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
1724
  ] }, block.id)),
1581
1725
  pendingMessage ? /* @__PURE__ */ jsxs4("article", { class: "scui-message scui-pending", "data-role": "user", "data-status": pendingMessage.status, "aria-label": "Your pending message", children: [
1726
+ /* @__PURE__ */ jsx5(MessageImages, { items: pendingMessage.images }),
1582
1727
  /* @__PURE__ */ jsx5(Markdown, { value: pendingMessage.text, copyText: adapter?.copyText }),
1583
1728
  /* @__PURE__ */ jsx5(ContextDisclosure, { context: pendingMessage.context }),
1584
1729
  /* @__PURE__ */ jsxs4("footer", { children: [
@@ -1923,9 +2068,10 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
1923
2068
  if (pending.status === "sending" && state.busy && !pending.seenBusy) setPending({ ...pending, seenBusy: true });
1924
2069
  else if (pending.status === "sending" && state.error && !state.busy && !state.operation && (pending.seenBusy || state.error !== pending.initialError)) setPending({ ...pending, status: "failed" });
1925
2070
  }, [pending, state.busy, state.error, state.operation, state.transcript]);
1926
- const beginPending = (text, context = []) => setPending({
2071
+ const beginPending = (text, context = [], images = []) => setPending({
1927
2072
  text,
1928
2073
  context,
2074
+ images,
1929
2075
  status: "sending",
1930
2076
  initialError: state.error,
1931
2077
  seenBusy: state.busy,
@@ -1933,13 +2079,13 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
1933
2079
  });
1934
2080
  const retryPending = () => {
1935
2081
  if (!pending) return;
1936
- adapter.onIntent({ action: "send", text: pending.text, ...pending.context?.length ? { context: pending.context } : {} });
2082
+ adapter.onIntent({ action: "send", text: pending.text, ...pending.context?.length ? { context: pending.context } : {}, ...pending.images?.length ? { images: pending.images } : {} });
1937
2083
  setPending({ ...pending, status: "sending", initialError: state.error, seenBusy: state.busy });
1938
2084
  };
1939
2085
  const editPending = () => {
1940
2086
  if (!pending) return;
1941
2087
  restoreSequence.current += 1;
1942
- setRestoreDraft({ id: restoreSequence.current, text: pending.text, context: pending.context });
2088
+ setRestoreDraft({ id: restoreSequence.current, text: pending.text, context: pending.context, images: pending.images });
1943
2089
  setPending({ ...pending, status: "editing" });
1944
2090
  };
1945
2091
  useEffect6(() => {
@@ -1985,7 +2131,7 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
1985
2131
  setPendingAction(null);
1986
2132
  }
1987
2133
  }, [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;
2134
+ const pendingMessage = pending && pending.status !== "editing" ? { text: pending.text, context: pending.context, images: pending.images, status: pending.status, onRetry: retryPending, onEdit: editPending } : null;
1989
2135
  const action = state.operation || pendingAction?.action || null;
1990
2136
  const actionLabel = operationLabel(action);
1991
2137
  const actionState = action ? { ...state, operation: action, canInterrupt: false, canRespond: false } : state;
@@ -2009,12 +2155,14 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2009
2155
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2010
2156
  const startable = state.harnesses.filter((item) => item.startable);
2011
2157
  const startableKey = startable.map((item) => item.id).join("\0");
2012
- const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [] };
2158
+ const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [] };
2013
2159
  const [harness, setHarness] = useState4(remembered.harness);
2014
2160
  const [draft, setDraft] = useState4(remembered.draft);
2015
2161
  const [context, setContext] = useState4(remembered.context);
2162
+ const [images, setImages] = useState4(remembered.images ?? []);
2016
2163
  const [starting, setStarting] = useState4(null);
2017
2164
  const [picking, setPicking] = useState4(false);
2165
+ const [dragging, setDragging] = useState4(false);
2018
2166
  const [pickerError, setPickerError] = useState4(null);
2019
2167
  const startSequence = useRef5(0);
2020
2168
  const textarea = useRef5(null);
@@ -2023,7 +2171,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2023
2171
  if (startable.some((item) => item.id === harness)) return;
2024
2172
  const next = startable[0]?.id ?? "";
2025
2173
  setHarness(next);
2026
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context });
2174
+ boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2027
2175
  }, [harness, startableKey]);
2028
2176
  useEffect6(() => {
2029
2177
  if (!starting) return;
@@ -2040,24 +2188,60 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2040
2188
  textarea.current?.focus({ preventScroll: true });
2041
2189
  }, []);
2042
2190
  const pickContext = () => {
2043
- if (!adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS) return;
2191
+ if (!adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2044
2192
  setPicking(true);
2045
2193
  setPickerError(null);
2046
2194
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
2195
+ const attachments = partitionAttachments(picked);
2196
+ if (context.length + attachments.context.length > MAX_CONTEXT_ITEMS) throw new Error(`Attach at most ${MAX_CONTEXT_ITEMS} text items.`);
2197
+ if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2047
2198
  setContext((current) => {
2048
- const next = mergeContext(current, picked);
2049
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next });
2199
+ const next = mergeContext(current, attachments.context);
2200
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2201
+ return next;
2202
+ });
2203
+ setImages((current) => {
2204
+ const next = mergeImages(current, attachments.images);
2205
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2050
2206
  return next;
2051
2207
  });
2052
- }, (error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
2208
+ }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
2209
+ };
2210
+ const addImageFiles = (value, source) => {
2211
+ const allFiles = Array.from(value ?? []);
2212
+ if (!allFiles.length) return false;
2213
+ const files = allFiles.filter((file) => file.type.startsWith("image/"));
2214
+ if (files.length !== allFiles.length) {
2215
+ setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
2216
+ return true;
2217
+ }
2218
+ if (images.length + files.length > MAX_IMAGE_ITEMS) {
2219
+ setPickerError(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2220
+ return true;
2221
+ }
2222
+ setPicking(true);
2223
+ setPickerError(null);
2224
+ imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2225
+ const next = mergeImages(current, picked);
2226
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2227
+ return next;
2228
+ }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
2229
+ return true;
2230
+ };
2231
+ const pasteImages = (event) => {
2232
+ if (addImageFiles(event.clipboardData?.files, "paste")) event.preventDefault();
2233
+ };
2234
+ const dropImages = (event) => {
2235
+ setDragging(false);
2236
+ if (addImageFiles(event.dataTransfer?.files, "drop")) event.preventDefault();
2053
2237
  };
2054
2238
  const send = () => {
2055
2239
  const text = draft.trim();
2056
- if (!text || !harness || starting) return;
2240
+ if (!text && !images.length || !harness || starting) return;
2057
2241
  const id = startSequence.current + 1;
2058
2242
  startSequence.current = id;
2059
2243
  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 } : {} });
2244
+ const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2061
2245
  if (result && typeof result.then === "function") {
2062
2246
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2063
2247
  }
@@ -2087,31 +2271,45 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2087
2271
  /* @__PURE__ */ jsx8("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2088
2272
  const value = event.currentTarget.value;
2089
2273
  setHarness(value);
2090
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context });
2274
+ boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2091
2275
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs7("option", { value: item.id, disabled: !item.startable, children: [
2092
2276
  item.label,
2093
2277
  item.startable ? "" : " \xB7 unavailable"
2094
2278
  ] }, item.id)) })
2095
2279
  ] }),
2280
+ /* @__PURE__ */ jsx8(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2281
+ const next = items.filter((_, itemIndex) => itemIndex !== index);
2282
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2283
+ return next;
2284
+ }) }),
2096
2285
  /* @__PURE__ */ jsx8(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2097
2286
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2098
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next });
2287
+ boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2099
2288
  return next;
2100
2289
  }) }),
2101
2290
  pickerError ? /* @__PURE__ */ jsx8("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2102
- /* @__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) => {
2291
+ /* @__PURE__ */ jsxs7("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
2292
+ if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
2293
+ event.preventDefault();
2294
+ setDragging(true);
2295
+ }
2296
+ }, onDragOver: (event) => {
2297
+ if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) event.preventDefault();
2298
+ }, onDragLeave: (event) => {
2299
+ if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
2300
+ }, onDrop: dropImages, children: [
2301
+ 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,
2302
+ /* @__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
2303
  const value = event.currentTarget.value;
2106
2304
  setDraft(value);
2107
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context });
2305
+ boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2108
2306
  }, onKeyDown: (event) => {
2109
2307
  if (isSendKey(event)) {
2110
2308
  event.preventDefault();
2111
2309
  send();
2112
2310
  }
2113
2311
  } }),
2114
- /* @__PURE__ */ jsx8("span", { children: /* @__PURE__ */ jsx8("button", { type: "button", class: "scui-send", "aria-label": "Start chat", disabled: !draft.trim() || !harness || Boolean(starting), onClick: send, children: starting ? /* @__PURE__ */ jsx8("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx8(UiIcon, { name: "send", size: 17 }) }) })
2312
+ /* @__PURE__ */ jsx8("span", { children: /* @__PURE__ */ jsx8("button", { type: "button", class: "scui-send", "aria-label": "Start chat", disabled: !draft.trim() && !images.length || !harness || Boolean(starting), onClick: send, children: starting ? /* @__PURE__ */ jsx8("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx8(UiIcon, { name: "send", size: 17 }) }) })
2115
2313
  ] })
2116
2314
  ] })
2117
2315
  ] });