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:
Vyctor H. Brzezowski
2026-07-12 07:48:24 -03:00
committed by GitHub
parent a20314d6cc
commit 2c5715238d
7 changed files with 667 additions and 34 deletions
-7
View File
@@ -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": "&times;"
},
{
"count": 2,
"kind": "html-text",
+1
View File
@@ -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?.();
+43
View File
@@ -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) => {
+363
View File
@@ -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(
+2
View File
@@ -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,
+160 -20
View File
@@ -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);
}}
>
&times;
${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" });
+98 -7
View File
@@ -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 {