Accept pasted images in inbox Other and Reply answers

This commit is contained in:
Ian Gulliver
2026-10-16 22:49:06 -07:00
parent a9bec7a7ea
commit ea90178fce
19 changed files with 579 additions and 469 deletions
+107
View File
@@ -0,0 +1,107 @@
import { post } from "./api.js";
import { h, replace, showAlert } from "./dom.js";
export const imageDrafts = new Map();
const imageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const maxImageBytes = 5 << 20;
export function imageTray(drop, input, key, onCount) {
const el = h("div", { class: "compose-images" });
const render = () => {
const imgs = imageDrafts.get(key) ?? [];
onCount(imgs.length);
el.hidden = imgs.length === 0;
replace(el, ...imgs.map((img) => {
const remove = h("button", { type: "button", class: "remove", title: "Remove image" }, "×");
remove.onclick = () => {
URL.revokeObjectURL(img.url);
const rest = (imageDrafts.get(key) ?? []).filter((i) => i !== img);
if (rest.length > 0) {
imageDrafts.set(key, rest);
}
else {
imageDrafts.delete(key);
}
render();
};
return h("div", { class: "thumb" }, h("img", { src: img.url, alt: "attached image" }), remove);
}));
};
const add = (files) => {
const added = [];
for (const f of files) {
if (!imageTypes.includes(f.type)) {
void showAlert(`${f.name || "That file"} isn't a PNG, JPEG, GIF or WebP image.`);
continue;
}
if (f.size > maxImageBytes) {
void showAlert(`${f.name || "That image"} is over ${maxImageBytes >> 20} MiB.`);
continue;
}
added.push({ file: f, url: URL.createObjectURL(f) });
}
if (added.length === 0) {
return false;
}
imageDrafts.set(key, [...(imageDrafts.get(key) ?? []), ...added]);
render();
return true;
};
input.addEventListener("paste", (e) => {
const files = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
if (files.length > 0) {
e.preventDefault();
add(files);
}
});
drop.addEventListener("dragover", (e) => {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
drop.classList.add("dropping");
}
});
drop.addEventListener("dragleave", (e) => {
if (!drop.contains(e.relatedTarget)) {
drop.classList.remove("dropping");
}
});
drop.addEventListener("drop", (e) => {
drop.classList.remove("dropping");
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length > 0) {
e.preventDefault();
add(files);
}
});
render();
return {
el,
take: () => {
const imgs = imageDrafts.get(key) ?? [];
imageDrafts.delete(key);
render();
return imgs;
},
restore: (imgs) => {
if (imgs.length > 0) {
imageDrafts.set(key, [...imgs, ...(imageDrafts.get(key) ?? [])]);
}
render();
},
};
}
export async function uploadImages(imgs) {
if (imgs.length === 0) {
return undefined;
}
const ids = [];
for (const img of imgs) {
const blob = await post("/api/blobs", { body: img.file });
ids.push(blob.id);
}
return ids;
}
export function releaseImages(imgs) {
for (const img of imgs) {
URL.revokeObjectURL(img.url);
}
}
//# sourceMappingURL=images.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"images.js","sourceRoot":"","sources":["../src/images.ts"],"names":[],"mappings":"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"}
+32 -20
View File
@@ -3,6 +3,7 @@ import { mountChanges } from "./changes.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { image, inlineMarkdown, markdown } from "./markdown.js";
import { checkedAnswer, clearAnswer, dropTextAnswer, flushDrafts, hasTextAnswer, setCheckedAnswer, setTextAnswer, textAnswer } from "./drafts.js";
import { imageDrafts, imageTray, releaseImages, uploadImages } from "./images.js";
import { diagram } from "./mermaid.js";
import { focusTab, onEach, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
@@ -90,13 +91,17 @@ function card(item) {
c.disabled = on;
}
};
const submit = async (response) => {
const imageKey = `inbox:${item.id}`;
const submit = async (response, tray) => {
if (busy()) {
return;
}
lock(true);
const imgs = tray?.take() ?? [];
try {
await post("/api/inbox/responses", { body: { responses: [response] } });
const images = await uploadImages(imgs);
await post("/api/inbox/responses", { body: { responses: [{ ...response, images }] } });
releaseImages(imgs);
clearAnswer(item.id);
const others = state.inbox.some((i) => i.task_id === item.task_id && i.id !== item.id);
if (!others && state.focus.tab === "inbox" && state.selectedTaskId === item.task_id) {
@@ -104,6 +109,7 @@ function card(item) {
}
}
catch (err) {
tray?.restore(imgs);
void showAlert(errorText(err));
lock(false);
renderActions();
@@ -139,10 +145,16 @@ function card(item) {
return h("label", { class: rec ? "option choice-box rec" : "option choice-box" }, h("div", { class: "choice-head" }, input, name, tag));
};
const textEntry = (label, placeholder, shared) => {
const open = hasTextAnswer(item.id);
const open = hasTextAnswer(item.id) || imageDrafts.has(imageKey);
const text = h("textarea", { rows: 3, placeholder, value: textAnswer(item.id) ?? "" });
const send = h("button", { type: "button", class: "primary", disabled: !text.value.trim() }, "Submit");
const editor = h("div", { class: "text-editor", hidden: !open }, text, !shared && h("div", { class: "submit-row" }, send));
const send = h("button", { type: "button", class: "primary" }, "Submit");
const editor = h("div", { class: "text-editor", hidden: !open });
const empty = () => !text.value.trim() && !imageDrafts.has(imageKey);
const tray = imageTray(editor, text, imageKey, () => {
send.disabled = empty();
shared?.onInput();
});
editor.append(tray.el, text, shared ? "" : h("div", { class: "submit-row" }, send));
const block = clickable(h("div", { class: "pick other", role: "button", tabIndex: 0, hidden: open }, label), () => {
setTextAnswer(item.id, "");
block.hidden = true;
@@ -151,21 +163,21 @@ function card(item) {
});
const go = () => {
if (shared) {
shared.onSubmit();
shared.onSubmit(tray);
return;
}
if (text.value.trim()) {
void submit({ item_id: item.id, type: "other", text: text.value });
if (!empty()) {
void submit({ item_id: item.id, type: "other", text: text.value }, tray);
}
};
send.onclick = go;
text.oninput = () => {
setTextAnswer(item.id, text.value);
send.disabled = !text.value.trim();
send.disabled = empty();
shared?.onInput();
};
text.onblur = () => {
if (!text.value.trim() && !busy()) {
if (empty() && !busy()) {
dropTextAnswer(item.id);
editor.hidden = true;
block.hidden = false;
@@ -179,7 +191,7 @@ function card(item) {
go();
}
};
return h("div", { class: "text-entry" }, block, editor);
return { el: h("div", { class: "text-entry" }, block, editor), tray };
};
const delegate = () => {
const button = h("button", { type: "button", class: "delegate" }, "Let the agent decide");
@@ -193,28 +205,28 @@ function card(item) {
const send = h("button", { type: "button", class: "primary" }, "Submit");
const typed = () => (textAnswer(item.id) ?? "").trim();
const refresh = () => {
send.disabled = !typed() && (!multiple || checkedAnswer(item.id).length === 0);
send.disabled = !typed() && !imageDrafts.has(imageKey) && (!multiple || checkedAnswer(item.id).length === 0);
};
const go = () => {
const go = (tray) => {
const options = multiple ? checkedAnswer(item.id) : [];
if (options.length > 0) {
void submit({ item_id: item.id, type: "option", options, text: typed() ? textAnswer(item.id) : undefined });
void submit({ item_id: item.id, type: "option", options, text: typed() ? textAnswer(item.id) : undefined }, tray);
return;
}
if (typed()) {
void submit({ item_id: item.id, type: "other", text: textAnswer(item.id) });
if (typed() || imageDrafts.has(imageKey)) {
void submit({ item_id: item.id, type: "other", text: textAnswer(item.id) ?? "" }, tray);
}
};
const other = textEntry("Other…", placeholder, { onInput: refresh, onSubmit: go });
send.onclick = go;
send.onclick = () => go(other.tray);
refresh();
replace(actions, other, h("div", { class: "submit-row" }, delegate(), send));
replace(actions, other.el, h("div", { class: "submit-row" }, delegate(), send));
return;
}
default: {
const dismiss = h("button", { type: "button" }, "Dismiss");
dismiss.onclick = () => void submit({ item_id: item.id, type: "dismiss" });
replace(actions, h("div", { class: "reply-row" }, textEntry("Reply…", "Reply to the agent"), dismiss));
replace(actions, h("div", { class: "reply-row" }, textEntry("Reply…", "Reply to the agent").el, dismiss));
}
}
};
@@ -251,7 +263,7 @@ function thread(item) {
if (item.responses.length === 0) {
return null;
}
return h("ul", { class: "thread" }, ...item.responses.map((r) => h("li", {}, h("strong", {}, r.user), ` ${r.type.replace("_", " ")}`, r.options ? `: ${r.options.join("; ")}` : "", r.text ? ` — ${r.text}` : "")));
return h("ul", { class: "thread" }, ...item.responses.map((r) => h("li", {}, h("strong", {}, r.user), ` ${r.type.replace("_", " ")}`, r.options ? `: ${r.options.join("; ")}` : "", r.text ? ` — ${r.text}` : "", r.images.length > 0 ? ` (${r.images.length === 1 ? "1 image" : `${r.images.length} images`})` : "")));
}
function attachments(list, taskId) {
if (list.length === 0) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -105
View File
@@ -4,6 +4,7 @@ import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { imageDrafts, imageTray, releaseImages, uploadImages } from "./images.js";
import { currentSession, findTask, focusTab, focusTask, markSummaryRead, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, transcriptLoading } from "./state.js";
import { taskStyle } from "./taskcolor.js";
import { fillTodoLinks } from "./todolink.js";
@@ -668,110 +669,6 @@ function lostImagesNote(key) {
};
return note;
}
const imageDrafts = new Map();
const imageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const maxImageBytes = 5 << 20;
function imageTray(form, input, key) {
const el = h("div", { class: "compose-images" });
const render = () => {
const imgs = imageDrafts.get(key) ?? [];
setComposerImages(key, imgs.length);
el.hidden = imgs.length === 0;
replace(el, ...imgs.map((img) => {
const remove = h("button", { type: "button", class: "remove", title: "Remove image" }, "×");
remove.onclick = () => {
URL.revokeObjectURL(img.url);
const rest = (imageDrafts.get(key) ?? []).filter((i) => i !== img);
if (rest.length > 0) {
imageDrafts.set(key, rest);
}
else {
imageDrafts.delete(key);
}
render();
};
return h("div", { class: "thumb" }, h("img", { src: img.url, alt: "attached image" }), remove);
}));
};
const add = (files) => {
const added = [];
for (const f of files) {
if (!imageTypes.includes(f.type)) {
void showAlert(`${f.name || "That file"} isn't a PNG, JPEG, GIF or WebP image.`);
continue;
}
if (f.size > maxImageBytes) {
void showAlert(`${f.name || "That image"} is over ${maxImageBytes >> 20} MiB.`);
continue;
}
added.push({ file: f, url: URL.createObjectURL(f) });
}
if (added.length === 0) {
return false;
}
imageDrafts.set(key, [...(imageDrafts.get(key) ?? []), ...added]);
render();
return true;
};
input.addEventListener("paste", (e) => {
const files = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
if (files.length > 0) {
e.preventDefault();
add(files);
}
});
form.addEventListener("dragover", (e) => {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
form.classList.add("dropping");
}
});
form.addEventListener("dragleave", (e) => {
if (!form.contains(e.relatedTarget)) {
form.classList.remove("dropping");
}
});
form.addEventListener("drop", (e) => {
form.classList.remove("dropping");
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length > 0) {
e.preventDefault();
add(files);
}
});
render();
return {
el,
take: () => {
const imgs = imageDrafts.get(key) ?? [];
imageDrafts.delete(key);
render();
return imgs;
},
restore: (imgs) => {
if (imgs.length > 0) {
imageDrafts.set(key, [...imgs, ...(imageDrafts.get(key) ?? [])]);
}
render();
},
};
}
async function uploadImages(imgs) {
if (imgs.length === 0) {
return undefined;
}
const ids = [];
for (const img of imgs) {
const blob = await post("/api/blobs", { body: img.file });
ids.push(blob.id);
}
return ids;
}
function releaseImages(imgs) {
for (const img of imgs) {
URL.revokeObjectURL(img.url);
}
}
const modelChoices = new Map();
function vendorOf(model) {
return model.startsWith("gpt-") ? "codex" : "claude";
@@ -806,7 +703,7 @@ function composeForm(src, key, sessionId, sessionState, currentModel, vendor, sh
const modelKey = `message:${sessionId ?? key}`;
const model = modelSelect(modelKey, currentModel, vendor);
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, draftKey);
const tray = imageTray(form, text, draftKey, (n) => setComposerImages(draftKey, n));
form.append(lostImagesNote(draftKey) ?? "", tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
+1 -1
View File
File diff suppressed because one or more lines are too long