Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const experimentIds = [
"preventFocusDisruption",
"imageGeneration",
"runSlashCommand",
"filesChangedOverview",
] as const

export const experimentIdsSchema = z.enum(experimentIds)
Expand All @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({
preventFocusDisruption: z.boolean().optional(),
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
filesChangedOverview: z.boolean().optional(),
})

export type Experiments = z.infer<typeof experimentsSchema>
Expand Down
21 changes: 21 additions & 0 deletions packages/types/src/file-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type FileChangeType = "create" | "delete" | "edit"

export interface FileChange {
uri: string
type: FileChangeType
// Note: Checkpoint hashes are for backend use, but can be included
fromCheckpoint: string
toCheckpoint: string
// Line count information for display
linesAdded?: number
linesRemoved?: number
}

/**
* Represents the set of file changes for the webview.
* The `files` property is an array for easy serialization.
*/
export interface FileChangeset {
baseCheckpoint: string
files: FileChange[]
}
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from "./type-fu.js"
export * from "./vscode.js"

export * from "./providers/index.js"
export * from "./file-changes.js"
6 changes: 5 additions & 1 deletion src/core/context-tracking/FileContextTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import fs from "fs/promises"
import { ContextProxy } from "../config/ContextProxy"
import type { FileMetadataEntry, RecordSource, TaskMetadata } from "./FileContextTrackerTypes"
import { ClineProvider } from "../webview/ClineProvider"
import { EventEmitter } from "events"

// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Roo, the context may become stale and need to be updated.
Expand All @@ -20,7 +21,7 @@ import { ClineProvider } from "../webview/ClineProvider"
// This class is responsible for tracking file operations.
// If the full contents of a file are passed to Roo via a tool, mention, or edit, the file is marked as active.
// If a file is modified outside of Roo, we detect and track this change to prevent stale context.
export class FileContextTracker {
export class FileContextTracker extends EventEmitter {
readonly taskId: string
private providerRef: WeakRef<ClineProvider>

Expand All @@ -31,6 +32,7 @@ export class FileContextTracker {
private checkpointPossibleFiles = new Set<string>()

constructor(provider: ClineProvider, taskId: string) {
super()
this.providerRef = new WeakRef(provider)
this.taskId = taskId
}
Expand Down Expand Up @@ -183,6 +185,8 @@ export class FileContextTracker {
newEntry.roo_edit_date = now
this.checkpointPossibleFiles.add(filePath)
this.markFileAsEditedByRoo(filePath)
// Emit event for Files Changed Overview
this.emit("roo_edited", filePath)
break

// read_tool/file_mentioned: Roo has read the file via a tool or file mention
Expand Down
28 changes: 28 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { processUserContentMentions } from "../mentions/processUserContentMentio
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
import { MessageQueueService } from "../message-queue/MessageQueueService"
import { TaskFilesChangedState } from "../../services/files-changed/TaskFilesChangedState"

import { AutoApprovalHandler } from "./AutoApprovalHandler"

Expand Down Expand Up @@ -277,6 +278,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
public readonly messageQueueService: MessageQueueService
private messageQueueStateChangedHandler: (() => void) | undefined

// Files Changed Overview state
private filesChangedState?: TaskFilesChangedState

// Streaming
isWaitingForFirstChunk = false
isStreaming = false
Expand Down Expand Up @@ -1598,6 +1602,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error("Error disposing file context tracker:", error)
}

try {
this.disposeFilesChangedState()
} catch (error) {
console.error("Error disposing Files Changed state:", error)
}

try {
// If we're not streaming then `abortStream` won't be called.
if (this.isStreaming && this.diffViewProvider.isEditing) {
Expand Down Expand Up @@ -2924,4 +2934,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error(`[Task] Queue processing error:`, e)
}
}

// Files Changed Overview helpers

public ensureFilesChangedState(): TaskFilesChangedState {
if (!this.filesChangedState) {
this.filesChangedState = new TaskFilesChangedState()
}
return this.filesChangedState
}

public getFilesChangedState(): TaskFilesChangedState | undefined {
return this.filesChangedState
}

public disposeFilesChangedState(): void {
this.filesChangedState?.dispose()
this.filesChangedState = undefined
}
}
80 changes: 72 additions & 8 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage"
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { experimentDefault } from "../../shared/experiments"
import { experimentDefault, EXPERIMENT_IDS } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
Expand Down Expand Up @@ -93,6 +93,7 @@ import type { ClineMessage } from "@roo-code/types"
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { FilesChangedMessageHandler } from "../../services/files-changed/FilesChangedMessageHandler"

/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
Expand Down Expand Up @@ -136,6 +137,9 @@ export class ClineProvider
private taskCreationCallback: (task: Task) => void
private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
private currentWorkspacePath: string | undefined
private lastCheckpointByTaskId: Map<string, string> = new Map()
// Files Changed handler
private filesChangedHandler: FilesChangedMessageHandler

private recentTasksCache?: string[]
private pendingOperations: Map<string, PendingEditOperation> = new Map()
Expand Down Expand Up @@ -177,6 +181,9 @@ export class ClineProvider
await this.postStateToWebview()
})

