feat: add lightweight conversation forking functionality

- Add fork button to user messages in ChatRow
- Reuse existing delete message logic for truncating history
- Create new task with truncated history using existing createTaskWithHistoryItem
- No tool exposed to the model - purely user-driven functionality
- Minimal implementation as requested in issue feedback

Addresses #9790
This commit is contained in:
Roo Code 2025-12-03 20:03:38 +00:00
parent 1879200964
commit f827c3267e
6 changed files with 119 additions and 1 deletions

View file

@ -2652,6 +2652,70 @@ export class ClineProvider
// task is a subtask of the previous one, and when it finishes it is removed
// from the stack and the caller is resumed in this way we can have a chain
// of tasks, each one being a sub task of the previous one until the main
/**
* Fork the current conversation at a specific message timestamp.
* Creates a new task with the conversation history up to that point.
*/
public async forkConversationAtMessage(messageTs: number): Promise<void> {
const currentTask = this.getCurrentTask()
if (!currentTask) {
throw new Error("No active task to fork")
}
// Find the message index to fork from
const messageIndex = currentTask.clineMessages.findIndex((msg: any) => msg.ts === messageTs)
if (messageIndex === -1) {
throw new Error(`Message with timestamp ${messageTs} not found`)
}
// Find the corresponding API conversation history index
const apiIndex = currentTask.apiConversationHistory.findIndex((msg: any) => msg.ts === messageTs)
// If exact match not found, find the first API message at or after the timestamp
let apiIndexToUse = apiIndex
if (apiIndexToUse === -1) {
apiIndexToUse = currentTask.apiConversationHistory.findIndex(
(msg: any) => typeof msg?.ts === "number" && msg.ts >= messageTs,
)
}
// Get the current mode from provider state
const { mode } = await this.getState()
// Create a new history item with messages up to the fork point
const forkedHistoryItem: HistoryItem = {
id: currentTask.taskId, // Will be replaced with a new ID in createTaskWithHistoryItem
ts: Date.now(),
number: 0, // Will be set by the history system
mode: mode,
task: `Forked from: ${currentTask.taskId}`,
tokensIn: 0, // Reset token counts for the fork
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
// Note: messages are stored separately in the task persistence system
}
// Store the forked messages in the task's message history
const forkedMessages = currentTask.clineMessages.slice(0, messageIndex + 1)
const forkedApiHistory =
apiIndexToUse !== -1 ? currentTask.apiConversationHistory.slice(0, apiIndexToUse + 1) : []
// Create the new task with the forked history
// We'll need to pass the messages through a different mechanism
const newTask = await this.createTaskWithHistoryItem(forkedHistoryItem)
// Now update the new task with the forked messages
if (this.getCurrentTask()) {
await this.getCurrentTask()!.overwriteClineMessages(forkedMessages)
await this.getCurrentTask()!.overwriteApiConversationHistory(forkedApiHistory)
}
// The new task is now active, and the UI will be updated
this.log(`Forked conversation from message at ${messageTs}`)
}
// task is finished.
public async createTask(
text?: string,

View file

@ -1915,6 +1915,35 @@ export const webviewMessageHandler = async (
await handleDeleteMessageConfirm(message.messageTs, message.restoreCheckpoint)
break
case "forkConversation": {
const forkMessageTs = message.messageTs
if (!forkMessageTs || typeof forkMessageTs !== "number") {
await vscode.window.showErrorMessage(t("common:errors.message.invalid_timestamp_for_fork"))
break
}
const currentTask = provider.getCurrentTask()
if (!currentTask) {
await vscode.window.showErrorMessage(t("common:errors.message.no_active_task_to_fork"))
break
}
try {
// Fork the conversation at the specified message
await provider.forkConversationAtMessage(forkMessageTs)
// Show success message
await vscode.window.showInformationMessage(t("common:info.conversation_forked"))
} catch (error) {
console.error("Error forking conversation:", error)
await vscode.window.showErrorMessage(
t("common:errors.message.error_forking_conversation", {
error: error instanceof Error ? error.message : String(error),
}),
)
}
break
}
case "editMessageConfirm":
if (message.messageTs && message.text) {
await handleEditMessageConfirm(

View file

@ -101,7 +101,10 @@
"cannot_delete_invalid_timestamp": "Cannot delete message: invalid timestamp",
"message_not_found": "Message with timestamp {{messageTs}} not found",
"error_deleting_message": "Error deleting message: {{error}}",
"error_editing_message": "Error editing message: {{error}}"
"error_editing_message": "Error editing message: {{error}}",
"invalid_timestamp_for_fork": "Invalid timestamp for forking conversation",
"no_active_task_to_fork": "No active task to fork",
"error_forking_conversation": "Error forking conversation: {{error}}"
},
"gemini": {
"generate_stream": "Gemini generate context stream error: {{error}}",
@ -145,6 +148,7 @@
"share_link_copied": "Share link copied to clipboard",
"organization_share_link_copied": "Organization share link copied to clipboard!",
"public_share_link_copied": "Public share link copied to clipboard!",
"conversation_forked": "Conversation forked successfully",
"image_copied_to_clipboard": "Image data URI copied to clipboard",
"image_saved": "Image saved to {{path}}",
"mode_exported": "Mode '{{mode}}' exported successfully",

View file

@ -92,6 +92,7 @@ export interface WebviewMessage {
| "deleteMessage"
| "deleteMessageConfirm"
| "submitEditedMessage"
| "forkConversation"
| "editMessageConfirm"
| "enableMcpServerCreation"
| "remoteControlEnabled"

View file

@ -59,6 +59,7 @@ import {
FolderTree,
TerminalSquare,
MessageCircle,
GitBranch,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { PathTooltip } from "../ui/PathTooltip"
@ -1196,6 +1197,22 @@ export const ChatRowContent = ({
}}>
<Edit className="w-4 shrink-0" aria-label="Edit message icon" />
</div>
<div
className="cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ visibility: isStreaming ? "hidden" : "visible" }}
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({
type: "forkConversation",
messageTs: message.ts,
})
}}
title={t("chat:fork.tooltip")}>
<GitBranch
className="w-4 shrink-0"
aria-label="Fork conversation icon"
/>
</div>
<div
className="cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ visibility: isStreaming ? "hidden" : "visible" }}

View file

@ -241,6 +241,9 @@
"feedback": {
"youSaid": "You said"
},
"fork": {
"tooltip": "Fork conversation from this point"
},
"mcp": {
"wantsToUseTool": "Roo wants to use a tool on the {{serverName}} MCP server",
"wantsToAccessResource": "Roo wants to access a resource on the {{serverName}} MCP server"