New edit feature implemented for issue #4703

This commit is contained in:
Sannidhya Sah 2025-06-15 15:29:24 +05:30
parent 829fe8ff2a
commit 4906396517
4 changed files with 314 additions and 9 deletions

View file

@ -3,7 +3,13 @@ import fs from "fs/promises"
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { type Language, type ProviderSettings, type GlobalState, TelemetryEventName } from "@roo-code/types"
import {
type Language,
type ProviderSettings,
type GlobalState,
TelemetryEventName,
type ClineMessage,
} from "@roo-code/types"
import { CloudService } from "@roo-code/cloud"
import { TelemetryService } from "@roo-code/telemetry"
@ -28,6 +34,7 @@ import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { searchCommits } from "../../utils/git"
import { exportSettings, importSettings } from "../config/importExport"
import { checkpointRestore } from "../checkpoints"
import { getOpenAiModels } from "../../api/providers/openai"
import { getOllamaModels } from "../../api/providers/ollama"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
@ -959,6 +966,186 @@ export const webviewMessageHandler = async (
}
break
}
case "editMessage": {
if (
provider.getCurrentCline() &&
typeof message.value === "number" &&
message.value &&
message.text !== undefined
) {
const timeCutoff = message.value - 1000 // 1 second buffer before the message to edit
const messageIndex = provider
.getCurrentCline()!
.clineMessages.findIndex((msg) => msg.ts && msg.ts >= timeCutoff)
const apiConversationHistoryIndex =
provider
.getCurrentCline()
?.apiConversationHistory.findIndex((msg) => msg.ts && msg.ts >= timeCutoff) ?? -1
if (messageIndex !== -1) {
// Check if there are subsequent messages that will be deleted
const totalMessages = provider.getCurrentCline()!.clineMessages.length
const hasSubsequentMessages = messageIndex < totalMessages - 1
// Check for checkpoints if enabled
const checkpointsEnabled = (await provider.getState()).enableCheckpoints
let affectedCheckpointsCount = 0
let closestPreviousCheckpoint: ClineMessage | undefined
if (checkpointsEnabled) {
const editMessageTimestamp = message.value
const checkpointMessages = provider
.getCurrentCline()!
.clineMessages.filter((msg) => msg.say === "checkpoint_saved")
.sort((a, b) => a.ts - b.ts)
// Find checkpoints that will be affected (those after the edited message)
affectedCheckpointsCount = checkpointMessages.filter(
(cp) => cp.ts > editMessageTimestamp,
).length
// Find the closest checkpoint before the edited message
closestPreviousCheckpoint = checkpointMessages
.reverse()
.find((cp) => cp.ts < editMessageTimestamp)
}
// Build confirmation message
let confirmationMessage = "Edit and delete subsequent messages?"
if (checkpointsEnabled && affectedCheckpointsCount > 0) {
confirmationMessage += `\n\n• ${affectedCheckpointsCount} checkpoint(s) will be removed`
if (closestPreviousCheckpoint) {
confirmationMessage += "\n• Files will restore to previous checkpoint"
}
}
// Show confirmation dialog if there are subsequent messages or affected checkpoints
if (hasSubsequentMessages || affectedCheckpointsCount > 0) {
const confirmation = await vscode.window.showWarningMessage(
confirmationMessage,
{ modal: true },
"Edit Message",
)
if (confirmation !== "Edit Message") {
// User cancelled, update the webview to show the original state
await provider.postStateToWebview()
break
}
}
const { historyItem } = await provider.getTaskWithId(provider.getCurrentCline()!.taskId)
// Get messages up to and including the edited message
const updatedClineMessages = [
...provider.getCurrentCline()!.clineMessages.slice(0, messageIndex + 1),
]
const messageToEdit = updatedClineMessages[messageIndex]
if (messageToEdit && messageToEdit.type === "say" && messageToEdit.say === "user_feedback") {
// Update the text content
messageToEdit.text = message.text
// Update images if provided
if (message.images) {
messageToEdit.images = message.images
}
// Overwrite with only messages up to and including the edited one
await provider.getCurrentCline()!.overwriteClineMessages(updatedClineMessages)
// Handle checkpoint restoration if checkpoints are enabled
if (checkpointsEnabled && closestPreviousCheckpoint) {
// Restore to the closest checkpoint before the edited message
const commitHash = closestPreviousCheckpoint.text // The commit hash is stored in the text field
if (commitHash) {
// Use "preview" mode to only restore files without affecting messages
// (we've already handled message cleanup above)
await checkpointRestore(provider.getCurrentCline()!, {
ts: closestPreviousCheckpoint.ts,
commitHash: commitHash,
mode: "preview",
})
}
}
// Update API conversation history if needed
if (apiConversationHistoryIndex !== -1) {
const updatedApiHistory = [
...provider
.getCurrentCline()!
.apiConversationHistory.slice(0, apiConversationHistoryIndex + 1),
]
const apiMessage = updatedApiHistory[apiConversationHistoryIndex]
if (apiMessage && apiMessage.role === "user") {
// Update the content in API history
if (typeof apiMessage.content === "string") {
apiMessage.content = message.text
} else if (Array.isArray(apiMessage.content)) {
// Find and update text content blocks
apiMessage.content = apiMessage.content.map((block: any) => {
if (block.type === "text") {
return { ...block, text: message.text }
}
return block
})
// Handle image updates if provided
if (message.images) {
// Remove existing image blocks
apiMessage.content = apiMessage.content.filter(
(block: any) => block.type !== "image",
)
// Add new image blocks
const imageBlocks = message.images.map((image) => ({
type: "image" as const,
source: {
type: "base64" as const,
media_type: (image.startsWith("data:image/png")
? "image/png"
: "image/jpeg") as
| "image/png"
| "image/jpeg"
| "image/gif"
| "image/webp",
data: image.split(",")[1] || image,
},
}))
// Add image blocks after text
apiMessage.content.push(...imageBlocks)
}
}
// Overwrite with only API messages up to and including the edited one
await provider.getCurrentCline()!.overwriteApiConversationHistory(updatedApiHistory)
}
}
await provider.initClineWithHistoryItem(historyItem)
// Force a state update to ensure the webview reflects the changes
await provider.postStateToWebview()
// Auto-resume the task after editing
// Use setTimeout to ensure the task is fully initialized and the ask dialog is ready
setTimeout(async () => {
const currentCline = provider.getCurrentCline()
if (currentCline && currentCline.isInitialized) {
// Simulate clicking "Resume Task" by sending the response directly
currentCline.handleWebviewAskResponse("messageResponse", message.text, message.images)
}
}, 100) // Small delay to ensure proper initialization
}
}
}
break
}
case "screenshotQuality":
await updateGlobalState("screenshotQuality", message.value)
await provider.postStateToWebview()