// Initialize Files Changed handler
this.filesChangedHandler = new FilesChangedMessageHandler(this)

// Initialize MCP Hub through the singleton manager
McpServerManager.getInstance(this.context, this)
.then((hub) => {
Expand Down Expand Up @@ -481,12 +488,16 @@ export class ClineProvider
// This is used when a subtask is finished and the parent task needs to be
// resumed.
async finishSubTask(lastMessage: string) {
// Remove the last cline instance from the stack (this is the finished
// subtask).
const childTask = this.getCurrentTask()
const parentFromStack = this.clineStack.length > 1 ? this.clineStack[this.clineStack.length - 2] : undefined
await this.filesChangedHandler.handleChildTaskCompletion(childTask, parentFromStack)

const previousTask = this.getCurrentTask()
await this.removeClineFromStack()
// Resume the last cline instance in the stack (if it exists - this is
// the 'parent' calling task).
await this.getCurrentTask()?.completeSubtask(lastMessage)
const parentTask = this.getCurrentTask()

await parentTask?.completeSubtask(lastMessage)
await this.filesChangedHandler.applyExperimentsToTask(parentTask)
}
// Pending Edit Operations Management

Expand Down Expand Up @@ -588,6 +599,7 @@ export class ClineProvider
}

this.clearWebviewResources()
this.filesChangedHandler?.dispose(this.getCurrentTask())

// Clean up cloud service event listener
if (CloudService.hasInstance()) {
Expand Down Expand Up @@ -845,6 +857,8 @@ export class ClineProvider
}

public async createTaskWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) {
// Capture current task before removal for potential FCO state transfer
const previousTask = this.getCurrentTask()
await this.removeClineFromStack()

// If the history item has a saved mode, restore it and its associated API configuration.
Expand Down Expand Up @@ -919,10 +933,17 @@ export class ClineProvider

await this.addClineToStack(task)

if (previousTask && previousTask.taskId === task.taskId) {
this.filesChangedHandler.transferStateBetweenTasks(previousTask, task)
}

this.log(
`[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
)

// Initialize Files Changed state for this task if setting is enabled
await this.filesChangedHandler.applyExperimentsToTask(task)

// Check if there's a pending edit after checkpoint restoration
const operationId = `task-${task.taskId}`
const pendingEdit = this.getPendingEditOperation(operationId)
Expand Down Expand Up @@ -1149,8 +1170,14 @@ export class ClineProvider
* @param webview A reference to the extension webview
*/
private setWebviewMessageListener(webview: vscode.Webview) {
const onReceiveMessage = async (message: WebviewMessage) =>
webviewMessageHandler(this, message, this.marketplaceManager)
const onReceiveMessage = async (message: WebviewMessage) => {
// Route Files Changed Overview messages first
if (this.filesChangedHandler.shouldHandleMessage(message)) {
await this.filesChangedHandler.handleMessage(message)
return
}
await webviewMessageHandler(this, message, this.marketplaceManager)
}

const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage)
this.webviewDisposables.push(messageDisposable)
Expand Down Expand Up @@ -2211,6 +2238,23 @@ export class ClineProvider
return this.contextProxy.getValue(key)
}

// FilesChanged Message Handler access
public getFilesChangedHandler(): FilesChangedMessageHandler {
return this.filesChangedHandler
}

// Track last checkpoint per task for delta-based FilesChanged updates
public setLastCheckpointForTask(taskId: string, commitHash: string) {
this.lastCheckpointByTaskId.set(taskId, commitHash)
}

/**
* Check if a message should be handled by Files Changed service
*/
public getLastCheckpointForTask(taskId: string): string | undefined {
return this.lastCheckpointByTaskId.get(taskId)
}

public async setValue<K extends keyof RooCodeSettings>(key: K, value: RooCodeSettings[K]) {
await this.contextProxy.setValue(key, value)
}
Expand Down Expand Up @@ -2543,6 +2587,9 @@ export class ClineProvider
`[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
)

// Initialize Files Changed state for this task if setting is enabled
await this.filesChangedHandler.applyExperimentsToTask(task)

return task
}

Expand All @@ -2567,6 +2614,9 @@ export class ClineProvider
// Capture the current instance to detect if rehydrate already occurred elsewhere
const originalInstanceId = task.instanceId

// Capture FCO state before task disposal (task.abortTask() will dispose it)
const fcoState = task.getFilesChangedState()

// Begin abort (non-blocking)
task.abortTask()

Expand Down Expand Up @@ -2611,6 +2661,20 @@ export class ClineProvider

// Clears task again, so we need to abortTask manually above.
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })

