Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
69 changes: 52 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,48 @@ 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(
`Only ${availableSlots} images can be pasted, ignoring ${imageSrcs.length - newImages.length} images`,
)
}
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 +687,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 +719,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
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] })
})
})
68 changes: 57 additions & 11 deletions webview-ui/src/components/ui/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,68 @@
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
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 }
}