Skip to content

Commit e9786c5

Browse files
committed
feat: Copy task prompts support carrying all images from the task to the clipboard and allow complete pasting in new conversations.
1 parent 98b8d5b commit e9786c5

File tree

6 files changed

+122
-34
lines changed

6 files changed

+122
-34
lines changed

packages/types/src/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const historyItemSchema = z.object({
1919
size: z.number().optional(),
2020
workspace: z.string().optional(),
2121
mode: z.string().optional(),
22+
images: z.array(z.string()).optional(),
2223
})
2324

2425
export type HistoryItem = z.infer<typeof historyItemSchema>

src/core/task-persistence/taskMetadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export async function taskMetadata({
9393
task: hasMessages
9494
? taskMessage!.text?.trim() || t("common:tasks.incomplete", { taskNumber })
9595
: t("common:tasks.no_messages", { taskNumber }),
96+
images: hasMessages ? taskMessage!.images : undefined,
9697
tokensIn: tokenUsage.totalTokensIn,
9798
tokensOut: tokenUsage.totalTokensOut,
9899
cacheWrites: tokenUsage.totalCacheWrites,

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -634,11 +634,50 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
634634

635635
const handlePaste = useCallback(
636636
async (e: React.ClipboardEvent) => {
637+
const hasHtml = Array.from(e.clipboardData.types).includes("text/html")
638+
if (hasHtml && navigator.clipboard?.read) {
639+
e.preventDefault()
640+
try {
641+
const clipboardItems = await navigator.clipboard.read()
642+
const htmlItem = clipboardItems.find((item) => item.types.includes("text/html"))
643+
if (htmlItem) {
644+
const htmlBlob = await htmlItem.getType("text/html")
645+
const htmlText = await htmlBlob.text()
646+
const parser = new DOMParser()
647+
const doc = parser.parseFromString(htmlText, "text/html")
648+
const plainText = doc.body.textContent?.trim() || ""
649+
const imgElements = doc.querySelectorAll("img")
650+
const imageSrcs = Array.from(imgElements)
651+
.map((img) => img.src)
652+
.filter((src) => src.startsWith("data:image/"))
653+
const availableSlots = MAX_IMAGES_PER_MESSAGE - selectedImages.length
654+
const newImages = imageSrcs.slice(0, availableSlots)
655+
if (imageSrcs.length > newImages.length) {
656+
console.warn(
657+
`只能粘贴 ${availableSlots} 张图片,已忽略剩余 ${
658+
imageSrcs.length - newImages.length
659+
} 张`,
660+
)
661+
}
662+
if (plainText) {
663+
const newValue =
664+
inputValue.slice(0, cursorPosition) + plainText + inputValue.slice(cursorPosition)
665+
setInputValue(newValue)
666+
const newCursorPosition = cursorPosition + plainText.length
667+
setCursorPosition(newCursorPosition)
668+
setIntendedCursorPosition(newCursorPosition)
669+
}
670+
if (newImages.length > 0) {
671+
setSelectedImages((prev) => [...prev, ...newImages])
672+
}
673+
return
674+
}
675+
} catch (err) {
676+
console.warn("Rich text paste failed, falling back to legacy paste.", err)
677+
}
678+
}
637679
const items = e.clipboardData.items
638-
639680
const pastedText = e.clipboardData.getData("text")
640-
// Check if the pasted content is a URL, add space after so user
641-
// can easily delete if they don't want it.
642681
const urlRegex = /^\S+:\/\/\S+$/
643682
if (urlRegex.test(pastedText.trim())) {
644683
e.preventDefault()
@@ -650,39 +689,29 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
650689
setCursorPosition(newCursorPosition)
651690
setIntendedCursorPosition(newCursorPosition)
652691
setShowContextMenu(false)
653-
654-
// Scroll to new cursor position.
655692
setTimeout(() => {
656693
if (textAreaRef.current) {
657694
textAreaRef.current.blur()
658695
textAreaRef.current.focus()
659696
}
660697
}, 0)
661-
662698
return
663699
}
664-
665700
const acceptedTypes = ["png", "jpeg", "webp"]
666-
667701
const imageItems = Array.from(items).filter((item) => {
668702
const [type, subtype] = item.type.split("/")
669703
return type === "image" && acceptedTypes.includes(subtype)
670704
})
671-
672705
if (!shouldDisableImages && imageItems.length > 0) {
673706
e.preventDefault()
674-
675707
const imagePromises = imageItems.map((item) => {
676708
return new Promise<string | null>((resolve) => {
677709
const blob = item.getAsFile()
678-
679710
if (!blob) {
680711
resolve(null)
681712
return
682713
}
683-
684714
const reader = new FileReader()
685-
686715
reader.onloadend = () => {
687716
if (reader.error) {
688717
console.error(t("chat:errorReadingFile"), reader.error)
@@ -692,22 +721,30 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
692721
resolve(typeof result === "string" ? result : null)
693722
}
694723
}
695-
696724
reader.readAsDataURL(blob)
697725
})
698726
})
699-
700727
const imageDataArray = await Promise.all(imagePromises)
701728
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
702-
703729
if (dataUrls.length > 0) {
704730
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
705731
} else {
706732
console.warn(t("chat:noValidImages"))
707733
}
708734
}
709735
},
710-
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
736+
[
737+
shouldDisableImages,
738+
setSelectedImages,
739+
cursorPosition,
740+
setInputValue,
741+
inputValue,
742+
t,
743+
selectedImages,
744+
setIntendedCursorPosition,
745+
setShowContextMenu,
746+
setCursorPosition,
747+
],
711748
)
712749

713750
const handleMenuMouseDown = useCallback(() => {

webview-ui/src/components/chat/TaskActions.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
44
import type { HistoryItem } from "@roo-code/types"
55

66
import { vscode } from "@/utils/vscode"
7-
import { useCopyToClipboard } from "@/utils/clipboard"
7+
import { useClipboard } from "@/components/ui/hooks"
88

99
import { DeleteTaskDialog } from "../history/DeleteTaskDialog"
1010
import { IconButton } from "./IconButton"
@@ -19,7 +19,7 @@ interface TaskActionsProps {
1919
export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => {
2020
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
2121
const { t } = useTranslation()
22-
const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard()
22+
const { isCopied: showCopyFeedback, copy } = useClipboard()
2323

2424
return (
2525
<div className="flex flex-row items-center">
@@ -32,7 +32,13 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => {
3232
<IconButton
3333
iconClass={showCopyFeedback ? "codicon-check" : "codicon-copy"}
3434
title={t("history:copyPrompt")}
35-
onClick={(e) => copyWithFeedback(item.task, e)}
35+
onClick={(e) => {
36+
e.stopPropagation()
37+
copy({
38+
text: item.task,
39+
images: item.images || [],
40+
})
41+
}}
3642
/>
3743
)}
3844
{!!item?.size && item.size > 0 && (

webview-ui/src/components/history/CopyButton.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { cn } from "@/lib/utils"
77

88
type CopyButtonProps = {
99
itemTask: string
10+
itemImages?: string[]
1011
}
1112

12-
export const CopyButton = ({ itemTask }: CopyButtonProps) => {
13+
export const CopyButton = ({ itemTask, itemImages = [] }: CopyButtonProps) => {
1314
const { isCopied, copy } = useClipboard()
1415
const { t } = useAppTranslation()
1516

@@ -18,10 +19,10 @@ export const CopyButton = ({ itemTask }: CopyButtonProps) => {
1819
e.stopPropagation()
1920

2021
if (!isCopied) {
21-
copy(itemTask)
22+
copy({ text: itemTask, images: itemImages })
2223
}
2324
},
24-
[isCopied, copy, itemTask],
25+
[isCopied, copy, itemTask, itemImages],
2526
)
2627

2728
return (
Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,64 @@
11
import { useState } from "react"
2-
2+
import { MAX_IMAGES_PER_MESSAGE } from "@src/components/chat/ChatView"
33
export interface UseClipboardProps {
44
timeout?: number
55
}
6-
6+
export interface CopyPayload {
7+
text: string
8+
images?: string[]
9+
}
710
export function useClipboard({ timeout = 2000 }: UseClipboardProps = {}) {
811
const [isCopied, setIsCopied] = useState(false)
9-
10-
const copy = (value: string) => {
11-
if (typeof window === "undefined" || !navigator.clipboard?.writeText || !value) {
12-
return
13-
}
14-
15-
navigator.clipboard.writeText(value).then(() => {
12+
const copy = async (payload: CopyPayload | string) => {
13+
const { text, images = [] } = typeof payload === "string" ? { text: payload, images: [] } : payload
14+
const handleSuccess = () => {
1615
setIsCopied(true)
1716
setTimeout(() => setIsCopied(false), timeout)
18-
})
17+
}
18+
if (typeof window === "undefined") {
19+
return
20+
}
21+
try {
22+
if (navigator.clipboard?.write && images.length > 0) {
23+
const limitedImages = images.slice(0, MAX_IMAGES_PER_MESSAGE)
24+
const escapedText = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"')
25+
const imgTags = limitedImages.map((base64) => `<img src="${base64}" />`).join("")
26+
const html = `<div><p>${escapedText}</p>${imgTags}</div>`
27+
const htmlBlob = new Blob([html], { type: "text/html" })
28+
const textBlob = new Blob([text], { type: "text/plain" })
29+
const clipboardItem = new ClipboardItem({
30+
"text/html": htmlBlob,
31+
"text/plain": textBlob,
32+
})
33+
await navigator.clipboard.write([clipboardItem])
34+
handleSuccess()
35+
return
36+
}
37+
} catch (err) {
38+
console.warn("Rich text copy failed, falling back to plain text", err)
39+
}
40+
try {
41+
if (navigator.clipboard?.writeText) {
42+
await navigator.clipboard.writeText(text)
43+
handleSuccess()
44+
return
45+
}
46+
} catch (err) {
47+
console.warn("navigator.clipboard.writeText failed, falling back to execCommand", err)
48+
}
49+
try {
50+
const textarea = document.createElement("textarea")
51+
textarea.value = text
52+
textarea.style.position = "fixed"
53+
textarea.style.opacity = "0"
54+
document.body.appendChild(textarea)
55+
textarea.select()
56+
document.execCommand("copy")
57+
document.body.removeChild(textarea)
58+
handleSuccess()
59+
} catch (err) {
60+
console.error("All copy methods failed", err)
61+
}
1962
}
20-
2163
return { isCopied, copy }
2264
}

0 commit comments

Comments
 (0)