mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
feat: implement conversation forking feature (#10049)
- Add forkedFromTaskId field to HistoryItem schema - Implement forkCurrentTask() method in ClineProvider with atomic copy-then-switch - Add forkTask message handler in webviewMessageHandler - Add fork button to TaskActions UI component with GitBranch icon - Add "Forked from..." indicator in TaskHeader with link to parent task - Add comprehensive tests for fork functionality - Preserve all persisted state (UI/API history, tokens, cost, metadata) - Reset delegation fields in forked task to start fresh - Include error handling and partial fork cleanup on failure
This commit is contained in:
parent
d976a9b296
commit
1e6ac27950
10 changed files with 578 additions and 2 deletions
|
|
@ -25,6 +25,7 @@ export const historyItemSchema = z.object({
|
|||
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
|
||||
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
|
||||
completionResultSummary: z.string().optional(), // Summary from completed child
|
||||
forkedFromTaskId: z.string().optional(), // ID of the task this was forked from
|
||||
})
|
||||
|
||||
export type HistoryItem = z.infer<typeof historyItemSchema>
|
||||
|
|
|
|||
|
|
@ -1674,6 +1674,111 @@ export class ClineProvider
|
|||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the current task, creating an exact copy with all conversation history and state.
|
||||
* This enables exploring multiple task paths without reloading context.
|
||||
*
|
||||
* Implements atomic copy-then-switch for safety:
|
||||
* 1. Creates new task directory and copies all data
|
||||
* 2. Creates new history item with forkedFromTaskId reference
|
||||
* 3. Only switches to the fork after all data is persisted
|
||||
*
|
||||
* @returns The new forked task ID
|
||||
*/
|
||||
async forkCurrentTask(): Promise<string> {
|
||||
const currentTask = this.getCurrentTask()
|
||||
if (!currentTask) {
|
||||
throw new Error("No current task to fork")
|
||||
}
|
||||
|
||||
const currentTaskId = currentTask.taskId
|
||||
const { historyItem } = await this.getTaskWithId(currentTaskId)
|
||||
|
||||
// Generate new unique task ID
|
||||
const newTaskId = Date.now().toString()
|
||||
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
|
||||
|
||||
try {
|
||||
// ATOMIC COPY PHASE: Copy all task data before switching
|
||||
|
||||
// 1. Read current task's UI messages
|
||||
const uiMessages = await readTaskMessages({
|
||||
taskId: currentTaskId,
|
||||
globalStoragePath,
|
||||
})
|
||||
|
||||
// 2. Read current task's API messages
|
||||
const apiMessages = await readApiMessages({
|
||||
taskId: currentTaskId,
|
||||
globalStoragePath,
|
||||
})
|
||||
|
||||
// 3. Create new task directory and save messages atomically
|
||||
await saveTaskMessages({
|
||||
messages: uiMessages,
|
||||
taskId: newTaskId,
|
||||
globalStoragePath,
|
||||
})
|
||||
|
||||
await saveApiMessages({
|
||||
messages: apiMessages,
|
||||
taskId: newTaskId,
|
||||
globalStoragePath,
|
||||
})
|
||||
|
||||
// 4. Create new history item with all metadata preserved
|
||||
const newHistoryItem: HistoryItem = {
|
||||
...historyItem,
|
||||
id: newTaskId,
|
||||
ts: Date.now(),
|
||||
forkedFromTaskId: currentTaskId,
|
||||
// Reset delegation fields for the fork
|
||||
status: undefined,
|
||||
delegatedToId: undefined,
|
||||
awaitingChildId: undefined,
|
||||
completedByChildId: undefined,
|
||||
completionResultSummary: undefined,
|
||||
// Preserve parent/root relationships
|
||||
parentTaskId: historyItem.parentTaskId,
|
||||
rootTaskId: historyItem.rootTaskId,
|
||||
// Note: childIds not copied - fork starts fresh without children
|
||||
}
|
||||
|
||||
// 5. Add new history item to state
|
||||
await this.updateTaskHistory(newHistoryItem)
|
||||
|
||||
this.log(`[forkCurrentTask] Successfully forked task ${currentTaskId} to ${newTaskId}`)
|
||||
|
||||
// SWITCH PHASE: Only after all data is safely persisted
|
||||
// 6. Switch to the newly forked task
|
||||
await this.createTaskWithHistoryItem(newHistoryItem)
|
||||
|
||||
// 7. Post success message to webview
|
||||
await this.postMessageToWebview({
|
||||
type: "taskForked",
|
||||
taskId: newTaskId,
|
||||
forkedFromTaskId: currentTaskId,
|
||||
})
|
||||
|
||||
return newTaskId
|
||||
} catch (error) {
|
||||
// Clean up partial fork on error
|
||||
try {
|
||||
await this.deleteTaskWithId(newTaskId)
|
||||
} catch (cleanupError) {
|
||||
this.log(
|
||||
`[forkCurrentTask] Failed to clean up partial fork ${newTaskId}: ${
|
||||
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.log(`[forkCurrentTask] Failed to fork task ${currentTaskId}: ${errorMessage}`)
|
||||
throw new Error(`Failed to fork task: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWorkspace() {
|
||||
this.currentWorkspacePath = getWorkspacePath()
|
||||
await this.postStateToWebview()
|
||||
|
|
|
|||
387
src/core/webview/__tests__/ClineProvider.fork.spec.ts
Normal file
387
src/core/webview/__tests__/ClineProvider.fork.spec.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
// npx vitest src/core/webview/__tests__/ClineProvider.fork.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../ClineProvider"
|
||||
import { ContextProxy } from "../../config/ContextProxy"
|
||||
import type { HistoryItem } from "@roo-code/types"
|
||||
import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages } from "../../task-persistence"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
Uri: {
|
||||
file: (path: string) => ({ fsPath: path }),
|
||||
joinPath: vi.fn(),
|
||||
},
|
||||
ExtensionMode: {
|
||||
Production: 1,
|
||||
Development: 2,
|
||||
Test: 3,
|
||||
},
|
||||
commands: {
|
||||
executeCommand: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showWarningMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
},
|
||||
workspace: {
|
||||
getConfiguration: vi.fn().mockReturnValue({
|
||||
get: vi.fn().mockReturnValue([]),
|
||||
update: vi.fn(),
|
||||
}),
|
||||
onDidChangeConfiguration: vi.fn().mockImplementation(() => ({
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
workspaceFolders: [],
|
||||
},
|
||||
env: {
|
||||
uriScheme: "vscode",
|
||||
language: "en",
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../task-persistence")
|
||||
vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({
|
||||
TerminalRegistry: {
|
||||
getAllTerminals: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
initializeFilePaths: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("../../task/Task", () => ({
|
||||
Task: vi.fn().mockImplementation((options: any) => ({
|
||||
taskId: options?.historyItem?.id || "test-task-id",
|
||||
emit: vi.fn(),
|
||||
abortTask: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@roo-code/cloud", () => ({
|
||||
CloudService: {
|
||||
hasInstance: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
BridgeOrchestrator: {
|
||||
isEnabled: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
|
||||
}))
|
||||
|
||||
describe("ClineProvider.forkCurrentTask()", () => {
|
||||
let provider: ClineProvider
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockOutputChannel: vscode.OutputChannel
|
||||
let mockContextProxy: ContextProxy
|
||||
|
||||
beforeEach(() => {
|
||||
// Initialize TelemetryService
|
||||
if (!TelemetryService.hasInstance()) {
|
||||
TelemetryService.createInstance([])
|
||||
}
|
||||
|
||||
// Create mock context
|
||||
mockContext = {
|
||||
globalState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setKeysForSync: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
workspaceState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
secrets: {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
store: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
onDidChange: vi.fn(),
|
||||
},
|
||||
subscriptions: [],
|
||||
extensionUri: vscode.Uri.file("/test/extension"),
|
||||
extensionPath: "/test/extension",
|
||||
globalStorageUri: vscode.Uri.file("/test/storage"),
|
||||
storageUri: vscode.Uri.file("/test/workspace-storage"),
|
||||
logUri: vscode.Uri.file("/test/logs"),
|
||||
extensionMode: vscode.ExtensionMode.Test,
|
||||
} as any
|
||||
|
||||
mockOutputChannel = {
|
||||
appendLine: vi.fn(),
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
} as any
|
||||
|
||||
mockContextProxy = new ContextProxy(mockContext)
|
||||
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy)
|
||||
|
||||
// Mock file system operations
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([
|
||||
{ type: "say", say: "text", text: "Hello", ts: 1000 },
|
||||
{ type: "ask", ask: "completion_result", text: "Done", ts: 2000 },
|
||||
] as any)
|
||||
|
||||
vi.mocked(readApiMessages).mockResolvedValue([
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }], ts: 1000 },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Response" }], ts: 1500 },
|
||||
] as any)
|
||||
|
||||
vi.mocked(saveTaskMessages).mockResolvedValue()
|
||||
vi.mocked(saveApiMessages).mockResolvedValue()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should fork current task and create an exact copy", async () => {
|
||||
// Setup: Create a mock current task
|
||||
const originalHistoryItem: HistoryItem = {
|
||||
id: "task-123",
|
||||
number: 1,
|
||||
ts: 1000,
|
||||
task: "Original task",
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
totalCost: 0.05,
|
||||
mode: "code",
|
||||
workspace: "/test/workspace",
|
||||
}
|
||||
|
||||
// Mock getTaskWithId to return the original task
|
||||
vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({
|
||||
historyItem: originalHistoryItem,
|
||||
taskDirPath: "/test/storage/tasks/task-123",
|
||||
})
|
||||
|
||||
// Mock getCurrentTask to return a task
|
||||
vi.spyOn(provider, "getCurrentTask").mockReturnValue({
|
||||
taskId: "task-123",
|
||||
} as any)
|
||||
|
||||
// Mock updateTaskHistory
|
||||
vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([])
|
||||
|
||||
// Mock createTaskWithHistoryItem
|
||||
vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({
|
||||
taskId: "new-task-id",
|
||||
})
|
||||
|
||||
// Mock postMessageToWebview
|
||||
vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined)
|
||||
|
||||
// Execute fork
|
||||
const newTaskId = await provider.forkCurrentTask()
|
||||
|
||||
// Assertions
|
||||
expect(newTaskId).toBeDefined()
|
||||
expect(readTaskMessages).toHaveBeenCalledWith({
|
||||
taskId: "task-123",
|
||||
globalStoragePath: "/test/storage",
|
||||
})
|
||||
expect(readApiMessages).toHaveBeenCalledWith({
|
||||
taskId: "task-123",
|
||||
globalStoragePath: "/test/storage",
|
||||
})
|
||||
|
||||
// Verify messages were saved with new task ID
|
||||
expect(saveTaskMessages).toHaveBeenCalledWith({
|
||||
messages: expect.any(Array),
|
||||
taskId: expect.stringMatching(/^\d+$/), // New timestamp-based ID
|
||||
globalStoragePath: "/test/storage",
|
||||
})
|
||||
|
||||
expect(saveApiMessages).toHaveBeenCalledWith({
|
||||
messages: expect.any(Array),
|
||||
taskId: expect.stringMatching(/^\d+$/), // New timestamp-based ID
|
||||
globalStoragePath: "/test/storage",
|
||||
})
|
||||
|
||||
// Verify new history item was created with forkedFromTaskId
|
||||
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
forkedFromTaskId: "task-123",
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
totalCost: 0.05,
|
||||
mode: "code",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify switch to new task
|
||||
expect(provider.createTaskWithHistoryItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
forkedFromTaskId: "task-123",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify success message posted
|
||||
expect(provider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "taskForked",
|
||||
taskId: newTaskId,
|
||||
forkedFromTaskId: "task-123",
|
||||
})
|
||||
})
|
||||
|
||||
it("should reset delegation fields in forked task", async () => {
|
||||
// Setup: Task with delegation metadata
|
||||
const delegatedHistoryItem: HistoryItem = {
|
||||
id: "task-456",
|
||||
number: 1,
|
||||
ts: 1000,
|
||||
task: "Delegated task",
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
totalCost: 0.05,
|
||||
status: "delegated",
|
||||
delegatedToId: "child-task-1",
|
||||
awaitingChildId: "child-task-1",
|
||||
childIds: ["child-task-1"],
|
||||
completedByChildId: "child-task-1",
|
||||
completionResultSummary: "Child completed",
|
||||
}
|
||||
|
||||
vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({
|
||||
historyItem: delegatedHistoryItem,
|
||||
})
|
||||
vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-456" } as any)
|
||||
vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([])
|
||||
vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({})
|
||||
vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined)
|
||||
|
||||
await provider.forkCurrentTask()
|
||||
|
||||
// Verify delegation fields are reset
|
||||
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
forkedFromTaskId: "task-456",
|
||||
status: undefined,
|
||||
delegatedToId: undefined,
|
||||
awaitingChildId: undefined,
|
||||
completedByChildId: undefined,
|
||||
completionResultSummary: undefined,
|
||||
// childIds should not be present (not copied)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should preserve parent/root relationships", async () => {
|
||||
// Setup: Task with parent/root relationships
|
||||
const subtaskHistoryItem: HistoryItem = {
|
||||
id: "task-789",
|
||||
number: 2,
|
||||
ts: 1000,
|
||||
task: "Subtask",
|
||||
tokensIn: 50,
|
||||
tokensOut: 25,
|
||||
totalCost: 0.02,
|
||||
parentTaskId: "parent-task",
|
||||
rootTaskId: "root-task",
|
||||
}
|
||||
|
||||
vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({
|
||||
historyItem: subtaskHistoryItem,
|
||||
})
|
||||
vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-789" } as any)
|
||||
vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([])
|
||||
vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({})
|
||||
vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined)
|
||||
|
||||
await provider.forkCurrentTask()
|
||||
|
||||
// Verify parent/root relationships are preserved
|
||||
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentTaskId: "parent-task",
|
||||
rootTaskId: "root-task",
|
||||
forkedFromTaskId: "task-789",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when no current task exists", async () => {
|
||||
vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined)
|
||||
|
||||
await expect(provider.forkCurrentTask()).rejects.toThrow("No current task to fork")
|
||||
})
|
||||
|
||||
it("should clean up partial fork on error", async () => {
|
||||
const originalHistoryItem: HistoryItem = {
|
||||
id: "task-error",
|
||||
number: 1,
|
||||
ts: 1000,
|
||||
task: "Error task",
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
totalCost: 0.05,
|
||||
}
|
||||
|
||||
vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({
|
||||
historyItem: originalHistoryItem,
|
||||
})
|
||||
vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-error" } as any)
|
||||
|
||||
// Make saveApiMessages fail
|
||||
vi.mocked(saveApiMessages).mockRejectedValue(new Error("Disk full"))
|
||||
|
||||
// Mock deleteTaskWithId for cleanup
|
||||
vi.spyOn(provider as any, "deleteTaskWithId").mockResolvedValue(undefined)
|
||||
|
||||
await expect(provider.forkCurrentTask()).rejects.toThrow("Failed to fork task: Disk full")
|
||||
|
||||
// Verify cleanup was attempted
|
||||
expect(provider.deleteTaskWithId).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should copy all task state including tokens and cost", async () => {
|
||||
const fullStateHistoryItem: HistoryItem = {
|
||||
id: "task-full",
|
||||
number: 1,
|
||||
ts: 1000,
|
||||
task: "Full state task",
|
||||
tokensIn: 5000,
|
||||
tokensOut: 3000,
|
||||
cacheWrites: 2000,
|
||||
cacheReads: 1000,
|
||||
totalCost: 0.15,
|
||||
size: 1024,
|
||||
workspace: "/test/workspace",
|
||||
mode: "architect",
|
||||
}
|
||||
|
||||
vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({
|
||||
historyItem: fullStateHistoryItem,
|
||||
})
|
||||
vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-full" } as any)
|
||||
vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([])
|
||||
vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({})
|
||||
vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined)
|
||||
|
||||
await provider.forkCurrentTask()
|
||||
|
||||
// Verify all state is preserved
|
||||
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokensIn: 5000,
|
||||
tokensOut: 3000,
|
||||
cacheWrites: 2000,
|
||||
cacheReads: 1000,
|
||||
totalCost: 0.15,
|
||||
size: 1024,
|
||||
workspace: "/test/workspace",
|
||||
mode: "architect",
|
||||
forkedFromTaskId: "task-full",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -708,6 +708,15 @@ export const webviewMessageHandler = async (
|
|||
case "deleteTaskWithId":
|
||||
provider.deleteTaskWithId(message.text!)
|
||||
break
|
||||
case "forkTask":
|
||||
try {
|
||||
await provider.forkCurrentTask()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
provider.log(`Failed to fork task: ${errorMessage}`)
|
||||
vscode.window.showErrorMessage(`Failed to fork conversation: ${errorMessage}`)
|
||||
}
|
||||
break
|
||||
case "deleteMultipleTasksWithIds": {
|
||||
const ids = message.ids
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export interface ExtensionMessage {
|
|||
| "interactionRequired"
|
||||
| "browserSessionUpdate"
|
||||
| "browserSessionNavigate"
|
||||
| "taskForked"
|
||||
text?: string
|
||||
payload?: any // Add a generic payload for now, can refine later
|
||||
// Checkpoint warning message
|
||||
|
|
@ -217,6 +218,8 @@ export interface ExtensionMessage {
|
|||
browserSessionMessages?: ClineMessage[] // For browser session panel updates
|
||||
isBrowserSessionActive?: boolean // For browser session panel updates
|
||||
stepIndex?: number // For browserSessionNavigate: the target step index to display
|
||||
taskId?: string // For taskForked: the new forked task ID
|
||||
forkedFromTaskId?: string // For taskForked: the original task ID
|
||||
}
|
||||
|
||||
export type ExtensionState = Pick<
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface WebviewMessage {
|
|||
| "showTaskWithId"
|
||||
| "deleteTaskWithId"
|
||||
| "exportTaskWithId"
|
||||
| "forkTask"
|
||||
| "importSettings"
|
||||
| "exportSettings"
|
||||
| "resetState"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
|
|||
import { DeleteTaskDialog } from "../history/DeleteTaskDialog"
|
||||
import { ShareButton } from "./ShareButton"
|
||||
import { CloudTaskButton } from "./CloudTaskButton"
|
||||
import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react"
|
||||
import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon, GitBranchIcon } from "lucide-react"
|
||||
import { LucideIconButton } from "./LucideIconButton"
|
||||
|
||||
interface TaskActionsProps {
|
||||
|
|
@ -32,6 +32,13 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => {
|
|||
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
|
||||
/>
|
||||
|
||||
<LucideIconButton
|
||||
icon={GitBranchIcon}
|
||||
title={t("chat:task.fork")}
|
||||
disabled={buttonsDisabled}
|
||||
onClick={() => vscode.postMessage({ type: "forkTask" })}
|
||||
/>
|
||||
|
||||
{item?.task && (
|
||||
<LucideIconButton
|
||||
icon={CopyIcon}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
HardDriveUpload,
|
||||
FoldVertical,
|
||||
Globe,
|
||||
GitBranchIcon,
|
||||
} from "lucide-react"
|
||||
import prettyBytes from "pretty-bytes"
|
||||
|
||||
|
|
@ -61,7 +62,8 @@ const TaskHeader = ({
|
|||
todos,
|
||||
}: TaskHeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
|
||||
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive, taskHistory } =
|
||||
useExtensionState()
|
||||
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
|
||||
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
|
||||
|
|
@ -109,6 +111,14 @@ const TaskHeader = ({
|
|||
|
||||
const showBrowserGlobe = browserSessionStartIndex !== -1 || !!isBrowserSessionActive
|
||||
|
||||
// Find parent task if this is a forked task
|
||||
const parentTask = useMemo(() => {
|
||||
if (!currentTaskItem?.forkedFromTaskId || !taskHistory) {
|
||||
return null
|
||||
}
|
||||
return taskHistory.find((item) => item.id === currentTaskItem.forkedFromTaskId)
|
||||
}, [currentTaskItem?.forkedFromTaskId, taskHistory])
|
||||
|
||||
const condenseButton = (
|
||||
<LucideIconButton
|
||||
title={t("chat:task.condenseContext")}
|
||||
|
|
@ -131,6 +141,25 @@ const TaskHeader = ({
|
|||
{t("cloud:upsell.longRunningTask")}
|
||||
</DismissibleUpsell>
|
||||
)}
|
||||
{/* Forked from indicator */}
|
||||
{parentTask && (
|
||||
<div className="mb-2 px-3 py-2 bg-vscode-input-background rounded-lg border border-vscode-sideBar-background">
|
||||
<div className="flex items-center gap-2 text-sm text-vscode-descriptionForeground">
|
||||
<GitBranchIcon className="size-3.5 shrink-0" />
|
||||
<span className="font-medium">{t("chat:task.forkedFrom")}</span>
|
||||
<StandardTooltip content={t("chat:task.openParentTask")}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
vscode.postMessage({ type: "showTaskWithId", text: parentTask.id })
|
||||
}}
|
||||
className="text-vscode-textLink hover:text-vscode-textLink/80 underline max-w-[200px] truncate">
|
||||
{parentTask.task}
|
||||
</button>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 pt-2.5 pb-2 flex flex-col gap-1.5 relative z-1 cursor-pointer",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ vi.mock("react-i18next", () => ({
|
|||
const translations: Record<string, string> = {
|
||||
"chat:task.share": "Share task",
|
||||
"chat:task.export": "Export task history",
|
||||
"chat:task.fork": "Fork conversation (create copy at this point)",
|
||||
"chat:task.delete": "Delete Task (Shift + Click to skip confirmation)",
|
||||
"chat:task.shareWithOrganization": "Share with Organization",
|
||||
"chat:task.shareWithOrganizationDescription": "Only members of your organization can access",
|
||||
|
|
@ -335,6 +336,36 @@ describe("TaskActions", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("renders fork button", () => {
|
||||
render(<TaskActions item={mockItem} buttonsDisabled={false} />)
|
||||
|
||||
const forkButton = screen.getByLabelText("Fork conversation (create copy at this point)")
|
||||
expect(forkButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("sends forkTask message when fork button is clicked", () => {
|
||||
render(<TaskActions item={mockItem} buttonsDisabled={false} />)
|
||||
|
||||
const forkButton = screen.getByLabelText("Fork conversation (create copy at this point)")
|
||||
fireEvent.click(forkButton)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "forkTask",
|
||||
})
|
||||
})
|
||||
|
||||
it("fork button respects buttonsDisabled state", () => {
|
||||
const { rerender } = render(<TaskActions item={mockItem} buttonsDisabled={false} />)
|
||||
|
||||
let forkButton = screen.getByLabelText("Fork conversation (create copy at this point)")
|
||||
expect(forkButton).not.toBeDisabled()
|
||||
|
||||
rerender(<TaskActions item={mockItem} buttonsDisabled={true} />)
|
||||
|
||||
forkButton = screen.getByLabelText("Fork conversation (create copy at this point)")
|
||||
expect(forkButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it("renders delete button when item has size", () => {
|
||||
render(<TaskActions item={mockItem} buttonsDisabled={false} />)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
"contextWindow": "Context Length",
|
||||
"closeAndStart": "Close task and start a new one",
|
||||
"export": "Export task history",
|
||||
"fork": "Fork conversation (create copy at this point)",
|
||||
"forkedFrom": "Forked from:",
|
||||
"openParentTask": "Open parent task",
|
||||
"share": "Share task",
|
||||
"delete": "Delete Task (Shift + Click to skip confirmation)",
|
||||
"shareWithOrganization": "Share with Organization",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue