Skip to content

Commit 4906396

Browse files
committed
New edit feature implemented for issue #4703
1 parent 829fe8f commit 4906396

File tree

4 files changed

+314
-9
lines changed

4 files changed

+314
-9
lines changed

src/core/webview/webviewMessageHandler.ts

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import fs from "fs/promises"
33
import pWaitFor from "p-wait-for"
44
import * as vscode from "vscode"
55

6-
import { type Language, type ProviderSettings, type GlobalState, TelemetryEventName } from "@roo-code/types"
6+
import {
7+
type Language,
8+
type ProviderSettings,
9+
type GlobalState,
10+
TelemetryEventName,
11+
type ClineMessage,
12+
} from "@roo-code/types"
713
import { CloudService } from "@roo-code/cloud"
814
import { TelemetryService } from "@roo-code/telemetry"
915

@@ -28,6 +34,7 @@ import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
2834
import { singleCompletionHandler } from "../../utils/single-completion-handler"
2935
import { searchCommits } from "../../utils/git"
3036
import { exportSettings, importSettings } from "../config/importExport"
37+
import { checkpointRestore } from "../checkpoints"
3138
import { getOpenAiModels } from "../../api/providers/openai"
3239
import { getOllamaModels } from "../../api/providers/ollama"
3340
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
@@ -959,6 +966,186 @@ export const webviewMessageHandler = async (
959966
}
960967
break
961968
}
969+
case "editMessage": {
970+
if (
971+
provider.getCurrentCline() &&
972+
typeof message.value === "number" &&
973+
message.value &&
974+
message.text !== undefined
975+
) {
976+
const timeCutoff = message.value - 1000 // 1 second buffer before the message to edit
977+
978+
const messageIndex = provider
979+
.getCurrentCline()!
980+
.clineMessages.findIndex((msg) => msg.ts && msg.ts >= timeCutoff)
981+
982+
const apiConversationHistoryIndex =
983+
provider
984+
.getCurrentCline()
985+
?.apiConversationHistory.findIndex((msg) => msg.ts && msg.ts >= timeCutoff) ?? -1
986+
987+
if (messageIndex !== -1) {
988+
// Check if there are subsequent messages that will be deleted
989+
const totalMessages = provider.getCurrentCline()!.clineMessages.length
990+
const hasSubsequentMessages = messageIndex < totalMessages - 1
991+
992+
// Check for checkpoints if enabled
993+
const checkpointsEnabled = (await provider.getState()).enableCheckpoints
994+
let affectedCheckpointsCount = 0
995+
let closestPreviousCheckpoint: ClineMessage | undefined
996+
997+
if (checkpointsEnabled) {
998+
const editMessageTimestamp = message.value
999+
const checkpointMessages = provider
1000+
.getCurrentCline()!
1001+
.clineMessages.filter((msg) => msg.say === "checkpoint_saved")
1002+
.sort((a, b) => a.ts - b.ts)
1003+
1004+
// Find checkpoints that will be affected (those after the edited message)
1005+
affectedCheckpointsCount = checkpointMessages.filter(
1006+
(cp) => cp.ts > editMessageTimestamp,
1007+
).length
1008+
1009+
// Find the closest checkpoint before the edited message
1010+
closestPreviousCheckpoint = checkpointMessages
1011+
.reverse()
1012+
.find((cp) => cp.ts < editMessageTimestamp)
1013+
}
1014+
1015+
// Build confirmation message
1016+
let confirmationMessage = "Edit and delete subsequent messages?"
1017+
1018+
if (checkpointsEnabled && affectedCheckpointsCount > 0) {
1019+
confirmationMessage += `\n\n• ${affectedCheckpointsCount} checkpoint(s) will be removed`
1020+
1021+
if (closestPreviousCheckpoint) {
1022+
confirmationMessage += "\n• Files will restore to previous checkpoint"
1023+
}
1024+
}
1025+
1026+
// Show confirmation dialog if there are subsequent messages or affected checkpoints
1027+
if (hasSubsequentMessages || affectedCheckpointsCount > 0) {
1028+
const confirmation = await vscode.window.showWarningMessage(
1029+
confirmationMessage,
1030+
{ modal: true },
1031+
"Edit Message",
1032+
)
1033+
1034+
if (confirmation !== "Edit Message") {
1035+
// User cancelled, update the webview to show the original state
1036+
await provider.postStateToWebview()
1037+
break
1038+
}
1039+
}
1040+
1041+
const { historyItem } = await provider.getTaskWithId(provider.getCurrentCline()!.taskId)
1042+
1043+
// Get messages up to and including the edited message
1044+
const updatedClineMessages = [
1045+
...provider.getCurrentCline()!.clineMessages.slice(0, messageIndex + 1),
1046+
]
1047+
const messageToEdit = updatedClineMessages[messageIndex]
1048+
1049+
if (messageToEdit && messageToEdit.type === "say" && messageToEdit.say === "user_feedback") {
1050+
// Update the text content
1051+
messageToEdit.text = message.text
1052+
1053+
// Update images if provided
1054+
if (message.images) {
1055+
messageToEdit.images = message.images
1056+
}
1057+
1058+
// Overwrite with only messages up to and including the edited one
1059+
await provider.getCurrentCline()!.overwriteClineMessages(updatedClineMessages)
1060+
1061+
// Handle checkpoint restoration if checkpoints are enabled
1062+
if (checkpointsEnabled && closestPreviousCheckpoint) {
1063+
// Restore to the closest checkpoint before the edited message
1064+
const commitHash = closestPreviousCheckpoint.text // The commit hash is stored in the text field
1065+
if (commitHash) {
1066+
// Use "preview" mode to only restore files without affecting messages
1067+
// (we've already handled message cleanup above)
1068+
await checkpointRestore(provider.getCurrentCline()!, {
1069+
ts: closestPreviousCheckpoint.ts,
1070+
commitHash: commitHash,
1071+
mode: "preview",
1072+
})
1073+
}
1074+
}
1075+
1076+
// Update API conversation history if needed
1077+
if (apiConversationHistoryIndex !== -1) {
1078+
const updatedApiHistory = [
1079+
...provider
1080+
.getCurrentCline()!
1081+
.apiConversationHistory.slice(0, apiConversationHistoryIndex + 1),
1082+
]
1083+
const apiMessage = updatedApiHistory[apiConversationHistoryIndex]
1084+
1085+
if (apiMessage && apiMessage.role === "user") {
1086+
// Update the content in API history
1087+
if (typeof apiMessage.content === "string") {
1088+
apiMessage.content = message.text
1089+
} else if (Array.isArray(apiMessage.content)) {
1090+
// Find and update text content blocks
1091+
apiMessage.content = apiMessage.content.map((block: any) => {
1092+
if (block.type === "text") {
1093+
return { ...block, text: message.text }
1094+
}
1095+
return block
1096+
})
1097+
1098+
// Handle image updates if provided
1099+
if (message.images) {
1100+
// Remove existing image blocks
1101+
apiMessage.content = apiMessage.content.filter(
1102+
(block: any) => block.type !== "image",
1103+
)
1104+
1105+
// Add new image blocks
1106+
const imageBlocks = message.images.map((image) => ({
1107+
type: "image" as const,
1108+
source: {
1109+
type: "base64" as const,
1110+
media_type: (image.startsWith("data:image/png")
1111+
? "image/png"
1112+
: "image/jpeg") as
1113+
| "image/png"
1114+
| "image/jpeg"
1115+
| "image/gif"
1116+
| "image/webp",
1117+
data: image.split(",")[1] || image,
1118+
},
1119+
}))
1120+
1121+
// Add image blocks after text
1122+
apiMessage.content.push(...imageBlocks)
1123+
}
1124+
}
1125+
1126+
// Overwrite with only API messages up to and including the edited one
1127+
await provider.getCurrentCline()!.overwriteApiConversationHistory(updatedApiHistory)
1128+
}
1129+
}
1130+
1131+
await provider.initClineWithHistoryItem(historyItem)
1132+
// Force a state update to ensure the webview reflects the changes
1133+
await provider.postStateToWebview()
1134+
1135+
// Auto-resume the task after editing
1136+
// Use setTimeout to ensure the task is fully initialized and the ask dialog is ready
1137+
setTimeout(async () => {
1138+
const currentCline = provider.getCurrentCline()
1139+
if (currentCline && currentCline.isInitialized) {
1140+
// Simulate clicking "Resume Task" by sending the response directly
1141+
currentCline.handleWebviewAskResponse("messageResponse", message.text, message.images)
1142+
}
1143+
}, 100) // Small delay to ensure proper initialization
1144+
}
1145+
}
1146+
}
1147+
break
1148+
}
9621149
case "screenshotQuality":
9631150
await updateGlobalState("screenshotQuality", message.value)
9641151
await provider.postStateToWebview()

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export interface WebviewMessage {
9999
| "enhancedPrompt"
100100
| "draggedImages"
101101
| "deleteMessage"
102+
| "editMessage"
102103
| "terminalOutputLineLimit"
103104
| "terminalShellIntegrationTimeout"
104105
| "terminalShellIntegrationDisabled"

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

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,41 @@ export const ChatRowContent = ({
107107
const [showCopySuccess, setShowCopySuccess] = useState(false)
108108
const { copyWithFeedback } = useCopyToClipboard()
109109

110+
// Edit mode state
111+
const [isEditing, setIsEditing] = useState(false)
112+
const [editValue, setEditValue] = useState(message.text || "")
113+
const [editImages, setEditImages] = useState<string[]>(message.images || [])
114+
110115
// Memoized callback to prevent re-renders caused by inline arrow functions
111116
const handleToggleExpand = useCallback(() => {
112117
onToggleExpand(message.ts)
113118
}, [onToggleExpand, message.ts])
114119

120+
// Edit mode handlers
121+
const handleEditSave = useCallback(() => {
122+
if (editValue.trim() || editImages.length > 0) {
123+
vscode.postMessage({
124+
type: "editMessage",
125+
value: message.ts,
126+
text: editValue.trim(),
127+
images: editImages.length > 0 ? editImages : undefined,
128+
})
129+
setIsEditing(false)
130+
}
131+
}, [editValue, editImages, message.ts])
132+
133+
const handleEditCancel = useCallback(() => {
134+
setEditValue(message.text || "")
135+
setEditImages(message.images || [])
136+
setIsEditing(false)
137+
}, [message.text, message.images])
138+
139+
const handleStartEdit = useCallback(() => {
140+
setEditValue(message.text || "")
141+
setEditImages(message.images || [])
142+
setIsEditing(true)
143+
}, [message.text, message.images])
144+
115145
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
116146
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
117147
const info = safeJsonParse<ClineApiReqInfo>(message.text)
@@ -978,24 +1008,107 @@ export const ChatRowContent = ({
9781008
</div>
9791009
)
9801010
case "user_feedback":
981-
return (
982-
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap">
983-
<div className="flex justify-between">
984-
<div className="flex-grow px-2 py-1 wrap-anywhere">
985-
<Mention text={message.text} withShadow />
1011+
if (isEditing) {
1012+
return (
1013+
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden">
1014+
<div className="space-y-2 p-1">
1015+
<textarea
1016+
value={editValue}
1017+
onChange={(e) => setEditValue(e.target.value)}
1018+
onKeyDown={(e) => {
1019+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
1020+
e.preventDefault()
1021+
handleEditSave()
1022+
} else if (e.key === "Escape") {
1023+
e.preventDefault()
1024+
handleEditCancel()
1025+
}
1026+
}}
1027+
className="w-full min-h-[60px] max-h-[200px] p-2
1028+
bg-vscode-input-background
1029+
text-vscode-input-foreground
1030+
rounded-xs resize-none
1031+
focus:outline-none focus:ring-1 focus:ring-vscode-focusBorder"
1032+
placeholder={t("chat:typeMessagePlaceholder")}
1033+
autoFocus
1034+
/>
1035+
{editImages.length > 0 && (
1036+
<Thumbnails
1037+
images={editImages}
1038+
style={{ marginTop: "4px" }}
1039+
setImages={setEditImages}
1040+
/>
1041+
)}
1042+
<div className="flex gap-2 justify-between">
1043+
<Button
1044+
variant="ghost"
1045+
size="icon"
1046+
title={t("chat:addImages")}
1047+
onClick={async (e) => {
1048+
e.stopPropagation()
1049+
vscode.postMessage({ type: "selectImages" })
1050+
// Wait for the response
1051+
const handleSelectedImages = (event: MessageEvent) => {
1052+
const message = event.data
1053+
if (message.type === "selectedImages" && message.images) {
1054+
setEditImages([...editImages, ...message.images])
1055+
window.removeEventListener("message", handleSelectedImages)
1056+
}
1057+
}
1058+
window.addEventListener("message", handleSelectedImages)
1059+
}}
1060+
disabled={editImages.length >= 20}>
1061+
<span className="codicon codicon-file-media" />
1062+
</Button>
1063+
<div className="flex gap-2">
1064+
<Button variant="secondary" size="sm" onClick={handleEditCancel}>
1065+
{t("chat:cancel.title")}
1066+
</Button>
1067+
<Button
1068+
variant="default"
1069+
size="sm"
1070+
onClick={handleEditSave}
1071+
disabled={!editValue.trim() && editImages.length === 0}>
1072+
{t("chat:save.title")}
1073+
</Button>
1074+
</div>
1075+
</div>
9861076
</div>
1077+
</div>
1078+
)
1079+
}
1080+
1081+
return (
1082+
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap relative group">
1083+
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
9871084
<Button
9881085
variant="ghost"
989-
size="icon"
990-
className="shrink-0"
1086+
size="sm"
1087+
className="h-4.5 w-4.5 p-0"
9911088
disabled={isStreaming}
1089+
title={t("common:actions.edit")}
1090+
onClick={(e) => {
1091+
e.stopPropagation()
1092+
handleStartEdit()
1093+
}}>
1094+
<span className="codicon codicon-edit" style={{ fontSize: "11px" }} />
1095+
</Button>
1096+
<Button
1097+
variant="ghost"
1098+
size="sm"
1099+
className="h-4.5 w-4.5 p-0"
1100+
disabled={isStreaming}
1101+
title={t("common:actions.delete")}
9921102
onClick={(e) => {
9931103
e.stopPropagation()
9941104
vscode.postMessage({ type: "deleteMessage", value: message.ts })
9951105
}}>
996-
<span className="codicon codicon-trash" />
1106+
<span className="codicon codicon-trash" style={{ fontSize: "11px" }} />
9971107
</Button>
9981108
</div>
1109+
<div className="px-2 py-1 wrap-anywhere">
1110+
<Mention text={message.text} withShadow />
1111+
</div>
9991112
{message.images && message.images.length > 0 && (
10001113
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
10011114
)}

webview-ui/src/i18n/locales/en/common.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
"remove": "Remove",
77
"keep": "Keep"
88
},
9+
"actions": {
10+
"edit": "Edit",
11+
"delete": "Delete"
12+
},
913
"number_format": {
1014
"thousand_suffix": "k",
1115
"million_suffix": "m",

0 commit comments

Comments
 (0)