// Restore FCO state to the new task if we captured it
if (fcoState) {
const newTask = this.getCurrentTask()
if (newTask && newTask.taskId === task.taskId) {
const newTaskState = newTask.ensureFilesChangedState()
newTaskState.cloneFrom(fcoState)
// Ensure the restored task is not waiting (prevents clearFilesChangedDisplay)
newTaskState.setWaiting(false)
console.log(`[cancelTask] restored FCO state to recreated task ${newTask.taskId}.${newTask.instanceId}`)
// Re-trigger FCO display since applyExperimentsToTask may have cleared it
await this.filesChangedHandler.applyExperimentsToTask(newTask)
}
}
}

// Clear the current task without treating it as a subtask.
Expand Down
5 changes: 5 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ vi.mock("vscode", () => ({
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({
Expand Down Expand Up @@ -188,6 +189,10 @@ vi.mock("../../../api", () => ({
buildApiHandler: vi.fn(),
}))

vi.mock("../../checkpoints", () => ({
getCheckpointService: vi.fn(async () => ({})),
}))

vi.mock("../../prompts/system", () => ({
SYSTEM_PROMPT: vi.fn().mockImplementation(async () => "mocked system prompt"),
codeMode: "code",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ vi.mock("vscode", () => ({
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({
Expand Down Expand Up @@ -148,6 +149,10 @@ vi.mock("../../prompts/system", () => ({
codeMode: "code",
}))

vi.mock("../../checkpoints", () => ({
getCheckpointService: vi.fn(async () => ({})),
}))

vi.mock("../../../api/providers/fetchers/modelCache", () => ({
getModels: vi.fn().mockResolvedValue({}),
flushModels: vi.fn(),
Expand Down
17 changes: 16 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
checkoutRestorePayloadSchema,
} from "../../shared/WebviewMessage"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { experimentDefault, EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
Expand Down Expand Up @@ -1942,6 +1942,21 @@ export const webviewMessageHandler = async (

await updateGlobalState("experiments", updatedExperiments)

// Simple delegation to FilesChanged handler for universal baseline management
try {
const currentTask = provider.getCurrentTask()
if (currentTask?.taskId) {
await provider
.getFilesChangedHandler()
.handleExperimentToggle(
experiments.isEnabled(updatedExperiments, EXPERIMENT_IDS.FILES_CHANGED_OVERVIEW),
currentTask,
)
}
} catch (error) {
provider.log(`FilesChanged: Error handling experiment toggle: ${error}`)
}

await provider.postStateToWebview()
break
}
Expand Down
Loading
Loading