View file

@ -99,6 +99,7 @@ export interface WebviewMessage {
| "enhancedPrompt"
| "draggedImages"
| "deleteMessage"
| "editMessage"
| "terminalOutputLineLimit"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"

View file

@ -107,11 +107,41 @@ export const ChatRowContent = ({
const [showCopySuccess, setShowCopySuccess] = useState(false)
const { copyWithFeedback } = useCopyToClipboard()
// Edit mode state
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(message.text || "")
const [editImages, setEditImages] = useState<string[]>(message.images || [])
// Memoized callback to prevent re-renders caused by inline arrow functions
const handleToggleExpand = useCallback(() => {
onToggleExpand(message.ts)
}, [onToggleExpand, message.ts])
// Edit mode handlers
const handleEditSave = useCallback(() => {
if (editValue.trim() || editImages.length > 0) {
vscode.postMessage({
type: "editMessage",
value: message.ts,
text: editValue.trim(),
images: editImages.length > 0 ? editImages : undefined,
})
setIsEditing(false)
}
}, [editValue, editImages, message.ts])
const handleEditCancel = useCallback(() => {
setEditValue(message.text || "")
setEditImages(message.images || [])
setIsEditing(false)
}, [message.text, message.images])
const handleStartEdit = useCallback(() => {
setEditValue(message.text || "")
setEditImages(message.images || [])
setIsEditing(true)
}, [message.text, message.images])
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
const info = safeJsonParse<ClineApiReqInfo>(message.text)
@ -978,24 +1008,107 @@ export const ChatRowContent = ({
</div>
)
case "user_feedback":
return (
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap">
<div className="flex justify-between">
<div className="flex-grow px-2 py-1 wrap-anywhere">
<Mention text={message.text} withShadow />
if (isEditing) {
return (
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden">
<div className="space-y-2 p-1">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleEditSave()
} else if (e.key === "Escape") {
e.preventDefault()
handleEditCancel()
}
}}
className="w-full min-h-[60px] max-h-[200px] p-2
bg-vscode-input-background
text-vscode-input-foreground
rounded-xs resize-none
focus:outline-none focus:ring-1 focus:ring-vscode-focusBorder"
placeholder={t("chat:typeMessagePlaceholder")}
autoFocus
/>
{editImages.length > 0 && (
<Thumbnails
images={editImages}
style={{ marginTop: "4px" }}
setImages={setEditImages}
/>
)}
<div className="flex gap-2 justify-between">
<Button
variant="ghost"
size="icon"
title={t("chat:addImages")}
onClick={async (e) => {
e.stopPropagation()
vscode.postMessage({ type: "selectImages" })
// Wait for the response
const handleSelectedImages = (event: MessageEvent) => {
const message = event.data
if (message.type === "selectedImages" && message.images) {
setEditImages([...editImages, ...message.images])
window.removeEventListener("message", handleSelectedImages)
}
}
window.addEventListener("message", handleSelectedImages)
}}
disabled={editImages.length >= 20}>
<span className="codicon codicon-file-media" />
</Button>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleEditCancel}>
{t("chat:cancel.title")}
</Button>
<Button
variant="default"
size="sm"
onClick={handleEditSave}
disabled={!editValue.trim() && editImages.length === 0}>
{t("chat:save.title")}
</Button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap relative group">
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="shrink-0"
size="sm"
className="h-4.5 w-4.5 p-0"
disabled={isStreaming}
title={t("common:actions.edit")}
onClick={(e) => {
e.stopPropagation()
handleStartEdit()
}}>
<span className="codicon codicon-edit" style={{ fontSize: "11px" }} />
</Button>
<Button
variant="ghost"
size="sm"
className="h-4.5 w-4.5 p-0"
disabled={isStreaming}
title={t("common:actions.delete")}
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "deleteMessage", value: message.ts })
}}>
<span className="codicon codicon-trash" />
<span className="codicon codicon-trash" style={{ fontSize: "11px" }} />
</Button>
</div>
<div className="px-2 py-1 wrap-anywhere">
<Mention text={message.text} withShadow />
</div>
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}

View file

@ -6,6 +6,10 @@
"remove": "Remove",
"keep": "Keep"
},
"actions": {
"edit": "Edit",
"delete": "Delete"
},
"number_format": {
"thousand_suffix": "k",
"million_suffix": "m",