From e9786c55344d84091ae32f5d2e68860c67a2094d Mon Sep 17 00:00:00 2001 From: XiaoYingYo <759852125@qq.com> Date: Sun, 26 Oct 2025 04:48:26 +0800 Subject: [PATCH 1/4] feat: Copy task prompts support carrying all images from the task to the clipboard and allow complete pasting in new conversations. --- packages/types/src/history.ts | 1 + src/core/task-persistence/taskMetadata.ts | 1 + .../src/components/chat/ChatTextArea.tsx | 71 ++++++++++++++----- .../src/components/chat/TaskActions.tsx | 12 +++- .../src/components/history/CopyButton.tsx | 7 +- .../src/components/ui/hooks/useClipboard.ts | 64 ++++++++++++++--- 6 files changed, 122 insertions(+), 34 deletions(-) diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 395ec5986f70..631e757cc809 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -19,6 +19,7 @@ export const historyItemSchema = z.object({ size: z.number().optional(), workspace: z.string().optional(), mode: z.string().optional(), + images: z.array(z.string()).optional(), }) export type HistoryItem = z.infer diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index f6b9575be39a..30e34f549f87 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -93,6 +93,7 @@ export async function taskMetadata({ task: hasMessages ? taskMessage!.text?.trim() || t("common:tasks.incomplete", { taskNumber }) : t("common:tasks.no_messages", { taskNumber }), + images: hasMessages ? taskMessage!.images : undefined, tokensIn: tokenUsage.totalTokensIn, tokensOut: tokenUsage.totalTokensOut, cacheWrites: tokenUsage.totalCacheWrites, diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c7813372fa79..6db60b58af6e 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -634,11 +634,50 @@ export const ChatTextArea = forwardRef( const handlePaste = useCallback( async (e: React.ClipboardEvent) => { + const hasHtml = Array.from(e.clipboardData.types).includes("text/html") + if (hasHtml && navigator.clipboard?.read) { + e.preventDefault() + try { + const clipboardItems = await navigator.clipboard.read() + const htmlItem = clipboardItems.find((item) => item.types.includes("text/html")) + if (htmlItem) { + const htmlBlob = await htmlItem.getType("text/html") + const htmlText = await htmlBlob.text() + const parser = new DOMParser() + const doc = parser.parseFromString(htmlText, "text/html") + const plainText = doc.body.textContent?.trim() || "" + const imgElements = doc.querySelectorAll("img") + const imageSrcs = Array.from(imgElements) + .map((img) => img.src) + .filter((src) => src.startsWith("data:image/")) + const availableSlots = MAX_IMAGES_PER_MESSAGE - selectedImages.length + const newImages = imageSrcs.slice(0, availableSlots) + if (imageSrcs.length > newImages.length) { + console.warn( + `只能粘贴 ${availableSlots} 张图片,已忽略剩余 ${ + imageSrcs.length - newImages.length + } 张`, + ) + } + if (plainText) { + const newValue = + inputValue.slice(0, cursorPosition) + plainText + inputValue.slice(cursorPosition) + setInputValue(newValue) + const newCursorPosition = cursorPosition + plainText.length + setCursorPosition(newCursorPosition) + setIntendedCursorPosition(newCursorPosition) + } + if (newImages.length > 0) { + setSelectedImages((prev) => [...prev, ...newImages]) + } + return + } + } catch (err) { + console.warn("Rich text paste failed, falling back to legacy paste.", err) + } + } const items = e.clipboardData.items - const pastedText = e.clipboardData.getData("text") - // Check if the pasted content is a URL, add space after so user - // can easily delete if they don't want it. const urlRegex = /^\S+:\/\/\S+$/ if (urlRegex.test(pastedText.trim())) { e.preventDefault() @@ -650,39 +689,29 @@ export const ChatTextArea = forwardRef( setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) setShowContextMenu(false) - - // Scroll to new cursor position. setTimeout(() => { if (textAreaRef.current) { textAreaRef.current.blur() textAreaRef.current.focus() } }, 0) - return } - const acceptedTypes = ["png", "jpeg", "webp"] - const imageItems = Array.from(items).filter((item) => { const [type, subtype] = item.type.split("/") return type === "image" && acceptedTypes.includes(subtype) }) - if (!shouldDisableImages && imageItems.length > 0) { e.preventDefault() - const imagePromises = imageItems.map((item) => { return new Promise((resolve) => { const blob = item.getAsFile() - if (!blob) { resolve(null) return } - const reader = new FileReader() - reader.onloadend = () => { if (reader.error) { console.error(t("chat:errorReadingFile"), reader.error) @@ -692,14 +721,11 @@ export const ChatTextArea = forwardRef( resolve(typeof result === "string" ? result : null) } } - reader.readAsDataURL(blob) }) }) - const imageDataArray = await Promise.all(imagePromises) const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) - if (dataUrls.length > 0) { setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) } else { @@ -707,7 +733,18 @@ export const ChatTextArea = forwardRef( } } }, - [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t], + [ + shouldDisableImages, + setSelectedImages, + cursorPosition, + setInputValue, + inputValue, + t, + selectedImages, + setIntendedCursorPosition, + setShowContextMenu, + setCursorPosition, + ], ) const handleMenuMouseDown = useCallback(() => { diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index a6954c5ef3f7..1dac4bb2d53d 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next" import type { HistoryItem } from "@roo-code/types" import { vscode } from "@/utils/vscode" -import { useCopyToClipboard } from "@/utils/clipboard" +import { useClipboard } from "@/components/ui/hooks" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { IconButton } from "./IconButton" @@ -19,7 +19,7 @@ interface TaskActionsProps { export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { const [deleteTaskId, setDeleteTaskId] = useState(null) const { t } = useTranslation() - const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() + const { isCopied: showCopyFeedback, copy } = useClipboard() return (
@@ -32,7 +32,13 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { copyWithFeedback(item.task, e)} + onClick={(e) => { + e.stopPropagation() + copy({ + text: item.task, + images: item.images || [], + }) + }} /> )} {!!item?.size && item.size > 0 && ( diff --git a/webview-ui/src/components/history/CopyButton.tsx b/webview-ui/src/components/history/CopyButton.tsx index 4243ff8d5a6d..7b6cded98501 100644 --- a/webview-ui/src/components/history/CopyButton.tsx +++ b/webview-ui/src/components/history/CopyButton.tsx @@ -7,9 +7,10 @@ import { cn } from "@/lib/utils" type CopyButtonProps = { itemTask: string + itemImages?: string[] } -export const CopyButton = ({ itemTask }: CopyButtonProps) => { +export const CopyButton = ({ itemTask, itemImages = [] }: CopyButtonProps) => { const { isCopied, copy } = useClipboard() const { t } = useAppTranslation() @@ -18,10 +19,10 @@ export const CopyButton = ({ itemTask }: CopyButtonProps) => { e.stopPropagation() if (!isCopied) { - copy(itemTask) + copy({ text: itemTask, images: itemImages }) } }, - [isCopied, copy, itemTask], + [isCopied, copy, itemTask, itemImages], ) return ( diff --git a/webview-ui/src/components/ui/hooks/useClipboard.ts b/webview-ui/src/components/ui/hooks/useClipboard.ts index 884cb642cab0..2344f13721a3 100644 --- a/webview-ui/src/components/ui/hooks/useClipboard.ts +++ b/webview-ui/src/components/ui/hooks/useClipboard.ts @@ -1,22 +1,64 @@ import { useState } from "react" - +import { MAX_IMAGES_PER_MESSAGE } from "@src/components/chat/ChatView" export interface UseClipboardProps { timeout?: number } - +export interface CopyPayload { + text: string + images?: string[] +} export function useClipboard({ timeout = 2000 }: UseClipboardProps = {}) { const [isCopied, setIsCopied] = useState(false) - - const copy = (value: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText || !value) { - return - } - - navigator.clipboard.writeText(value).then(() => { + const copy = async (payload: CopyPayload | string) => { + const { text, images = [] } = typeof payload === "string" ? { text: payload, images: [] } : payload + const handleSuccess = () => { setIsCopied(true) setTimeout(() => setIsCopied(false), timeout) - }) + } + if (typeof window === "undefined") { + return + } + try { + if (navigator.clipboard?.write && images.length > 0) { + const limitedImages = images.slice(0, MAX_IMAGES_PER_MESSAGE) + const escapedText = text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, '"') + const imgTags = limitedImages.map((base64) => ``).join("") + const html = `

${escapedText}

${imgTags}
` + const htmlBlob = new Blob([html], { type: "text/html" }) + const textBlob = new Blob([text], { type: "text/plain" }) + const clipboardItem = new ClipboardItem({ + "text/html": htmlBlob, + "text/plain": textBlob, + }) + await navigator.clipboard.write([clipboardItem]) + handleSuccess() + return + } + } catch (err) { + console.warn("Rich text copy failed, falling back to plain text", err) + } + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + handleSuccess() + return + } + } catch (err) { + console.warn("navigator.clipboard.writeText failed, falling back to execCommand", err) + } + try { + const textarea = document.createElement("textarea") + textarea.value = text + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.select() + document.execCommand("copy") + document.body.removeChild(textarea) + handleSuccess() + } catch (err) { + console.error("All copy methods failed", err) + } } - return { isCopied, copy } } From 20997034eacb06ca6d2c26cfe9d90666cfab0000 Mon Sep 17 00:00:00 2001 From: XiaoYingYo <759852125@qq.com> Date: Sun, 26 Oct 2025 05:05:28 +0800 Subject: [PATCH 2/4] refactor: use English for paste image warning --- webview-ui/src/components/chat/ChatTextArea.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 6db60b58af6e..b0d5a664f3ab 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -654,9 +654,7 @@ export const ChatTextArea = forwardRef( const newImages = imageSrcs.slice(0, availableSlots) if (imageSrcs.length > newImages.length) { console.warn( - `只能粘贴 ${availableSlots} 张图片,已忽略剩余 ${ - imageSrcs.length - newImages.length - } 张`, + `Only ${availableSlots} images can be pasted, ignoring ${imageSrcs.length - newImages.length} images`, ) } if (plainText) { From a2daa1529faa6da2fa15775906feda1242030ed7 Mon Sep 17 00:00:00 2001 From: XiaoYingYo <759852125@qq.com> Date: Sun, 26 Oct 2025 06:00:05 +0800 Subject: [PATCH 3/4] fix: Correct HTML escaping in useClipboard to prevent XSS --- webview-ui/src/components/ui/hooks/useClipboard.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/ui/hooks/useClipboard.ts b/webview-ui/src/components/ui/hooks/useClipboard.ts index 2344f13721a3..1b339455fa54 100644 --- a/webview-ui/src/components/ui/hooks/useClipboard.ts +++ b/webview-ui/src/components/ui/hooks/useClipboard.ts @@ -21,7 +21,11 @@ export function useClipboard({ timeout = 2000 }: UseClipboardProps = {}) { try { if (navigator.clipboard?.write && images.length > 0) { const limitedImages = images.slice(0, MAX_IMAGES_PER_MESSAGE) - const escapedText = text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, '"') + const escapedText = text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) const imgTags = limitedImages.map((base64) => ``).join("") const html = `

${escapedText}

${imgTags}
` const htmlBlob = new Blob([html], { type: "text/html" }) From e020ec9639ee15a338d42485d25e14e2723e3004 Mon Sep 17 00:00:00 2001 From: XiaoYingYo <759852125@qq.com> Date: Sun, 26 Oct 2025 06:09:50 +0800 Subject: [PATCH 4/4] fix: update CopyButton test to match new copy function signature --- webview-ui/src/components/history/__tests__/CopyButton.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/history/__tests__/CopyButton.spec.tsx b/webview-ui/src/components/history/__tests__/CopyButton.spec.tsx index ac1b39859df5..e43c3ca16cad 100644 --- a/webview-ui/src/components/history/__tests__/CopyButton.spec.tsx +++ b/webview-ui/src/components/history/__tests__/CopyButton.spec.tsx @@ -28,6 +28,6 @@ describe("CopyButton", () => { const copyButton = screen.getByRole("button") fireEvent.click(copyButton) - expect(mockCopy).toHaveBeenCalledWith("Test task content") + expect(mockCopy).toHaveBeenCalledWith({ text: "Test task content", images: [] }) }) })