fix(ui): attach pasted data image text (#85392)

This commit is contained in:
Peter Steinberger
2026-05-22 17:35:14 +01:00
committed by GitHub
parent d9c6c5f600
commit a03a8d91f6
3 changed files with 66 additions and 0 deletions
+1
View File
@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai
- CLI/update: keep managed Gateway service stop/restart status lines out of `openclaw update --json` stdout so package-update automation can parse the JSON payload.
- Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution.
- Agents/OpenAI: preserve Responses and Chat Completions `reasoning_tokens` usage metadata without double-counting it in aggregate output tokens. (#85319)
- Control UI/chat: convert pasted `data:image/...;base64,...` clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi.
- Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr.
- OpenAI Codex: preserve image input support for sparse `openai-codex/gpt-5.5` catalog rows. (#85095) Thanks @sercada.
- Plugins/discovery: strip `-plugin` package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.
+29
View File
@@ -1039,6 +1039,35 @@ describe("chat slash menu accessibility", () => {
});
describe("chat attachment picker", () => {
it("converts pasted data image text into an attachment", () => {
const onAttachmentsChange = vi.fn();
const container = renderChatView({ onAttachmentsChange });
const textarea = requireElement(
container,
".agent-chat__composer-combobox > textarea",
"composer textarea",
);
const base64 = btoa("png");
const dataUrl = ` data:image/PNG;base64,${base64.slice(0, 2)}\n${base64.slice(2)} `;
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
value: {
items: { length: 0 },
getData: (type: string) => (type === "text/plain" ? dataUrl : ""),
},
});
const allowed = textarea.dispatchEvent(event);
expect(allowed).toBe(false);
const attachments = requireFirstAttachmentsChange(onAttachmentsChange);
expect(attachments).toHaveLength(1);
expect(attachments[0]?.fileName).toBe("pasted-image.png");
expect(attachments[0]?.mimeType).toBe("image/png");
expect(attachments[0]?.sizeBytes).toBe(3);
expect(getChatAttachmentDataUrl(attachments[0])).toBe(`data:image/png;base64,${base64}`);
});
it("accepts and previews non-video file attachments", async () => {
const onAttachmentsChange = vi.fn();
const container = renderChatView({ onAttachmentsChange });
+36
View File
@@ -453,6 +453,32 @@ function chatAttachmentFromFile(file: File, dataUrl: string): ChatAttachment {
return registerChatAttachmentPayload({ attachment, dataUrl, file });
}
function dataImageClipboardFile(dataUrl: string): { file: File; dataUrl: string } | null {
const match = /^\s*data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)\s*$/i.exec(dataUrl);
if (!match) {
return null;
}
const mimeType = match[1].toLowerCase();
if (!isSupportedChatAttachmentFile({ name: "pasted-image", type: mimeType })) {
return null;
}
const base64 = match[2].replace(/\s+/g, "");
try {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png";
return {
file: new File([bytes], `pasted-image.${extension}`, { type: mimeType }),
dataUrl: `data:${mimeType};base64,${base64}`,
};
} catch {
return null;
}
}
function isImageAttachment(att: ChatAttachment): boolean {
return att.mimeType.startsWith("image/");
}
@@ -470,6 +496,16 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) {
}
}
if (imageItems.length === 0) {
const text = e.clipboardData?.getData("text/plain");
const pasted = text ? dataImageClipboardFile(text) : null;
if (!pasted) {
return;
}
e.preventDefault();
props.onAttachmentsChange([
...(props.attachments ?? []),
chatAttachmentFromFile(pasted.file, pasted.dataUrl),
]);
return;
}
e.preventDefault();