mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): turn large pasted text into compact composer attachments (#100929)
* fix(ui): compact large pasted text in composer * fix(ui): turn large pasted text into compact composer attachments * fix(ui): turn large pasted text into compact composer attachments * Fix large paste composer CI * Use translated restore label * Fix large paste review findings * Mark generated paste attachments by identity --------- Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
a20314d6cc
commit
2c5715238d
Generated
-7
@@ -309,13 +309,6 @@
|
||||
"path": "ui/src/pages/channels/view.nostr.ts",
|
||||
"text": "NIP-05"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/chat/components/chat-composer.ts",
|
||||
"text": "×"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"kind": "html-text",
|
||||
|
||||
@@ -1924,6 +1924,7 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
|
||||
onScrollToBottom: state.scrollToBottom,
|
||||
attachments: state.chatAttachments,
|
||||
getAttachments: () => state.chatAttachments,
|
||||
onAttachmentsChange: (next) => {
|
||||
state.chatAttachments = next;
|
||||
state.requestUpdate?.();
|
||||
|
||||
@@ -2457,6 +2457,49 @@ describe("handleSendChat", () => {
|
||||
expect(getChatAttachmentDataUrl(attachment)).toBeNull();
|
||||
});
|
||||
|
||||
it("sends pasted plain text attachments as file payloads", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return { status: "started" };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const text = "large paste\n" + "x".repeat(1100);
|
||||
const file = new File([text], "pasted-text-123.txt", { type: "text/plain" });
|
||||
const attachment = registerChatAttachmentPayload({
|
||||
attachment: {
|
||||
id: "pasted-text-att",
|
||||
mimeType: "text/plain",
|
||||
fileName: "pasted-text-123.txt",
|
||||
sizeBytes: file.size,
|
||||
},
|
||||
dataUrl: `data:text/plain;base64,${btoa(text)}`,
|
||||
file,
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatAttachments: [attachment],
|
||||
chatMessage: "summarize this",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
const payload = findRequestPayload(
|
||||
request as unknown as MockCallSource,
|
||||
"chat.send",
|
||||
"chat send payload",
|
||||
);
|
||||
expect(payload.message).toBe("summarize this");
|
||||
expect(payload.attachments).toStrictEqual([
|
||||
{
|
||||
type: "file",
|
||||
mimeType: "text/plain",
|
||||
fileName: "pasted-text-123.txt",
|
||||
content: btoa(text),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not cross-gate case-distinct opaque Matrix sessions", async () => {
|
||||
const otherSessionSwitch = createDeferred<boolean>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "../../test-helpers/chat-model.ts";
|
||||
import {
|
||||
getChatAttachmentDataUrl,
|
||||
registerChatAttachmentPayload,
|
||||
resetChatAttachmentPayloadStoreForTest,
|
||||
} from "./attachment-payload-store.ts";
|
||||
import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts";
|
||||
@@ -3526,6 +3527,347 @@ describe("chat slash menu accessibility", () => {
|
||||
});
|
||||
|
||||
describe("chat attachment picker", () => {
|
||||
it("turns large pasted plain text into a compact attachment", async () => {
|
||||
const onAttachmentsChange = vi.fn();
|
||||
const container = renderChatView({
|
||||
draft: "intro",
|
||||
getDraft: () => "intro",
|
||||
onAttachmentsChange,
|
||||
});
|
||||
const textarea = requireElement(
|
||||
container,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const pastedText = "large paste\n" + "x".repeat(1100);
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? pastedText : ""),
|
||||
},
|
||||
});
|
||||
|
||||
const allowed = textarea.dispatchEvent(event);
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
await vi.waitFor(() => {
|
||||
const attachments = requireFirstAttachmentsChange(onAttachmentsChange);
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0]?.fileName).toMatch(/^pasted-text-\d+\.txt$/u);
|
||||
expect(attachments[0]?.mimeType).toBe("text/plain");
|
||||
expect(attachments[0]?.sizeBytes).toBe(new Blob([pastedText]).size);
|
||||
expect(getChatAttachmentDataUrl(attachments[0])).toMatch(/^data:text\/plain;base64,/u);
|
||||
});
|
||||
});
|
||||
|
||||
it("turns large rich-text clipboard content into a text attachment", () => {
|
||||
const onAttachmentsChange = vi.fn();
|
||||
const container = renderChatView({ onAttachmentsChange });
|
||||
const textarea = requireElement(
|
||||
container,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const pastedText = `large rich-text paste ${"x".repeat(1100)}`;
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
items: {
|
||||
0: { type: "text/plain" },
|
||||
1: { type: "text/html" },
|
||||
length: 2,
|
||||
},
|
||||
getData: (type: string) => (type === "text/plain" ? pastedText : "<p>rich text</p>"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(textarea.dispatchEvent(event)).toBe(false);
|
||||
expect(requireFirstAttachmentsChange(onAttachmentsChange)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers a large paste before an immediate send", () => {
|
||||
let attachments: ChatAttachment[] = [];
|
||||
const onSend = vi.fn(() => {
|
||||
expect(attachments).toHaveLength(1);
|
||||
});
|
||||
const container = renderChatView({
|
||||
attachments,
|
||||
getAttachments: () => attachments,
|
||||
onAttachmentsChange: (next) => {
|
||||
attachments = next;
|
||||
},
|
||||
onSend,
|
||||
});
|
||||
const textarea = requireElement(
|
||||
container,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const pastedText = `large paste ${"x".repeat(1100)}`;
|
||||
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(pasteEvent, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? pastedText : ""),
|
||||
},
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(pasteEvent);
|
||||
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("merges successive large pastes into the current attachment state", () => {
|
||||
let attachments: ChatAttachment[] = [];
|
||||
const onAttachmentsChange = vi.fn((next: ChatAttachment[]) => {
|
||||
attachments = next;
|
||||
});
|
||||
const container = renderChatView({
|
||||
attachments,
|
||||
getAttachments: () => attachments,
|
||||
onAttachmentsChange,
|
||||
});
|
||||
const textarea = requireElement(
|
||||
container,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const paste = (text: string) => {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? text : ""),
|
||||
},
|
||||
});
|
||||
textarea.dispatchEvent(event);
|
||||
};
|
||||
const firstText = `first ${"a".repeat(1100)}`;
|
||||
const secondText = `second ${"b".repeat(1100)}`;
|
||||
|
||||
paste(firstText);
|
||||
paste(secondText);
|
||||
|
||||
expect(attachments).toHaveLength(2);
|
||||
expect(attachments.map((attachment) => getChatAttachmentDataUrl(attachment))).toEqual([
|
||||
`data:text/plain;base64,${btoa(firstText)}`,
|
||||
`data:text/plain;base64,${btoa(secondText)}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a large paste when a dropped file finishes later", async () => {
|
||||
const readers: FileReader[] = [];
|
||||
const readAsDataUrl = vi
|
||||
.spyOn(FileReader.prototype, "readAsDataURL")
|
||||
.mockImplementation(function (this: FileReader) {
|
||||
readers.push(this);
|
||||
});
|
||||
let attachments: ChatAttachment[] = [];
|
||||
const onAttachmentsChange = vi.fn((next: ChatAttachment[]) => {
|
||||
attachments = next;
|
||||
});
|
||||
const container = renderChatView({
|
||||
attachments,
|
||||
getAttachments: () => attachments,
|
||||
onAttachmentsChange,
|
||||
});
|
||||
const textarea = requireElement(
|
||||
container,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const chat = requireElement(container, "section.card.chat", "chat drop target");
|
||||
const pastedText = `large paste ${"x".repeat(1100)}`;
|
||||
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(pasteEvent, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? pastedText : ""),
|
||||
},
|
||||
});
|
||||
const droppedFile = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" });
|
||||
const dropEvent = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(dropEvent, "dataTransfer", { value: { files: [droppedFile] } });
|
||||
|
||||
try {
|
||||
textarea.dispatchEvent(pasteEvent);
|
||||
chat.dispatchEvent(dropEvent);
|
||||
|
||||
expect(readers).toHaveLength(1);
|
||||
expect(attachments).toHaveLength(1);
|
||||
Object.defineProperty(readers[0], "result", {
|
||||
configurable: true,
|
||||
value: `data:application/pdf;base64,${btoa("%PDF-1.4\n")}`,
|
||||
});
|
||||
readers[0].dispatchEvent(new ProgressEvent("load"));
|
||||
|
||||
await vi.waitFor(() => expect(attachments).toHaveLength(2));
|
||||
expect(attachments.map((attachment) => attachment.fileName)).toEqual([
|
||||
expect.stringMatching(/^pasted-text-\d+\.txt$/u),
|
||||
"brief.pdf",
|
||||
]);
|
||||
} finally {
|
||||
readAsDataUrl.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the default placeholder only for internally generated pasted text", () => {
|
||||
let pastedTextAttachments: ChatAttachment[] = [];
|
||||
const pasteTarget = renderChatView({
|
||||
getAttachments: () => pastedTextAttachments,
|
||||
onAttachmentsChange: (next) => {
|
||||
pastedTextAttachments = next;
|
||||
},
|
||||
});
|
||||
const textarea = requireElement(
|
||||
pasteTarget,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? `large paste ${"x".repeat(1100)}` : ""),
|
||||
},
|
||||
});
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
const namedLikePaste = registerChatAttachmentPayload({
|
||||
attachment: {
|
||||
id: "ordinary-text-file",
|
||||
fileName: "pasted-text-1.txt",
|
||||
mimeType: "text/plain",
|
||||
sizeBytes: 4,
|
||||
},
|
||||
dataUrl: `data:text/plain;base64,${btoa("file")}`,
|
||||
file: new File(["file"], "pasted-text-1.txt", { type: "text/plain" }),
|
||||
});
|
||||
const imageAttachment: ChatAttachment = {
|
||||
id: "image",
|
||||
fileName: "screen.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 2048,
|
||||
};
|
||||
|
||||
const textOnly = renderChatView({ attachments: pastedTextAttachments });
|
||||
expect(textOnly.querySelector("textarea")?.getAttribute("placeholder")).toBe(
|
||||
t("chat.composer.placeholder", { name: "Val" }),
|
||||
);
|
||||
|
||||
const ordinaryTextFile = renderChatView({ attachments: [namedLikePaste] });
|
||||
expect(ordinaryTextFile.querySelector("textarea")?.getAttribute("placeholder")).toBe(
|
||||
t("chat.composer.placeholderWithAttachments"),
|
||||
);
|
||||
expect(ordinaryTextFile.querySelector(".chat-attachment-text-action")).toBeNull();
|
||||
|
||||
const withImage = renderChatView({ attachments: [imageAttachment] });
|
||||
expect(withImage.querySelector("textarea")?.getAttribute("placeholder")).toBe(
|
||||
t("chat.composer.placeholderWithAttachments"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a cached short preview for pasted text", () => {
|
||||
let attachments: ChatAttachment[] = [];
|
||||
let container = renderChatView({
|
||||
attachments,
|
||||
getAttachments: () => attachments,
|
||||
onAttachmentsChange: (next) => {
|
||||
attachments = next;
|
||||
},
|
||||
});
|
||||
const textarea = requireElement(
|
||||
container,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const text = `First words from a long pasted note ${"x".repeat(1100)}`;
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? text : ""),
|
||||
},
|
||||
});
|
||||
textarea.dispatchEvent(event);
|
||||
container = renderChatView({ attachments });
|
||||
|
||||
expect(container.querySelector(".chat-attachment-file__name")?.textContent).toContain(
|
||||
"First words from a l...",
|
||||
);
|
||||
expect(container.querySelector(".chat-attachment-text-action")?.textContent).toContain(
|
||||
"Restore",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps normal short plain-text paste in the textarea", () => {
|
||||
const onAttachmentsChange = vi.fn();
|
||||
const container = renderChatView({ onAttachmentsChange });
|
||||
const textarea = requireElement(
|
||||
container,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? "short paste" : ""),
|
||||
},
|
||||
});
|
||||
|
||||
const allowed = textarea.dispatchEvent(event);
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(onAttachmentsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves a pasted text attachment back into the composer", async () => {
|
||||
const onAttachmentsChange = vi.fn();
|
||||
const firstRender = renderChatView({ onAttachmentsChange });
|
||||
const textarea = requireElement(
|
||||
firstRender,
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
"composer textarea",
|
||||
);
|
||||
const pastedText = "large paste\n" + "x".repeat(1100);
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
items: { 0: { type: "text/plain" }, length: 1 },
|
||||
getData: (type: string) => (type === "text/plain" ? pastedText : ""),
|
||||
},
|
||||
});
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onAttachmentsChange).toHaveBeenCalled();
|
||||
});
|
||||
const [attachment] = requireFirstAttachmentsChange(onAttachmentsChange);
|
||||
const onDraftChange = vi.fn();
|
||||
const onShowAttachmentsChange = vi.fn();
|
||||
const preview = renderChatView({
|
||||
attachments: [attachment],
|
||||
draft: "intro",
|
||||
getDraft: () => "intro",
|
||||
onAttachmentsChange: onShowAttachmentsChange,
|
||||
onDraftChange,
|
||||
});
|
||||
const showButton = requireElement(
|
||||
preview,
|
||||
'[aria-label="Restore"]',
|
||||
"show pasted text button",
|
||||
) as HTMLButtonElement;
|
||||
|
||||
showButton.click();
|
||||
|
||||
expect(onShowAttachmentsChange).toHaveBeenCalledWith([]);
|
||||
expect(onDraftChange).toHaveBeenCalledWith(`intro\n\n${pastedText}`);
|
||||
expect(getChatAttachmentDataUrl(attachment)).toBeNull();
|
||||
});
|
||||
|
||||
it("converts pasted data image text into an attachment", () => {
|
||||
const onAttachmentsChange = vi.fn();
|
||||
const container = renderChatView({ onAttachmentsChange });
|
||||
@@ -3557,6 +3899,27 @@ describe("chat attachment picker", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a pasted image attachment from the preview", () => {
|
||||
const attachment: ChatAttachment = {
|
||||
id: "image",
|
||||
fileName: "pasted-image.png",
|
||||
mimeType: "image/png",
|
||||
previewUrl: "blob:pasted-image",
|
||||
sizeBytes: 3,
|
||||
};
|
||||
const onAttachmentsChange = vi.fn();
|
||||
const container = renderChatView({ attachments: [attachment], onAttachmentsChange });
|
||||
const removeButton = requireElement(
|
||||
container,
|
||||
'[aria-label="Remove attachment"]',
|
||||
"remove attachment button",
|
||||
) as HTMLButtonElement;
|
||||
|
||||
removeButton.click();
|
||||
|
||||
expect(onAttachmentsChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("opens the scoped file input from the attachment menu", () => {
|
||||
const container = renderChatView();
|
||||
const input = requireElement(
|
||||
|
||||
@@ -125,6 +125,7 @@ export type ChatProps = {
|
||||
assistantAttachmentAuthToken?: string | null;
|
||||
autoExpandToolCalls?: boolean;
|
||||
attachments?: ChatAttachment[];
|
||||
getAttachments?: () => ChatAttachment[];
|
||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||
onAssistantAttachmentLoaded?: () => void;
|
||||
showNewMessages?: boolean;
|
||||
@@ -297,6 +298,7 @@ export function renderChat(props: ChatProps) {
|
||||
assistantName: props.assistantName,
|
||||
sendShortcut: props.sendShortcut,
|
||||
attachments: props.attachments,
|
||||
getAttachments: props.getAttachments,
|
||||
replyTarget: props.replyTarget,
|
||||
realtimeTalkActive: props.realtimeTalkActive,
|
||||
realtimeTalkStatus: props.realtimeTalkStatus,
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "../../../lib/session-goal.ts";
|
||||
import { detectTextDirection } from "../../../lib/text-direction.ts";
|
||||
import {
|
||||
getChatAttachmentDataUrl,
|
||||
getChatAttachmentPreviewUrl,
|
||||
registerChatAttachmentPayload,
|
||||
releaseChatAttachmentPayload,
|
||||
@@ -74,6 +75,12 @@ const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [
|
||||
const CHAT_ATTACHMENT_ACCEPT =
|
||||
"image/*,audio/*,application/pdf,text/*,.csv,.json,.md,.txt,.zip," +
|
||||
".doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||
const LARGE_PASTE_TEXT_THRESHOLD = 1000;
|
||||
const LARGE_PASTE_TEXT_MIME_TYPE = "text/plain";
|
||||
const LARGE_PASTE_TEXT_FILE_PREFIX = "pasted-text-";
|
||||
const PASTED_TEXT_PREVIEW_MAX_LENGTH = 20;
|
||||
const largePastedTextAttachments = new WeakSet<ChatAttachment>();
|
||||
const pastedTextPreviews = new WeakMap<ChatAttachment, string>();
|
||||
|
||||
type ChatComposerProps = {
|
||||
paneId: string;
|
||||
@@ -96,6 +103,7 @@ type ChatComposerProps = {
|
||||
assistantName: string;
|
||||
sendShortcut?: ChatSendShortcut;
|
||||
attachments?: ChatAttachment[];
|
||||
getAttachments?: () => ChatAttachment[];
|
||||
replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
|
||||
realtimeTalkActive?: boolean;
|
||||
realtimeTalkStatus?: RealtimeTalkStatus;
|
||||
@@ -938,9 +946,18 @@ function renderSlashMenu(
|
||||
|
||||
type ChatAttachmentControlsProps = {
|
||||
attachments?: ChatAttachment[];
|
||||
getAttachments?: () => ChatAttachment[];
|
||||
draft?: string;
|
||||
getDraft?: () => string;
|
||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||
onDraftChange?: (next: string) => void;
|
||||
onRequestUpdate?: () => void;
|
||||
};
|
||||
|
||||
function currentAttachments(props: ChatAttachmentControlsProps): ChatAttachment[] {
|
||||
return props.getAttachments?.() ?? props.attachments ?? [];
|
||||
}
|
||||
|
||||
type ChatQueueProps = {
|
||||
queue: ChatQueueItem[];
|
||||
canAbort?: boolean;
|
||||
@@ -1100,6 +1117,95 @@ function chatAttachmentFromFile(file: File, dataUrl: string): ChatAttachment {
|
||||
return registerChatAttachmentPayload({ attachment, dataUrl, file });
|
||||
}
|
||||
|
||||
function isLargePastedTextAttachment(attachment: ChatAttachment): boolean {
|
||||
return largePastedTextAttachments.has(attachment);
|
||||
}
|
||||
|
||||
function encodeTextAsDataUrl(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const chunks: string[] = [];
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
||||
}
|
||||
return `data:${LARGE_PASTE_TEXT_MIME_TYPE};base64,${btoa(chunks.join(""))}`;
|
||||
}
|
||||
|
||||
function createLargePastedTextAttachment(text: string): ChatAttachment {
|
||||
const file = new File([text], `${LARGE_PASTE_TEXT_FILE_PREFIX}${Date.now()}.txt`, {
|
||||
type: LARGE_PASTE_TEXT_MIME_TYPE,
|
||||
});
|
||||
const attachment = chatAttachmentFromFile(file, encodeTextAsDataUrl(text));
|
||||
largePastedTextAttachments.add(attachment);
|
||||
const preview = compactPastedTextPreview(text);
|
||||
if (preview) {
|
||||
pastedTextPreviews.set(attachment, preview);
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function readTextFromDataUrl(dataUrl: string): string | null {
|
||||
const match = /^data:([^,]*),(.*)$/s.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const metadata = match[1];
|
||||
const payload = match[2];
|
||||
if (metadata === undefined || payload === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (metadata.toLowerCase().includes(";base64")) {
|
||||
try {
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(payload.replace(/\+/g, "%20"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compactPastedTextPreview(text: string): string | null {
|
||||
const normalized = text.replace(/\s+/gu, " ").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.length <= PASTED_TEXT_PREVIEW_MAX_LENGTH) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, PASTED_TEXT_PREVIEW_MAX_LENGTH).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function pastedTextPreview(attachment: ChatAttachment): string {
|
||||
return pastedTextPreviews.get(attachment) ?? attachment.fileName ?? "Attached file";
|
||||
}
|
||||
|
||||
function appendPastedTextToDraft(draft: string, text: string): string {
|
||||
if (!draft.trim()) {
|
||||
return text;
|
||||
}
|
||||
return `${draft.replace(/\s+$/u, "")}\n\n${text}`;
|
||||
}
|
||||
|
||||
function handleLargeTextPaste(e: ClipboardEvent, props: ChatAttachmentControlsProps): boolean {
|
||||
if (!props.onAttachmentsChange) {
|
||||
return false;
|
||||
}
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
if (!text || text.length <= LARGE_PASTE_TEXT_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
e.preventDefault();
|
||||
const attachment = createLargePastedTextAttachment(text);
|
||||
props.onAttachmentsChange([...currentAttachments(props), attachment]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function dataImageClipboardFile(
|
||||
dataUrl: string,
|
||||
baseName = "pasted-image",
|
||||
@@ -1162,11 +1268,12 @@ function handleChatAttachmentPaste(e: ClipboardEvent, props: ChatAttachmentContr
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
const pasted = text ? dataImageClipboardFile(text) : null;
|
||||
if (!pasted) {
|
||||
handleLargeTextPaste(e, props);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
props.onAttachmentsChange([
|
||||
...(props.attachments ?? []),
|
||||
...currentAttachments(props),
|
||||
chatAttachmentFromFile(pasted.file, pasted.dataUrl),
|
||||
]);
|
||||
return;
|
||||
@@ -1181,19 +1288,32 @@ function handleChatAttachmentPaste(e: ClipboardEvent, props: ChatAttachmentContr
|
||||
reader.addEventListener("load", () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const newAttachment = chatAttachmentFromFile(file, dataUrl);
|
||||
const current = props.attachments ?? [];
|
||||
props.onAttachmentsChange?.([...current, newAttachment]);
|
||||
props.onAttachmentsChange?.([...currentAttachments(props), newAttachment]);
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function showPastedTextInComposer(att: ChatAttachment, props: ChatAttachmentControlsProps): void {
|
||||
const dataUrl = getChatAttachmentDataUrl(att);
|
||||
const text = dataUrl ? readTextFromDataUrl(dataUrl) : null;
|
||||
if (!text || !props.onDraftChange) {
|
||||
return;
|
||||
}
|
||||
const nextAttachments = currentAttachments(props).filter(
|
||||
(attachment) => attachment.id !== att.id,
|
||||
);
|
||||
releaseChatAttachmentPayload(att.id);
|
||||
props.onAttachmentsChange?.(nextAttachments);
|
||||
props.onDraftChange(appendPastedTextToDraft(props.getDraft?.() ?? props.draft ?? "", text));
|
||||
props.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
function handleChatAttachmentFileSelect(e: Event, props: ChatAttachmentControlsProps) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || !props.onAttachmentsChange) {
|
||||
return;
|
||||
}
|
||||
const current = props.attachments ?? [];
|
||||
const additions: ChatAttachment[] = [];
|
||||
let pending = 0;
|
||||
for (const file of input.files) {
|
||||
@@ -1206,7 +1326,7 @@ function handleChatAttachmentFileSelect(e: Event, props: ChatAttachmentControlsP
|
||||
additions.push(chatAttachmentFromFile(file, reader.result as string));
|
||||
pending--;
|
||||
if (pending === 0) {
|
||||
props.onAttachmentsChange?.([...current, ...additions]);
|
||||
props.onAttachmentsChange?.([...currentAttachments(props), ...additions]);
|
||||
}
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
@@ -1220,7 +1340,6 @@ export function handleChatAttachmentDrop(e: DragEvent, props: ChatAttachmentCont
|
||||
if (!files || !props.onAttachmentsChange) {
|
||||
return;
|
||||
}
|
||||
const current = props.attachments ?? [];
|
||||
const additions: ChatAttachment[] = [];
|
||||
let pending = 0;
|
||||
for (const file of files) {
|
||||
@@ -1233,7 +1352,7 @@ export function handleChatAttachmentDrop(e: DragEvent, props: ChatAttachmentCont
|
||||
additions.push(chatAttachmentFromFile(file, reader.result as string));
|
||||
pending--;
|
||||
if (pending === 0) {
|
||||
props.onAttachmentsChange?.([...current, ...additions]);
|
||||
props.onAttachmentsChange?.([...currentAttachments(props), ...additions]);
|
||||
}
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
@@ -1253,34 +1372,53 @@ function renderAttachmentPreview(props: ChatAttachmentControlsProps) {
|
||||
class=${[
|
||||
"chat-attachment-thumb",
|
||||
isImageAttachment(att) ? "" : "chat-attachment-thumb--file",
|
||||
isLargePastedTextAttachment(att) ? "chat-attachment-thumb--pasted-text" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
${isImageAttachment(att) && getChatAttachmentPreviewUrl(att)
|
||||
? html`<img src=${getChatAttachmentPreviewUrl(att)!} alt="Attachment preview" />`
|
||||
: html`
|
||||
<openclaw-tooltip .content=${att.fileName ?? "Attached file"}>
|
||||
<div class="chat-attachment-file">
|
||||
<span class="chat-attachment-file__icon">${icons.paperclip}</span>
|
||||
<span class="chat-attachment-file__name"
|
||||
>${att.fileName ?? "Attached file"}</span
|
||||
>
|
||||
: isLargePastedTextAttachment(att)
|
||||
? html`
|
||||
<div class="chat-attachment-file chat-attachment-file--pasted-text">
|
||||
<span class="chat-attachment-file__icon">${icons.fileText}</span>
|
||||
<span class="chat-attachment-file__body">
|
||||
<span class="chat-attachment-file__name">${pastedTextPreview(att)}</span>
|
||||
<button
|
||||
class="chat-attachment-text-action"
|
||||
type="button"
|
||||
aria-label=${t("worktrees.restore")}
|
||||
@click=${() => showPastedTextInComposer(att, props)}
|
||||
>
|
||||
${t("worktrees.restore")}
|
||||
<span aria-hidden="true">${icons.chevronRight}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</openclaw-tooltip>
|
||||
`}
|
||||
`
|
||||
: html`
|
||||
<openclaw-tooltip .content=${att.fileName ?? "Attached file"}>
|
||||
<div class="chat-attachment-file">
|
||||
<span class="chat-attachment-file__icon">${icons.paperclip}</span>
|
||||
<span class="chat-attachment-file__name"
|
||||
>${att.fileName ?? "Attached file"}</span
|
||||
>
|
||||
</div>
|
||||
</openclaw-tooltip>
|
||||
`}
|
||||
<openclaw-tooltip .content=${t("chat.composer.removeAttachment")}>
|
||||
<button
|
||||
class="chat-attachment-remove"
|
||||
type="button"
|
||||
aria-label=${t("chat.composer.removeAttachment")}
|
||||
@click=${() => {
|
||||
const next = (props.attachments ?? []).filter((a) => a.id !== att.id);
|
||||
const next = currentAttachments(props).filter((a) => a.id !== att.id);
|
||||
releaseChatAttachmentPayload(att.id);
|
||||
props.onAttachmentsChange?.(next);
|
||||
}}
|
||||
>
|
||||
×
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
@@ -2111,7 +2249,9 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
const actionDraft =
|
||||
state.composingDraft?.key === draftKey ? state.composingDraft.value : visibleDraft;
|
||||
let composerTextarea: HTMLTextAreaElement | null = null;
|
||||
const hasAttachments = (props.attachments?.length ?? 0) > 0;
|
||||
const hasVisualAttachments = (props.attachments ?? []).some(
|
||||
(attachment) => !isLargePastedTextAttachment(attachment),
|
||||
);
|
||||
const tokens = tokenEstimate(visibleDraft);
|
||||
const contextNotice = renderContextNotice(
|
||||
activeSession,
|
||||
@@ -2150,7 +2290,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
const placeholder =
|
||||
!canCompose && props.disabledReason
|
||||
? props.disabledReason
|
||||
: hasAttachments
|
||||
: hasVisualAttachments
|
||||
? t("chat.composer.placeholderWithAttachments")
|
||||
: t("chat.composer.placeholder", { name: props.assistantName || "agent" });
|
||||
|
||||
|
||||
@@ -3066,7 +3066,7 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
margin-top: 7px;
|
||||
margin-top: 4px;
|
||||
padding-block-start: 10px;
|
||||
padding-inline-start: 10px;
|
||||
margin-bottom: 8px;
|
||||
@@ -3085,6 +3085,13 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.chat-attachment-thumb--pasted-text {
|
||||
width: min(220px, calc(100vw - 64px));
|
||||
height: 60px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--panel) 72%, transparent);
|
||||
}
|
||||
|
||||
.chat-attachment-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -3093,14 +3100,16 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
|
||||
.chat-attachment-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
@@ -3108,6 +3117,16 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-attachment-remove svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-attachment-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3121,6 +3140,12 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.chat-attachment-file--pasted-text {
|
||||
gap: 8px;
|
||||
padding: 10px 42px 10px 10px;
|
||||
background: color-mix(in srgb, var(--panel) 62%, transparent);
|
||||
}
|
||||
|
||||
.chat-attachment-file__icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -3130,6 +3155,23 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
.chat-attachment-file__icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.7px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-attachment-file--pasted-text .chat-attachment-file__icon {
|
||||
width: 28px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-attachment-file--pasted-text .chat-attachment-file__icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.chat-attachment-file__name {
|
||||
@@ -3139,6 +3181,55 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-attachment-file--pasted-text .chat-attachment-file__name {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-attachment-file__body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-attachment-file__meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.chat-attachment-text-action {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1.15;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-attachment-text-action:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-attachment-text-action svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.7px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.agent-chat__file-input,
|
||||
.agent-chat__photo-input,
|
||||
.agent-chat__camera-input {
|
||||
|
||||
Reference in New Issue
Block a user