mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
New edit feature implemented for issue #4703
This commit is contained in:
parent
829fe8ff2a
commit
4906396517
4 changed files with 314 additions and 9 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export interface WebviewMessage {
|
|||
| "enhancedPrompt"
|
||||
| "draggedImages"
|
||||
| "deleteMessage"
|
||||
| "editMessage"
|
||||
| "terminalOutputLineLimit"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
|
|
|
|||
|
|
@ -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" }} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
"remove": "Remove",
|
||||
"keep": "Keep"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"number_format": {
|
||||
"thousand_suffix": "k",
|
||||
"million_suffix": "m",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue