diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a4ef802efb..c9ba68717f 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -337,6 +337,9 @@ export type ExtensionState = Pick< openAiCodexIsAuthenticated?: boolean debug?: boolean + /** Background tasks status for the UI panel */ + backgroundTasks?: BackgroundTaskStatusInfo[] + /** * Monotonically increasing sequence number for clineMessages state pushes. * When present, the frontend should only apply clineMessages from a state push @@ -346,6 +349,21 @@ export type ExtensionState = Pick< clineMessagesSeq?: number } +/** + * Status of a background task as exposed to the webview UI. + */ +export interface BackgroundTaskStatusInfo { + taskId: string + parentTaskId: string + status: "running" | "completed" | "cancelled" | "timed_out" | "error" + startedAt: number + completedAt?: number + /** Short summary of the result (from attempt_completion) */ + resultSummary?: string + /** The mode slug the background task was running in */ + mode?: string +} + export interface Command { name: string source: "global" | "project" | "built-in" @@ -514,6 +532,8 @@ export interface WebviewMessage { | "createWorktreeInclude" | "checkoutBranch" | "browseForWorktreePath" + // Background task messages + | "cancelBackgroundTask" // Skills messages | "requestSkills" | "createSkill" diff --git a/src/core/task/BackgroundTaskRunner.ts b/src/core/task/BackgroundTaskRunner.ts index 9f383d17c5..e7ed7127c2 100644 --- a/src/core/task/BackgroundTaskRunner.ts +++ b/src/core/task/BackgroundTaskRunner.ts @@ -10,6 +10,8 @@ * This is Phase 4 of the parallel execution roadmap: Background Read-Only Concurrency. */ +import { BackgroundTaskStatusInfo } from "@roo-code/types" + import { Task, TaskOptions } from "./Task" /** Read-only tools that background tasks are allowed to use. */ @@ -46,11 +48,27 @@ export interface BackgroundTaskRunnerCallbacks { onTaskError?: (taskId: string, parentTaskId: string, error: Error) => void } +/** Maximum number of recently completed tasks to keep for UI display. */ +const MAX_COMPLETED_TASKS = 10 + +export interface CompletedBackgroundTaskInfo { + taskId: string + parentTaskId: string + status: "completed" | "cancelled" | "timed_out" | "error" + startedAt: number + completedAt: number + resultSummary?: string + mode?: string +} + export class BackgroundTaskRunner { private backgroundTasks: Map = new Map() + private completedTasks: CompletedBackgroundTaskInfo[] = [] private maxConcurrentTasks: number private taskTimeoutMs: number private callbacks: BackgroundTaskRunnerCallbacks + /** Called whenever the set of active/completed tasks changes, so the UI can be refreshed. */ + public onStateChanged?: () => void constructor( maxConcurrentTasks: number = DEFAULT_MAX_BACKGROUND_TASKS, @@ -108,12 +126,14 @@ export class BackgroundTaskRunner { `[BackgroundTaskRunner] Registered background task ${task.taskId} ` + `(parent: ${parentTaskId}, active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, ) + + this.notifyStateChanged() } /** * Called when a background task completes. Cleans up tracking state. */ - onTaskCompleted(taskId: string): BackgroundTaskInfo | undefined { + onTaskCompleted(taskId: string, resultSummary?: string): BackgroundTaskInfo | undefined { const info = this.backgroundTasks.get(taskId) if (!info) { @@ -123,11 +143,22 @@ export class BackgroundTaskRunner { clearTimeout(info.timeoutHandle) this.backgroundTasks.delete(taskId) + this.addCompletedTask({ + taskId, + parentTaskId: info.parentTaskId, + status: "completed", + startedAt: info.startedAt, + completedAt: Date.now(), + resultSummary, + }) + console.log( `[BackgroundTaskRunner] Background task ${taskId} completed ` + `(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, ) + this.notifyStateChanged() + return info } @@ -174,9 +205,12 @@ export class BackgroundTaskRunner { clearTimeout(info.timeoutHandle) + let status: CompletedBackgroundTaskInfo["status"] = "cancelled" + try { await info.task.abortTask(true) } catch (error) { + status = "error" const err = error instanceof Error ? error : new Error(String(error)) console.error(`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${err.message}`) try { @@ -188,10 +222,20 @@ export class BackgroundTaskRunner { this.backgroundTasks.delete(taskId) + this.addCompletedTask({ + taskId, + parentTaskId: info.parentTaskId, + status, + startedAt: info.startedAt, + completedAt: Date.now(), + }) + console.log( `[BackgroundTaskRunner] Cancelled background task ${taskId} ` + `(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, ) + + this.notifyStateChanged() } /** @@ -205,12 +249,79 @@ export class BackgroundTaskRunner { } } + /** + * Returns the combined status of all active and recently completed background tasks + * for display in the webview UI. + */ + getTasksStatus(): BackgroundTaskStatusInfo[] { + const activeTasks: BackgroundTaskStatusInfo[] = [] + + for (const [taskId, info] of this.backgroundTasks) { + activeTasks.push({ + taskId, + parentTaskId: info.parentTaskId, + status: "running", + startedAt: info.startedAt, + }) + } + + const completedStatuses: BackgroundTaskStatusInfo[] = this.completedTasks.map((ct) => ({ + taskId: ct.taskId, + parentTaskId: ct.parentTaskId, + status: ct.status, + startedAt: ct.startedAt, + completedAt: ct.completedAt, + resultSummary: ct.resultSummary, + mode: ct.mode, + })) + + return [...activeTasks, ...completedStatuses] + } + + /** + * Returns the list of recently completed tasks (for testing and direct access). + */ + getCompletedTasks(): readonly CompletedBackgroundTaskInfo[] { + return this.completedTasks + } + + /** + * Clears completed tasks from the buffer. + */ + clearCompletedTasks(): void { + this.completedTasks = [] + this.notifyStateChanged() + } + + /** + * Add a completed task to the buffer, evicting the oldest if at capacity. + */ + private addCompletedTask(info: CompletedBackgroundTaskInfo): void { + this.completedTasks.push(info) + + if (this.completedTasks.length > MAX_COMPLETED_TASKS) { + this.completedTasks = this.completedTasks.slice(-MAX_COMPLETED_TASKS) + } + } + + /** + * Notify the owner that background task state has changed. + */ + private notifyStateChanged(): void { + try { + this.onStateChanged?.() + } catch { + // Callback errors must not break internal logic. + } + } + /** * Handle timeout of a background task. */ private async timeoutTask(taskId: string): Promise { const info = this.backgroundTasks.get(taskId) const parentTaskId = info?.parentTaskId ?? "unknown" + const startedAt = info?.startedAt ?? Date.now() console.warn(`[BackgroundTaskRunner] Background task ${taskId} timed out after ${this.taskTimeoutMs}ms`) @@ -220,6 +331,28 @@ export class BackgroundTaskRunner { // Callback errors must not break cleanup. } - await this.cancelTask(taskId) + // Record as timed_out before cancelling (cancelTask will record as cancelled otherwise) + clearTimeout(info?.timeoutHandle) + if (info) { + try { + await info.task.abortTask(true) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + console.error(`[BackgroundTaskRunner] Error aborting timed-out task ${taskId}: ${err.message}`) + } + this.backgroundTasks.delete(taskId) + + this.addCompletedTask({ + taskId, + parentTaskId, + status: "timed_out", + startedAt, + completedAt: Date.now(), + }) + + this.notifyStateChanged() + } else { + await this.cancelTask(taskId) + } } } diff --git a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts index 29d9e28cd5..184a1b9b3f 100644 --- a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts +++ b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts @@ -223,4 +223,147 @@ describe("BackgroundTaskRunner", () => { expect(runner.getTaskInfo("unknown")).toBeUndefined() }) }) + + describe("getTasksStatus", () => { + it("should return empty array when no tasks", () => { + expect(runner.getTasksStatus()).toEqual([]) + }) + + it("should return running tasks with correct status", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(1) + expect(statuses[0].taskId).toBe("task-1") + expect(statuses[0].parentTaskId).toBe("parent-1") + expect(statuses[0].status).toBe("running") + expect(statuses[0].startedAt).toBeGreaterThan(0) + expect(statuses[0].completedAt).toBeUndefined() + }) + + it("should include completed tasks after onTaskCompleted", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + runner.onTaskCompleted("task-1", "Done!") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(1) + expect(statuses[0].taskId).toBe("task-1") + expect(statuses[0].status).toBe("completed") + expect(statuses[0].resultSummary).toBe("Done!") + expect(statuses[0].completedAt).toBeGreaterThan(0) + }) + + it("should include cancelled tasks after cancelTask", async () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + await runner.cancelTask("task-1") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(1) + expect(statuses[0].status).toBe("cancelled") + }) + + it("should show both active and completed tasks", () => { + const task1 = createMockTask("task-1") + const task2 = createMockTask("task-2") + runner.registerTask(task1, "parent-1") + runner.registerTask(task2, "parent-1") + runner.onTaskCompleted("task-1", "Result 1") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(2) + // Active task + const active = statuses.find((s) => s.taskId === "task-2") + expect(active?.status).toBe("running") + // Completed task + const completed = statuses.find((s) => s.taskId === "task-1") + expect(completed?.status).toBe("completed") + }) + }) + + describe("completed tasks buffer", () => { + it("should limit completed tasks to MAX_COMPLETED_TASKS (10)", () => { + // Register and complete 12 tasks + for (let i = 0; i < 12; i++) { + const task = createMockTask(`task-${i}`) + runner.registerTask(task, "parent-1") + runner.onTaskCompleted(`task-${i}`, `Result ${i}`) + } + + const completed = runner.getCompletedTasks() + expect(completed).toHaveLength(10) + // Should keep the most recent 10 + expect(completed[0].taskId).toBe("task-2") + expect(completed[9].taskId).toBe("task-11") + }) + + it("should clear completed tasks", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + runner.onTaskCompleted("task-1", "Done") + + expect(runner.getCompletedTasks()).toHaveLength(1) + runner.clearCompletedTasks() + expect(runner.getCompletedTasks()).toHaveLength(0) + }) + }) + + describe("onStateChanged callback", () => { + it("should be called when a task is registered", () => { + const callback = vi.fn() + runner.onStateChanged = callback + + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + expect(callback).toHaveBeenCalledTimes(1) + }) + + it("should be called when a task is completed", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const callback = vi.fn() + runner.onStateChanged = callback + runner.onTaskCompleted("task-1", "Done") + + expect(callback).toHaveBeenCalledTimes(1) + }) + + it("should be called when a task is cancelled", async () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const callback = vi.fn() + runner.onStateChanged = callback + await runner.cancelTask("task-1") + + expect(callback).toHaveBeenCalledTimes(1) + }) + + it("should not throw if onStateChanged is not set", () => { + const task = createMockTask("task-1") + runner.onStateChanged = undefined + expect(() => runner.registerTask(task, "parent-1")).not.toThrow() + }) + }) + + describe("timeout tracking", () => { + it("should record timed_out status when task times out", async () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + // Advance past timeout + vi.advanceTimersByTime(DEFAULT_BACKGROUND_TASK_TIMEOUT_MS + 1000) + + // Wait for async timeoutTask + await vi.runAllTimersAsync() + + const statuses = runner.getTasksStatus() + const timedOut = statuses.find((s) => s.taskId === "task-1") + expect(timedOut?.status).toBe("timed_out") + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b0f5f58ef0..f02bc7313e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -141,14 +141,21 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false - public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner(undefined, undefined, { - onTaskTimeout: (taskId, _parentTaskId) => { - vscode.window.showWarningMessage(`Background task ${taskId} timed out and was cancelled.`) - }, - onTaskError: (taskId, _parentTaskId, error) => { - vscode.window.showWarningMessage(`Background task ${taskId} encountered an error: ${error.message}`) - }, - }) + public readonly backgroundTaskRunner: BackgroundTaskRunner = (() => { + const runner = new BackgroundTaskRunner(undefined, undefined, { + onTaskTimeout: (taskId: string, _parentTaskId: string) => { + vscode.window.showWarningMessage(`Background task ${taskId} timed out and was cancelled.`) + }, + onTaskError: (taskId, _parentTaskId, error) => { + vscode.window.showWarningMessage(`Background task ${taskId} encountered an error: ${error.message}`) + }, + }) + runner.onStateChanged = () => { + // Push updated background task status to the webview whenever tasks change + this.postBackgroundTasksToWebview() + } + return runner + })() private globalStateWriteThroughTimer: ReturnType | null = null private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private pendingOperations: Map = new Map() @@ -1849,6 +1856,19 @@ export class ClineProvider this.postMessageToWebview({ type: "state", state }) } + /** + * Push only the background tasks status to the webview. + * This is a lightweight update triggered by BackgroundTaskRunner.onStateChanged + * so the UI can refresh the panel without a full state push. + */ + postBackgroundTasksToWebview(): void { + const backgroundTasks = this.backgroundTaskRunner.getTasksStatus() + this.postMessageToWebview({ + type: "state", + state: { backgroundTasks } as any, + }) + } + /** * Like postStateToWebview but intentionally omits taskHistory. * @@ -2131,6 +2151,7 @@ export class ClineProvider } })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), + backgroundTasks: this.backgroundTaskRunner.getTasksStatus(), } } @@ -3017,7 +3038,7 @@ export class ClineProvider * task's API conversation as a system message. */ private async handleBackgroundTaskComplete(taskId: string, result: string): Promise { - const info = this.backgroundTaskRunner.onTaskCompleted(taskId) + const info = this.backgroundTaskRunner.onTaskCompleted(taskId, result) if (!info) { this.log(`[handleBackgroundTaskComplete] Task ${taskId} not found in background runner`) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fac7ed10d5..419372db06 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1189,6 +1189,11 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We case "cancelTask": await provider.cancelTask() break + case "cancelBackgroundTask": + if (message.taskId) { + await provider.backgroundTaskRunner.cancelTask(message.taskId) + } + break case "cancelAutoApproval": // Cancel any pending auto-approval timeout for the current task provider.getCurrentTask()?.cancelAutoApprovalTimeout() diff --git a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx new file mode 100644 index 0000000000..d431415165 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx @@ -0,0 +1,168 @@ +import React, { useState, useMemo } from "react" + +import type { BackgroundTaskStatusInfo } from "@roo-code/types" + +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +/** + * Format elapsed time in a human-readable way. + */ +function formatElapsed(startedAt: number, completedAt?: number): string { + const end = completedAt ?? Date.now() + const ms = end - startedAt + + if (ms < 1000) { + return "<1s" + } + + const seconds = Math.floor(ms / 1000) + + if (seconds < 60) { + return `${seconds}s` + } + + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}m ${remainingSeconds}s` +} + +/** + * Get a status icon codicon class based on task status. + */ +function getStatusIcon(status: BackgroundTaskStatusInfo["status"]): string { + switch (status) { + case "running": + return "codicon-loading codicon-modifier-spin" + case "completed": + return "codicon-check" + case "cancelled": + return "codicon-circle-slash" + case "timed_out": + return "codicon-clock" + case "error": + return "codicon-error" + default: + return "codicon-question" + } +} + +/** + * Get a color class for the status indicator. + */ +function getStatusColor(status: BackgroundTaskStatusInfo["status"]): string { + switch (status) { + case "running": + return "text-vscode-charts-blue" + case "completed": + return "text-vscode-charts-green" + case "cancelled": + return "text-vscode-charts-yellow" + case "timed_out": + return "text-vscode-charts-orange" + case "error": + return "text-vscode-errorForeground" + default: + return "text-vscode-descriptionForeground" + } +} + +function BackgroundTaskItem({ task }: { task: BackgroundTaskStatusInfo }) { + const [showResult, setShowResult] = useState(false) + const isRunning = task.status === "running" + + const handleCancel = () => { + vscode.postMessage({ type: "cancelBackgroundTask", taskId: task.taskId }) + } + + const shortId = task.taskId.slice(0, 8) + + return ( +
+
+
+ + + {shortId} + + + {formatElapsed(task.startedAt, task.completedAt)} + +
+
+ {task.resultSummary && !isRunning && ( + + )} + {isRunning && ( + + )} +
+
+ {showResult && task.resultSummary && ( +
+ {task.resultSummary.length > 500 ? task.resultSummary.slice(0, 500) + "..." : task.resultSummary} +
+ )} +
+ ) +} + +/** + * BackgroundTasksPanel shows active and recently completed background tasks + * as a collapsible section in the chat sidebar. Only renders when there are + * background tasks to display. + */ +const BackgroundTasksPanel: React.FC = () => { + const { backgroundTasks } = useExtensionState() + const [isCollapsed, setIsCollapsed] = useState(false) + + const tasks = useMemo(() => backgroundTasks ?? [], [backgroundTasks]) + + const activeCount = useMemo(() => tasks.filter((t) => t.status === "running").length, [tasks]) + + if (tasks.length === 0) { + return null + } + + return ( +
+ + {!isCollapsed && ( +
+ {tasks.map((task) => ( + + ))} +
+ )} +
+ ) +} + +export default BackgroundTasksPanel diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 04f8e5b6b6..d2ffe759d7 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -41,6 +41,7 @@ import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" import FileChangesPanel from "./FileChangesPanel" +import BackgroundTasksPanel from "./BackgroundTasksPanel" import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" export interface ChatViewProps { @@ -1641,6 +1642,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction + {areButtonsVisible && (
({ + vscode: { postMessage: vi.fn() }, +})) + +// Mock useExtensionState +const mockBackgroundTasks: BackgroundTaskStatusInfo[] = [] +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + backgroundTasks: mockBackgroundTasks, + }), +})) + +import BackgroundTasksPanel from "../BackgroundTasksPanel" + +describe("BackgroundTasksPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + mockBackgroundTasks.length = 0 + }) + + it("should not render when there are no background tasks", () => { + const { container } = render() + expect(container.innerHTML).toBe("") + }) + + it("should render when there are background tasks", () => { + mockBackgroundTasks.push({ + taskId: "task-abc12345", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now() - 30000, + }) + + render() + expect(screen.getByText("Background Tasks")).toBeDefined() + expect(screen.getByText("task-abc")).toBeDefined() // short ID + }) + + it("should show active count badge", () => { + mockBackgroundTasks.push( + { + taskId: "task-1111", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }, + { + taskId: "task-2222", + parentTaskId: "parent-1", + status: "completed", + startedAt: Date.now() - 60000, + completedAt: Date.now(), + resultSummary: "Done", + }, + ) + + render() + // Badge should show "1" for 1 running task + expect(screen.getByText("1")).toBeDefined() + expect(screen.getByText("2 total")).toBeDefined() + }) + + it("should show cancel button for running tasks", () => { + mockBackgroundTasks.push({ + taskId: "task-run1", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }) + + render() + const cancelButton = screen.getByTitle("Cancel background task") + expect(cancelButton).toBeDefined() + }) + + it("should send cancelBackgroundTask message when cancel is clicked", () => { + mockBackgroundTasks.push({ + taskId: "task-cancel-me", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }) + + render() + const cancelButton = screen.getByTitle("Cancel background task") + fireEvent.click(cancelButton) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "cancelBackgroundTask", + taskId: "task-cancel-me", + }) + }) + + it("should show Result button for completed tasks with result summary", () => { + mockBackgroundTasks.push({ + taskId: "task-done1", + parentTaskId: "parent-1", + status: "completed", + startedAt: Date.now() - 60000, + completedAt: Date.now(), + resultSummary: "Analysis complete: found 3 issues.", + }) + + render() + const resultButton = screen.getByText("Result") + expect(resultButton).toBeDefined() + + // Click to expand + fireEvent.click(resultButton) + expect(screen.getByText("Analysis complete: found 3 issues.")).toBeDefined() + + // Click to collapse + fireEvent.click(screen.getByText("Hide")) + expect(screen.queryByText("Analysis complete: found 3 issues.")).toBeNull() + }) + + it("should collapse and expand the panel", () => { + mockBackgroundTasks.push({ + taskId: "task-1234", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }) + + render() + const header = screen.getByText("Background Tasks") + + // Click to collapse + fireEvent.click(header) + expect(screen.queryByText("task-1234".slice(0, 8))).toBeNull() + + // Click to expand + fireEvent.click(header) + expect(screen.getByText("task-1234".slice(0, 8))).toBeDefined() + }) +})