diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index f7d0ec250633..971d48dfa9f6 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -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", diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index a30c37bf8376..8f8f23f116f1 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -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?.(); diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 1924ed2265a6..9e0c02ee68cb 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -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(); const request = vi.fn(async (method: string) => { diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 1c0defd70dc1..635f3ef854bc 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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 : "

rich text

"), + }, + }); + + 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( diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 92b1742c4815..40ed18200d95 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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, diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index d81644387cd3..399e81e8116e 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -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(); +const pastedTextPreviews = new WeakMap(); 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`Attachment preview` - : html` - -
- ${icons.paperclip} - ${att.fileName ?? "Attached file"} + : isLargePastedTextAttachment(att) + ? html` +
+ ${icons.fileText} + + ${pastedTextPreview(att)} + +
- - `} + ` + : html` + +
+ ${icons.paperclip} + ${att.fileName ?? "Attached file"} +
+
+ `}
@@ -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" }); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index ffca34b7fcc9..3e339a4bfe9c 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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 {