Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof historyItemSchema>
1 change: 1 addition & 0 deletions src/core/task-persistence/taskMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 54 additions & 17 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -634,11 +634,50 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(

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()
Expand All @@ -650,39 +689,29 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<string | null>((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)
Expand All @@ -692,22 +721,30 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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 {
console.warn(t("chat:noValidImages"))
}
}
},
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
[
shouldDisableImages,
setSelectedImages,
cursorPosition,
setInputValue,
inputValue,
t,
selectedImages,
setIntendedCursorPosition,
setShowContextMenu,
setCursorPosition,
],
)

const handleMenuMouseDown = useCallback(() => {
Expand Down
12 changes: 9 additions & 3 deletions webview-ui/src/components/chat/TaskActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -19,7 +19,7 @@ interface TaskActionsProps {
export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => {
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
const { t } = useTranslation()
const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard()
const { isCopied: showCopyFeedback, copy } = useClipboard()

return (
<div className="flex flex-row items-center">
Expand All @@ -32,7 +32,13 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => {
<IconButton
iconClass={showCopyFeedback ? "codicon-check" : "codicon-copy"}
title={t("history:copyPrompt")}
onClick={(e) => copyWithFeedback(item.task, e)}
onClick={(e) => {
e.stopPropagation()
copy({
text: item.task,
images: item.images || [],
})
}}
/>
)}
{!!item?.size && item.size > 0 && (
Expand Down
7 changes: 4 additions & 3 deletions webview-ui/src/components/history/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 (
Expand Down
64 changes: 53 additions & 11 deletions webview-ui/src/components/ui/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
@@ -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, ">").replace(/"/g, '"')
const imgTags = limitedImages.map((base64) => `<img src="${base64}" />`).join("")
const html = `<div><p>${escapedText}</p>${imgTags}</div>`
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 }
}
Loading