diff --git a/docs/architecture/phase-6-background-task-visibility.md b/docs/architecture/phase-6-background-task-visibility.md new file mode 100644 index 0000000000..7f9ecb420b --- /dev/null +++ b/docs/architecture/phase-6-background-task-visibility.md @@ -0,0 +1,327 @@ +# Phase 6: Background Task Visibility and Interaction + +> Architectural design document for Issue #12330 +> Phase 6 of "Support parallel execution of specialized agents and improve context handoff between modes" + +## 1. Context + +Phase 5 (Background Tasks Panel UI) is complete. This document proposes the scope, priority, and architecture for Phase 6, which focuses on enabling better visibility and interaction with background tasks. + +## 2. Current Architecture + +### Task Lifecycle + +`ClineProvider` maintains a `clineStack: Task[]` (LIFO). Only the top-of-stack task is "current" -- all state updates, webview messages, and user interactions route through `getCurrentTask()`. + +``` +ClineProvider +├── clineStack: Task[] # LIFO stack, sequential execution +├── taskHistoryStore # Per-task file persistence +├── getCurrentTask() # Returns top of stack +├── addClineToStack(task) # Push new task +└── removeClineFromStack() # Pop completed task +``` + +### Task Persistence + +| Layer | File | Purpose | +|-------|------|---------| +| Messages | `taskMessages.ts` | Save/load `ClineMessage[]` per task | +| API History | `apiMessages.ts` | Save/load API conversation history | +| History Items | `TaskHistoryStore.ts` | Per-task metadata files with in-memory cache | +| Metadata | `taskMetadata.ts` | Task metadata helpers | + +### Webview Communication + +The extension sends typed `ExtensionMessage` objects to the webview. Key message types: + +- `state` -- Full state snapshot (includes `clineMessages`, `currentTaskId`) +- `taskHistoryUpdated` -- Full history list refresh +- `taskHistoryItemUpdated` -- Single history item update + +Currently, `postStateToWebviewWithoutTaskHistory()` sends state for only the current task. There is no mechanism to send updates for background tasks. + +### Subtask Support + +Parent-child relationships exist via `parentTaskId` and `childIds` on `HistoryItem`. The `new_task` tool creates subtasks that push onto the stack. When a subtask completes, it pops and returns control to the parent. + +## 3. Agreed Scope for Phase 6 + +**In scope (Items 1-3):** +1. Full conversation replay for completed background tasks +2. Tab switching / multi-task view +3. Real-time progress streaming for active background tasks + +**Deferred to Phase 7 (Items 4-5):** +4. Write-capable background tasks + basic file locking +5. Persistent background task history across sessions + +## 4. Feasibility Analysis + +### Item 1: Full Conversation Replay + +**Complexity: Medium | Risk: Low** + +`readTaskMessages(taskId, globalStoragePath)` already loads the full `ClineMessage[]` array from disk for any task. The existing `ChatView` component renders these messages. The main work is creating a read-only wrapper that: + +- Accepts a `taskId` prop instead of reading from global state +- Loads messages on mount via a new webview message +- Hides input controls (chat box, approval buttons) +- Renders tool calls, outputs, and assistant responses in the same format + +**Why it's low risk:** No changes to task execution, persistence, or the foreground task flow. Purely additive UI + a new message handler. + +### Item 2: Tab Switching / Multi-task View + +**Complexity: Medium-High | Risk: Medium** + +The webview already has a tab system in `App.tsx` (`tab === "history"`, `tab === "settings"`, `tab === "chat"`). Adding a background tasks view requires: + +- A new tab or panel within the chat view +- A list of active/completed background tasks with status indicators +- Navigation to open a task's replay view or live view +- State management to track which background task is currently being viewed + +**Key challenge:** The webview currently receives state for only one task. Viewing a background task must not disrupt the foreground task's state. This requires either: + - (a) A separate message channel for background task data, or + - (b) A secondary state context in the webview that can hold background task data alongside the primary task state + +Option (a) is cleaner and avoids polluting the existing state management. + +### Item 3: Real-time Progress Streaming + +**Complexity: High | Risk: Medium-High** + +Currently, `Task.ts` calls `provider.postStateToWebviewWithoutTaskHistory()` to update the UI. This method sends the full state for the current task only. For background tasks to stream progress: + +1. `Task.ts` must emit incremental updates even when it is not the "current" task +2. A new message type (`backgroundTaskProgress`) must carry task-scoped updates +3. The webview must handle concurrent update streams without degrading performance +4. Throttling/batching is needed to prevent excessive re-renders + +**Why it's harder:** Requires changes to the core task execution loop (`Task.ts`), not just additive UI. The task currently assumes it IS the visible task when posting updates. + +## 5. Recommended Priority Order + +``` +Phase 6a: Conversation Replay (Foundation -- standalone value) + │ + ▼ +Phase 6b: Tab/Panel Switching (Navigation framework, depends on 6a) + │ + ▼ +Phase 6c: Real-time Progress Streaming (Highest complexity, builds on 6b) +``` + +Each sub-phase is independently shippable and testable. + +## 6. Detailed Design + +### 6a. Conversation Replay + +#### New Message Types + +```typescript +// Webview → Extension +interface RequestBackgroundTaskMessages { + type: "requestBackgroundTaskMessages" + taskId: string +} + +// Extension → Webview +interface BackgroundTaskMessages { + type: "backgroundTaskMessages" + taskId: string + messages: ClineMessage[] +} +``` + +#### Extension Handler (webviewMessageHandler.ts) + +```typescript +case "requestBackgroundTaskMessages": { + const taskId = message.taskId + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages(taskId, globalStoragePath) + provider.postMessageToWebview({ + type: "backgroundTaskMessages", + taskId, + messages: messages ?? [], + }) + break +} +``` + +#### Webview Component + +``` +BackgroundTaskReplayView +├── Props: { taskId: string, onClose: () => void } +├── State: messages (ClineMessage[]), loading (boolean) +├── On mount: sends requestBackgroundTaskMessages +├── On message: receives backgroundTaskMessages, filters by taskId +├── Renders: read-only message list (reuses ChatRow components) +└── No input controls, no approval buttons +``` + +### 6b. Tab/Panel Switching + +#### UI Structure + +A new icon is added to the existing tab bar (alongside chat, history, settings) as the entry point. The background task view occupies the full tab area. + +``` +App.tsx +├── tab === "chat" → ChatView (foreground task) +├── tab === "history" → HistoryView +├── tab === "settings" → SettingsView +└── tab === "bgTask" → BackgroundTaskView + ├── BackgroundTasksList (task list with status badges + error badge on tab icon) + │ ├── Active tasks + │ └── Completed tasks + └── BackgroundTaskReplayView (from 6a) OR BackgroundTaskLiveView (from 6c) +``` + +#### State Management + +```typescript +// New webview state (in App.tsx or dedicated context) +interface BackgroundTaskViewState { + selectedTaskId: string | null + viewMode: "replay" | "live" +} +``` + +#### Navigation Flow + +1. User clicks background tasks icon in the tab bar +2. App switches to `tab === "bgTask"` +3. BackgroundTasksList shows available tasks +4. User clicks a task → sets `selectedTaskId` +5. If task is completed → opens BackgroundTaskReplayView +6. If task is active → opens BackgroundTaskLiveView (Phase 6c) + +### 6c. Real-time Progress Streaming (Minimal Viable Version) + +> **Design principle:** Keep Phase 6c tightly scoped to avoid expanding the phase. Ship the simplest useful version first; richer detail can be added incrementally in later phases. + +#### MVP Scope + +The minimal viable version streams only: +- **Tool name + status** (started / completed / errored) -- not full parameters or output +- **Last N updates** (rolling window of ~20 items) -- older entries are discarded client-side +- **Status changes** (running, paused, completed, errored) + +What is explicitly **out of scope** for the MVP: +- Full tool call parameters or output payloads +- Assistant text streaming +- Persistent storage of streamed updates (replay from disk covers completed tasks) + +#### New Message Types + +```typescript +// Extension → Webview (incremental updates) +interface BackgroundTaskProgress { + type: "backgroundTaskProgress" + taskId: string + update: BackgroundTaskUpdate +} + +interface BackgroundTaskUpdate { + kind: "tool_call" | "tool_result" | "status_change" | "error" + timestamp: number + toolName?: string // e.g. "read_file", "execute_command" + status?: string // e.g. "started", "completed", "errored" + errorMessage?: string // Only for kind === "error" +} +``` + +Note: `assistant_text` is excluded from the MVP. The update interface uses typed optional fields instead of `data: any` to keep the contract narrow and safe. + +#### Task.ts Changes + +Add a method that emits progress regardless of whether the task is "current": + +```typescript +// In Task.ts +private emitBackgroundProgress(update: BackgroundTaskUpdate) { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit background updates when this task is NOT the current task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + taskId: this.taskId, + update, + }) +} +``` + +The hook points in Task.ts should be minimal -- emit at tool call start and tool call end only. Avoid adding hooks inside the LLM streaming loop for the MVP. + +#### Throttling Strategy + +- Batch updates in 500ms windows (conservative default; can be tuned down later) +- Cap at 5 updates per batch per task +- Drop older updates if buffer exceeds threshold (keep last N = 20) +- Priority ordering: status_change > error > tool_result > tool_call + +#### Webview: BackgroundTaskLiveView + +``` +BackgroundTaskLiveView +├── Props: { taskId: string } +├── State: updates (BackgroundTaskUpdate[], capped at last 20), status +├── Subscribes to backgroundTaskProgress messages filtered by taskId +├── Renders: compact list of recent tool calls with status icons +├── Auto-scrolls to latest update +└── Shows task status badge (running, paused, completed, errored) +``` + +The live view intentionally shows a compact summary, not a full chat transcript. Users who want full detail can wait for the task to complete and use the replay view (6a). + +> **Confirmed:** Streaming is scoped to the currently selected background task only. The extension should not emit `backgroundTaskProgress` messages for tasks the user is not viewing. This keeps message traffic low and the implementation simple. + +## 7. Testing Strategy + +| Area | Test Type | Key Scenarios | +|------|-----------|---------------| +| Message handler | Unit (vitest) | Request/response for task messages, missing task, corrupt data | +| BackgroundTaskReplayView | Component (vitest + RTL) | Loading state, message rendering, empty state | +| Tab switching | Component (vitest + RTL) | Tab navigation, state preservation, back to foreground | +| Progress streaming | Unit (vitest) | Throttling, batching, concurrent tasks | +| Integration | E2E (if feasible) | Full flow: start bg task → view progress → replay after completion | + +## 8. Confirmed Decisions + +The following decisions were confirmed during design review and should guide implementation. + +### UI Layout + +1. **Background task view layout: Full tab** (`tab === "bgTask"`) + + Start with a full tab for simplicity in Phase 6. A sidebar/hybrid mode may be considered later based on user feedback. + +2. **Entry point placement: New tab bar icon** + + Add a new icon in the existing tab bar (alongside chat, history, settings). This is the most discoverable location without cluttering the chat view. + +3. **Replay view implementation: Thin wrapper around ChatRow components** + + Create a dedicated `BackgroundTaskReplayView` that wraps `ChatRow` components directly rather than reusing the full `ChatView`. This avoids inheriting input controls, scroll management, and approval button logic that don't apply to read-only replay. + +### Progress Streaming (6c) + +4. **Streaming granularity: Minimal level** + + Stream tool name + status only (started/completed/errored). This provides enough signal to know what the background task is doing without performance risk. Truncated arguments (medium level) can be added in a follow-up if users need more context. + +5. **Streaming scope: Currently selected task only** + + Only stream updates for the background task the user is currently viewing. This avoids unnecessary message traffic and keeps the implementation simple. + +6. **Error surfacing: Badge on the background tasks tab icon** + + Display a badge on the tab icon when a background task encounters an error. Toast notifications can be added later if users miss errors. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c8c448e993..d47cf2d490 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -16,6 +16,18 @@ import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-lim import type { SkillMetadata } from "./skills.js" import type { WorktreeIncludeStatus } from "./worktree.js" +/** + * Incremental progress update for a background task (Phase 6c). + * MVP: tool name + status only. No full parameters or output payloads. + */ +export interface BackgroundTaskUpdate { + kind: "tool_call" | "tool_result" | "status_change" | "error" + timestamp: number + toolName?: string // e.g. "read_file", "execute_command" + status?: string // e.g. "started", "completed", "errored" + errorMessage?: string // Only for kind === "error" +} + /** * ExtensionMessage * Extension -> Webview | CLI @@ -94,6 +106,8 @@ export interface ExtensionMessage { | "folderSelected" | "skills" | "fileContent" + | "backgroundTaskMessages" + | "backgroundTaskProgress" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -107,6 +121,7 @@ export interface ExtensionMessage { | "settingsButtonClicked" | "historyButtonClicked" | "cloudButtonClicked" + | "backgroundTasksButtonClicked" | "didBecomeVisible" | "focusInput" | "switchTab" @@ -166,6 +181,9 @@ export interface ExtensionMessage { tools?: SerializedCustomToolDefinition[] // For customToolsResult skills?: SkillMetadata[] // For skills response modes?: { slug: string; name: string }[] // For modes response + backgroundTaskMessages?: ClineMessage[] // For backgroundTaskMessages: loaded messages for a background task replay + backgroundTaskId?: string // For backgroundTaskMessages: the task ID these messages belong to + backgroundTaskProgress?: BackgroundTaskUpdate // For backgroundTaskProgress: incremental update for a background task aggregatedCosts?: { // For taskWithAggregatedCosts response totalCost: number @@ -352,9 +370,6 @@ 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 @@ -364,21 +379,6 @@ 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" @@ -560,7 +560,9 @@ export interface WebviewMessage { | "checkoutBranch" | "browseForWorktreePath" // Background task messages - | "cancelBackgroundTask" + | "requestBackgroundTaskMessages" + | "subscribeToBackgroundTask" + | "unsubscribeFromBackgroundTask" // Skills messages | "requestSkills" | "createSkill" @@ -572,6 +574,7 @@ export interface WebviewMessage { taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "cloud" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay" | "bgTask" disabled?: boolean context?: string dataUri?: string diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 7ea5377888..b709e710ae 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -46,6 +46,7 @@ export const commandIds = [ "acceptInput", "focusPanel", "toggleAutoApprove", + "backgroundTasksButtonClicked", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 44de751bcb..6e794fb75b 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -117,6 +117,15 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" }) }, + backgroundTasksButtonClicked: () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + visibleProvider.postMessageToWebview({ type: "action", action: "backgroundTasksButtonClicked" }) + }, newTask: handleNewTask, setCustomStoragePath: async () => { const { promptForCustomStoragePath } = await import("../utils/storage") diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index dc0c98c7d1..d3393d0961 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -486,6 +486,14 @@ export async function presentAssistantMessage(cline: Task) { } hasToolResult = true + + // Phase 6c: Emit background progress when a tool completes + cline.emitBackgroundProgress({ + kind: "tool_result", + timestamp: Date.now(), + toolName: block.name, + status: "completed", + }) } const askApproval = async ( @@ -547,6 +555,15 @@ export async function presentAssistantMessage(cline: Task) { `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, ) + // Phase 6c: Emit background progress on error + cline.emitBackgroundProgress({ + kind: "error", + timestamp: Date.now(), + toolName: block.name, + status: "errored", + errorMessage: error.message, + }) + pushToolResult(formatResponse.toolError(errorString)) } @@ -649,6 +666,16 @@ export async function presentAssistantMessage(cline: Task) { } } + // Phase 6c: Emit background progress when a tool starts executing + if (!block.partial) { + cline.emitBackgroundProgress({ + kind: "tool_call", + timestamp: Date.now(), + toolName: block.name, + status: "started", + }) + } + switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index 18c082e3ce..f846eaf73a 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -49,7 +49,7 @@ export default { description: BACKGROUND_PARAMETER_DESCRIPTION, }, }, - required: ["mode", "message", "todos", "background"], + required: ["mode", "message", "todos"], additionalProperties: false, }, }, diff --git a/src/core/task/BackgroundTaskRunner.ts b/src/core/task/BackgroundTaskRunner.ts deleted file mode 100644 index 638845198d..0000000000 --- a/src/core/task/BackgroundTaskRunner.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * BackgroundTaskRunner manages read-only background tasks that run concurrently - * alongside the user's active foreground task. Background tasks: - * - Are completely webview-silent (no UI updates) - * - Auto-approve all tool uses (no user interaction) - * - Are restricted to read-only tools only - * - Have a configurable timeout to prevent runaway execution - * - Are not added to the clineStack - * - * This is Phase 4 of the parallel execution roadmap: Background Read-Only Concurrency. - * - * Phase 6+ extension points: - * - To support write-capable background tasks, extend BACKGROUND_TASK_ALLOWED_TOOLS - * and add a file-locking mechanism to prevent conflicts with foreground edits. - * - For real-time progress streaming, add an `onProgressUpdate` callback to - * BackgroundTaskInfo and emit partial tool-call summaries from Task. - * - For persistent history across sessions, serialize completedTasks to global - * state via the TaskHistoryStore and restore on provider initialization. - * - For tab-based switching, expose the background task's clineMessages via - * getTasksStatus() so the webview can render a full conversation view. - */ - -import { BackgroundTaskStatusInfo } from "@roo-code/types" - -import { Task, TaskOptions } from "./Task" - -/** Read-only tools that background tasks are allowed to use. */ -export const BACKGROUND_TASK_ALLOWED_TOOLS = [ - "read_file", - "list_files", - "search_files", - "codebase_search", - "ask_followup_question", - "attempt_completion", -] as const - -/** Default maximum number of concurrent background tasks. */ -export const DEFAULT_MAX_BACKGROUND_TASKS = 3 - -/** Default timeout for background tasks in milliseconds (5 minutes). */ -export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MS = 5 * 60 * 1000 - -export interface BackgroundTaskInfo { - task: Task - parentTaskId: string - startedAt: number - timeoutHandle: ReturnType -} - -/** - * Optional callbacks that allow the owner (e.g. ClineProvider) to react to - * background task lifecycle events such as completion, timeout, or errors. - */ -export interface BackgroundTaskRunnerCallbacks { - /** Called when a background task times out. */ - onTaskTimeout?: (taskId: string, parentTaskId: string) => void - /** Called when aborting a background task throws an error. */ - 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, - taskTimeoutMs: number = DEFAULT_BACKGROUND_TASK_TIMEOUT_MS, - callbacks: BackgroundTaskRunnerCallbacks = {}, - ) { - this.maxConcurrentTasks = maxConcurrentTasks - this.taskTimeoutMs = taskTimeoutMs - this.callbacks = callbacks - } - - /** - * Returns the number of currently running background tasks. - */ - get activeCount(): number { - return this.backgroundTasks.size - } - - /** - * Returns whether the runner can accept more background tasks. - */ - get canAcceptTask(): boolean { - return this.backgroundTasks.size < this.maxConcurrentTasks - } - - /** - * Register a background task after it has been created. - * The task should already have isBackgroundTask=true and be started. - */ - registerTask(task: Task, parentTaskId: string): void { - if (this.backgroundTasks.has(task.taskId)) { - console.warn(`[BackgroundTaskRunner] Task ${task.taskId} already registered`) - return - } - - if (!this.canAcceptTask) { - throw new Error( - `[BackgroundTaskRunner] Cannot accept more background tasks. ` + - `Current: ${this.backgroundTasks.size}, Max: ${this.maxConcurrentTasks}`, - ) - } - - const timeoutHandle = setTimeout(() => { - this.timeoutTask(task.taskId) - }, this.taskTimeoutMs) - - this.backgroundTasks.set(task.taskId, { - task, - parentTaskId, - startedAt: Date.now(), - timeoutHandle, - }) - - console.log( - `[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, resultSummary?: string): BackgroundTaskInfo | undefined { - const info = this.backgroundTasks.get(taskId) - - if (!info) { - return undefined - } - - 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 - } - - /** - * Get info about a specific background task. - */ - getTaskInfo(taskId: string): BackgroundTaskInfo | undefined { - return this.backgroundTasks.get(taskId) - } - - /** - * Check if a task is a registered background task. - */ - isBackgroundTask(taskId: string): boolean { - return this.backgroundTasks.has(taskId) - } - - /** - * Cancel all background tasks spawned by a specific parent task. - */ - async cancelTasksByParent(parentTaskId: string): Promise { - const tasksToCancel: BackgroundTaskInfo[] = [] - - for (const [, info] of this.backgroundTasks) { - if (info.parentTaskId === parentTaskId) { - tasksToCancel.push(info) - } - } - - for (const info of tasksToCancel) { - await this.cancelTask(info.task.taskId) - } - } - - /** - * Cancel a specific background task. - */ - async cancelTask(taskId: string): Promise { - const info = this.backgroundTasks.get(taskId) - - if (!info) { - return - } - - 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 { - this.callbacks.onTaskError?.(taskId, info.parentTaskId, err) - } catch { - // Callback errors must not break cleanup. - } - } - - 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() - } - - /** - * Cancel all background tasks. Called during provider disposal. - */ - async dispose(): Promise { - const taskIds = Array.from(this.backgroundTasks.keys()) - - for (const taskId of taskIds) { - await this.cancelTask(taskId) - } - } - - /** - * 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`) - - try { - this.callbacks.onTaskTimeout?.(taskId, parentTaskId) - } catch { - // Callback errors must not break cleanup. - } - - // 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/Task.ts b/src/core/task/Task.ts index 0ddc1f505f..812d39ff41 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -30,6 +30,7 @@ import { type ClineSay, type ClineAsk, type ToolProgressStatus, + type BackgroundTaskUpdate, type HistoryItem, type CreateTaskOptions, type ModelInfo, @@ -183,11 +184,6 @@ export class Task extends EventEmitter implements TaskLike { readonly instanceId: string readonly metadata: TaskMetadata - /** When true, this task runs in the background with webview silencing and auto-approval. */ - readonly isBackgroundTask: boolean - /** Callback for background task completion result delivery. */ - readonly onBackgroundComplete?: (taskId: string, result: string) => void - todoList?: TodoItem[] readonly rootTask: Task | undefined = undefined @@ -1198,14 +1194,10 @@ export class Task extends EventEmitter implements TaskLike { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) - - if (!this.isBackgroundTask) { - const provider = this.providerRef.deref() - // Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update. - // taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated. - await provider?.postStateToWebviewWithoutTaskHistory() - } - + const provider = this.providerRef.deref() + // Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update. + // taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated. + await provider?.postStateToWebviewWithoutTaskHistory() this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() } @@ -1217,11 +1209,8 @@ export class Task extends EventEmitter implements TaskLike { } private async updateClineMessage(message: ClineMessage) { - if (!this.isBackgroundTask) { - const provider = this.providerRef.deref() - await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) - } - + const provider = this.providerRef.deref() + await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) this.emit(RooCodeEventName.Message, { action: "updated", message }) } @@ -1274,9 +1263,7 @@ export class Task extends EventEmitter implements TaskLike { // - Final state is emitted when updates stop (trailing: true) this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) - if (!this.isBackgroundTask) { - await this.providerRef.deref()?.updateTaskHistory(historyItem) - } + await this.providerRef.deref()?.updateTaskHistory(historyItem) return true } catch (error) { console.error("Failed to save Roo messages:", error) @@ -1396,26 +1383,6 @@ export class Task extends EventEmitter implements TaskLike { let timeouts: NodeJS.Timeout[] = [] - // Background tasks auto-approve all asks immediately (no user interaction). - // Design decision: Full auto-approval is safe here because background tasks - // are restricted to read-only tools only (read_file, list_files, search_files, - // codebase_search). They cannot modify files, execute commands, or perform any - // destructive operations. If a future phase introduces write-capable background - // tasks, this auto-approval should be revisited to allow selective user input - // for dangerous operations. - if (this.isBackgroundTask) { - this.approveAsk() - await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) - if (this.lastMessageTs !== askTs) { - throw new AskIgnoredError("superseded") - } - const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages } - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - return result - } - // Automatically approve if the ask according to the user's settings. const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined @@ -4624,6 +4591,68 @@ export class Task extends EventEmitter implements TaskLike { } } + // --- Phase 6c: Background task progress streaming --- + + private backgroundProgressBuffer: BackgroundTaskUpdate[] = [] + private backgroundProgressTimer: ReturnType | null = null + private static readonly BACKGROUND_PROGRESS_THROTTLE_MS = 500 + private static readonly BACKGROUND_PROGRESS_MAX_BATCH = 5 + + /** + * Emit a progress update for this task if it is a background task currently + * being viewed by the user. Updates are batched in 500ms windows and capped + * at 5 per batch. + */ + public emitBackgroundProgress(update: BackgroundTaskUpdate): void { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit when this task is NOT the current (foreground) task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + // Only emit when the user is actively viewing this background task + if (provider.viewedBackgroundTaskId !== this.taskId) return + + this.backgroundProgressBuffer.push(update) + + // If no flush is pending, schedule one + if (!this.backgroundProgressTimer) { + this.backgroundProgressTimer = setTimeout(() => { + this.flushBackgroundProgress() + }, Task.BACKGROUND_PROGRESS_THROTTLE_MS) + } + } + + private flushBackgroundProgress(): void { + this.backgroundProgressTimer = null + const provider = this.providerRef.deref() + if (!provider) { + this.backgroundProgressBuffer = [] + return + } + + // Take at most MAX_BATCH items, prioritizing by kind + const priorityOrder: Record = { + status_change: 0, + error: 1, + tool_result: 2, + tool_call: 3, + } + const sorted = this.backgroundProgressBuffer.sort( + (a, b) => (priorityOrder[a.kind] ?? 4) - (priorityOrder[b.kind] ?? 4), + ) + const batch = sorted.slice(0, Task.BACKGROUND_PROGRESS_MAX_BATCH) + this.backgroundProgressBuffer = [] + + for (const update of batch) { + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + backgroundTaskId: this.taskId, + backgroundTaskProgress: update, + }) + } + } + // Getters public get taskStatus(): TaskStatus { diff --git a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts deleted file mode 100644 index 184a1b9b3f..0000000000 --- a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { - BackgroundTaskRunner, - DEFAULT_MAX_BACKGROUND_TASKS, - DEFAULT_BACKGROUND_TASK_TIMEOUT_MS, -} from "../BackgroundTaskRunner" - -// Minimal mock for Task -function createMockTask(taskId: string): any { - return { - taskId, - instanceId: "test-instance", - isBackgroundTask: true, - abortTask: vi.fn().mockResolvedValue(undefined), - } -} - -describe("BackgroundTaskRunner", () => { - let runner: BackgroundTaskRunner - - beforeEach(() => { - vi.useFakeTimers() - runner = new BackgroundTaskRunner() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe("constructor", () => { - it("should initialize with default values", () => { - expect(runner.activeCount).toBe(0) - expect(runner.canAcceptTask).toBe(true) - }) - - it("should accept custom concurrency and timeout", () => { - const customRunner = new BackgroundTaskRunner(5, 60000) - expect(customRunner.canAcceptTask).toBe(true) - }) - }) - - describe("registerTask", () => { - it("should register a background task", () => { - const task = createMockTask("task-1") - runner.registerTask(task, "parent-1") - - expect(runner.activeCount).toBe(1) - expect(runner.isBackgroundTask("task-1")).toBe(true) - }) - - it("should track parent task ID", () => { - const task = createMockTask("task-1") - runner.registerTask(task, "parent-1") - - const info = runner.getTaskInfo("task-1") - expect(info).toBeDefined() - expect(info!.parentTaskId).toBe("parent-1") - }) - - it("should not register duplicate tasks", () => { - const task = createMockTask("task-1") - runner.registerTask(task, "parent-1") - runner.registerTask(task, "parent-1") // duplicate - - expect(runner.activeCount).toBe(1) - }) - - it("should throw when concurrency limit is reached", () => { - const customRunner = new BackgroundTaskRunner(2) - - customRunner.registerTask(createMockTask("task-1"), "parent-1") - customRunner.registerTask(createMockTask("task-2"), "parent-1") - - expect(() => { - customRunner.registerTask(createMockTask("task-3"), "parent-1") - }).toThrow("Cannot accept more background tasks") - }) - - it("should report canAcceptTask correctly", () => { - const customRunner = new BackgroundTaskRunner(2) - - expect(customRunner.canAcceptTask).toBe(true) - customRunner.registerTask(createMockTask("task-1"), "parent-1") - expect(customRunner.canAcceptTask).toBe(true) - customRunner.registerTask(createMockTask("task-2"), "parent-1") - expect(customRunner.canAcceptTask).toBe(false) - }) - }) - - describe("onTaskCompleted", () => { - it("should remove completed task and return info", () => { - const task = createMockTask("task-1") - runner.registerTask(task, "parent-1") - - const info = runner.onTaskCompleted("task-1") - - expect(info).toBeDefined() - expect(info!.parentTaskId).toBe("parent-1") - expect(runner.activeCount).toBe(0) - expect(runner.isBackgroundTask("task-1")).toBe(false) - }) - - it("should return undefined for unknown task", () => { - const info = runner.onTaskCompleted("unknown") - expect(info).toBeUndefined() - }) - - it("should clear the timeout on completion", () => { - const task = createMockTask("task-1") - runner.registerTask(task, "parent-1") - runner.onTaskCompleted("task-1") - - // Advance time past the timeout - should not trigger abort - vi.advanceTimersByTime(DEFAULT_BACKGROUND_TASK_TIMEOUT_MS + 1000) - expect(task.abortTask).not.toHaveBeenCalled() - }) - }) - - describe("cancelTask", () => { - it("should abort and remove a task", async () => { - const task = createMockTask("task-1") - runner.registerTask(task, "parent-1") - - await runner.cancelTask("task-1") - - expect(task.abortTask).toHaveBeenCalledWith(true) - expect(runner.activeCount).toBe(0) - }) - - it("should handle canceling unknown task gracefully", async () => { - await runner.cancelTask("unknown") // should not throw - }) - - it("should invoke onTaskError callback when abort throws", async () => { - const onTaskError = vi.fn() - const customRunner = new BackgroundTaskRunner(3, undefined, { onTaskError }) - const task = createMockTask("task-1") - task.abortTask.mockRejectedValue(new Error("abort failed")) - customRunner.registerTask(task, "parent-1") - - await customRunner.cancelTask("task-1") - - expect(onTaskError).toHaveBeenCalledWith("task-1", "parent-1", expect.any(Error)) - expect(customRunner.activeCount).toBe(0) - }) - }) - - describe("cancelTasksByParent", () => { - it("should cancel all tasks for a given parent", async () => { - const task1 = createMockTask("task-1") - const task2 = createMockTask("task-2") - const task3 = createMockTask("task-3") - - runner.registerTask(task1, "parent-1") - runner.registerTask(task2, "parent-1") - runner.registerTask(task3, "parent-2") - - await runner.cancelTasksByParent("parent-1") - - expect(task1.abortTask).toHaveBeenCalled() - expect(task2.abortTask).toHaveBeenCalled() - expect(task3.abortTask).not.toHaveBeenCalled() - expect(runner.activeCount).toBe(1) - }) - }) - - describe("timeout", () => { - it("should abort task after timeout", async () => { - const task = createMockTask("task-1") - const customRunner = new BackgroundTaskRunner(3, 5000) - customRunner.registerTask(task, "parent-1") - - vi.advanceTimersByTime(5000) - - // Allow any pending microtasks to flush - await vi.runAllTimersAsync() - - expect(task.abortTask).toHaveBeenCalledWith(true) - expect(customRunner.activeCount).toBe(0) - }) - - it("should invoke onTaskTimeout callback when task times out", async () => { - const onTaskTimeout = vi.fn() - const customRunner = new BackgroundTaskRunner(3, 5000, { onTaskTimeout }) - const task = createMockTask("task-1") - customRunner.registerTask(task, "parent-1") - - vi.advanceTimersByTime(5000) - await vi.runAllTimersAsync() - - expect(onTaskTimeout).toHaveBeenCalledWith("task-1", "parent-1") - }) - }) - - describe("dispose", () => { - it("should cancel all tasks", async () => { - const task1 = createMockTask("task-1") - const task2 = createMockTask("task-2") - - runner.registerTask(task1, "parent-1") - runner.registerTask(task2, "parent-2") - - await runner.dispose() - - expect(task1.abortTask).toHaveBeenCalled() - expect(task2.abortTask).toHaveBeenCalled() - expect(runner.activeCount).toBe(0) - }) - }) - - describe("getTaskInfo", () => { - it("should return task info for registered task", () => { - const task = createMockTask("task-1") - runner.registerTask(task, "parent-1") - - const info = runner.getTaskInfo("task-1") - expect(info).toBeDefined() - expect(info!.task).toBe(task) - expect(info!.parentTaskId).toBe("parent-1") - expect(info!.startedAt).toBeGreaterThan(0) - }) - - it("should return undefined for unregistered task", () => { - 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/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index d30aa68a07..7a024735f1 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -78,14 +78,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { task.consecutiveMistakeCount = 0 - // Background task completion: deliver result via callback, no UI interaction - if (task.isBackgroundTask && task.onBackgroundComplete) { - task.onBackgroundComplete(task.taskId, result) - this.emitTaskCompleted(task) - pushToolResult("") - return - } - await task.say("completion_result", result, undefined, false) // Check for subtask using parentTaskId (metadata-driven delegation) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index fc3dfaa6c3..e681c88d38 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -29,8 +29,8 @@ export class NewTaskTool extends BaseTool<"new_task"> { async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { const { mode, message, todos, task_queue, permissions: permissionsJson, background } = params const { mode, message, todos, background } = params + const { mode, message, todos } = params const { askApproval, handleError, pushToolResult } = callbacks - const isBackground = background === "true" try { // Validate required parameters. @@ -68,8 +68,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { // Check if todos are required based on VSCode setting. // Note: `undefined` means not provided, empty string is valid. - // Background tasks don't require todos (they're read-only). - if (requireTodos && todos === undefined && !isBackground) { + if (requireTodos && todos === undefined) { task.consecutiveMistakeCount++ task.recordToolError("new_task") task.didToolFailInCurrentTurn = true @@ -183,29 +182,6 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } - if (isBackground) { - // Spawn as a background task - parent continues executing - try { - const bgTask = await (provider as any).spawnBackgroundTask({ - parentTaskId: task.taskId, - message: unescapedMessage, - mode, - }) - pushToolResult( - `Background task ${bgTask.taskId} spawned in ${targetMode.name} mode. ` + - `It will run concurrently with read-only tools. ` + - `Results will be delivered when it completes.`, - ) - } catch (error) { - pushToolResult( - formatResponse.toolError( - `Failed to spawn background task: ${error instanceof Error ? error.message : String(error)}`, - ), - ) - } - return - } - // Delegate parent and open child as sole active task const child = await (provider as any).delegateParentAndOpenChild({ parentTaskId: task.taskId, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1f4f2ee7c7..0d86762ad0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -149,21 +149,6 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false - 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() @@ -181,6 +166,8 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number + /** The background task ID the webview is currently viewing (for Phase 6c progress streaming). */ + public viewedBackgroundTaskId: string | null = null public readonly latestAnnouncementId = "apr-2026-v3.53.0-community-handoff-gpt55-opus47" // v3.53.0 Community handoff, GPT-5.5, Claude Opus 4.7, checkpoint navigation public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -673,10 +660,6 @@ export class ClineProvider this._disposed = true this.log("Disposing ClineProvider...") - // Cancel all background tasks first. - await this.backgroundTaskRunner.dispose() - this.log("Disposed background task runner") - // Clear all tasks from the stack. while (this.clineStack.length > 0) { await this.removeClineFromStack() @@ -1969,19 +1952,6 @@ 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. * @@ -2300,7 +2270,6 @@ export class ClineProvider } })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), - backgroundTasks: this.backgroundTaskRunner.getTasksStatus(), } } @@ -3203,131 +3172,6 @@ export class ClineProvider return child } - /** - * Spawn a background task that runs concurrently alongside the foreground task. - * Background tasks are: - * - Completely webview-silent (no UI updates) - * - Auto-approved for all tool uses (no user interaction) - * - Restricted to read-only tools only - * - Tracked by the BackgroundTaskRunner with timeout enforcement - * - * The parent task continues executing while the background task runs. - * Results are delivered asynchronously via the onBackgroundComplete callback. - */ - public async spawnBackgroundTask(params: { parentTaskId: string; message: string; mode: string }): Promise { - const { parentTaskId, message, mode } = params - - if (!this.backgroundTaskRunner.canAcceptTask) { - throw new Error( - `[spawnBackgroundTask] Cannot spawn background task: concurrency limit reached ` + - `(${this.backgroundTaskRunner.activeCount} active)`, - ) - } - - // Get parent task for lineage - const parent = this.getCurrentTask() - if (!parent || parent.taskId !== parentTaskId) { - throw new Error(`[spawnBackgroundTask] Parent task mismatch or not found: ${parentTaskId}`) - } - - const { apiConfiguration, experiments } = await this.getState() - - // Switch mode for the background task's context - const savedMode = (await this.getState()).mode - - try { - await this.handleModeSwitch(mode as any) - } catch (e) { - this.log( - `[spawnBackgroundTask] handleModeSwitch failed for mode '${mode}': ${ - (e as Error)?.message ?? String(e) - }`, - ) - } - - // Create the background task - NOT added to clineStack - const backgroundTask = new Task({ - provider: this, - apiConfiguration, - task: message, - experiments, - rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined, - parentTask: parent, - taskNumber: -1, // Background tasks don't get a sequential number - isBackgroundTask: true, - enableCheckpoints: false, // Read-only tasks have nothing to checkpoint - startTask: false, - initialStatus: "active", - onBackgroundComplete: (taskId: string, result: string) => { - this.handleBackgroundTaskComplete(taskId, result) - }, - }) - - // Restore the original mode for the foreground task - try { - await this.handleModeSwitch(savedMode as any) - } catch (e) { - this.log( - `[spawnBackgroundTask] Failed to restore mode '${savedMode}': ${(e as Error)?.message ?? String(e)}`, - ) - } - - // Register with the background task runner (handles timeout, tracking) - this.backgroundTaskRunner.registerTask(backgroundTask, parentTaskId) - - // Start the task (it will auto-approve all tools and skip webview updates) - backgroundTask.start() - - this.log( - `[spawnBackgroundTask] Background task ${backgroundTask.taskId} spawned ` + - `(parent: ${parentTaskId}, mode: ${mode})`, - ) - - return backgroundTask - } - - /** - * Handle completion of a background task. Injects the result into the parent - * task's API conversation as a system message. - */ - private async handleBackgroundTaskComplete(taskId: string, result: string): Promise { - const info = this.backgroundTaskRunner.onTaskCompleted(taskId, result) - - if (!info) { - this.log(`[handleBackgroundTaskComplete] Task ${taskId} not found in background runner`) - return - } - - // Notify the user that the background task finished. - vscode.window.showInformationMessage(`Background task ${taskId} completed.`) - - const parentTaskId = info.parentTaskId - const currentTask = this.getCurrentTask() - - // If the parent is currently the foreground task, inject the result directly - if (currentTask && currentTask.taskId === parentTaskId) { - const resultMessage = [`Background task ${taskId} completed.`, ``, `Result:`, result].join("\n") - - // Inject as a system-level message into the parent's conversation - try { - await currentTask.say("subtask_result", resultMessage) - } catch (error) { - this.log( - `[handleBackgroundTaskComplete] Failed to inject result into parent ${parentTaskId}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } else { - // Parent is not the current foreground task (e.g., it was delegated). - // Store the result for later retrieval when the parent resumes. - this.log( - `[handleBackgroundTaskComplete] Parent ${parentTaskId} is not foreground. ` + - `Background task ${taskId} result will not be injected automatically.`, - ) - } - } - /** * Reopen parent task from delegation with write-back and events. */ diff --git a/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts new file mode 100644 index 0000000000..d9e68b79f6 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts @@ -0,0 +1,83 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +vi.mock("../../task-persistence", () => ({ + saveTaskMessages: vi.fn(), + readTaskMessages: vi.fn(), +})) + +import { readTaskMessages } from "../../task-persistence" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), +} as unknown as ClineProvider + +describe("webviewMessageHandler - requestBackgroundTaskMessages", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("loads task messages from disk and posts them to the webview", async () => { + const mockMessages = [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ] + vi.mocked(readTaskMessages).mockResolvedValue(mockMessages as any) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-123", + backgroundTaskMessages: mockMessages, + }) + }) + + it("returns empty array when task has no messages", async () => { + vi.mocked(readTaskMessages).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-empty", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-empty", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-empty", + backgroundTaskMessages: [], + }) + }) + + it("does nothing when taskId is not provided", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + // no text/taskId provided + }) + + expect(readTaskMessages).not.toHaveBeenCalled() + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts new file mode 100644 index 0000000000..a5f8138254 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts @@ -0,0 +1,52 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), + viewedBackgroundTaskId: null as string | null, +} as unknown as ClineProvider + +describe("webviewMessageHandler - background task progress subscription", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(mockClineProvider as any).viewedBackgroundTaskId = null + }) + + it("sets viewedBackgroundTaskId on subscribeToBackgroundTask", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + text: "task-456", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBe("task-456") + }) + + it("clears viewedBackgroundTaskId on unsubscribeFromBackgroundTask", async () => { + ;(mockClineProvider as any).viewedBackgroundTaskId = "task-456" + + await webviewMessageHandler(mockClineProvider, { + type: "unsubscribeFromBackgroundTask", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) + + it("handles subscribeToBackgroundTask with no text gracefully", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + // no text + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 434995aa5b..6783eccfe4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -24,7 +24,7 @@ import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" import { type ApiMessage } from "../task-persistence/apiMessages" -import { saveTaskMessages } from "../task-persistence" +import { saveTaskMessages, readTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" @@ -814,6 +814,28 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We case "showTaskWithId": provider.showTaskWithId(message.text!) break + case "requestBackgroundTaskMessages": { + const taskId = message.text + if (taskId) { + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages({ taskId, globalStoragePath }) + await provider.postMessageToWebview({ + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }) + } + break + } + case "subscribeToBackgroundTask": { + const taskId = message.text + provider.viewedBackgroundTaskId = taskId ?? null + break + } + case "unsubscribeFromBackgroundTask": { + provider.viewedBackgroundTaskId = null + break + } case "condenseTaskContextRequest": provider.condenseTaskContext(message.text!) break @@ -1305,11 +1327,6 @@ 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/src/package.json b/src/package.json index f578357706..fd1e9c5d45 100644 --- a/src/package.json +++ b/src/package.json @@ -164,6 +164,11 @@ "command": "roo-cline.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.backgroundTasksButtonClicked", + "title": "Background Tasks", + "icon": "$(server-process)" } ], "menus": { @@ -229,9 +234,14 @@ "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.backgroundTasksButtonClicked", "group": "overflow@2", "when": "view == roo-cline.SidebarProvider" + }, + { + "command": "roo-cline.popoutButtonClicked", + "group": "overflow@3", + "when": "view == roo-cline.SidebarProvider" } ], "editor/title": [ @@ -256,9 +266,14 @@ "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.backgroundTasksButtonClicked", "group": "overflow@2", "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" + }, + { + "command": "roo-cline.popoutButtonClicked", + "group": "overflow@3", + "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" } ] }, diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 0b7eb2109c..0179e1c551 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -107,6 +107,7 @@ export type NativeToolArgs = { list_files: { path: string; recursive?: boolean } new_task: { mode: string; message: string; todos?: string; task_queue?: string; permissions?: string } new_task: { mode: string; message: string; todos?: string; background?: string } + new_task: { mode: string; message: string; todos?: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -247,6 +248,7 @@ export interface NewTaskToolUse extends ToolUse<"new_task"> { params: Partial, "mode" | "message" | "todos" | "task_queue">> params: Partial, "mode" | "message" | "todos" | "permissions">> params: Partial, "mode" | "message" | "todos" | "background">> + params: Partial, "mode" | "message" | "todos">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 8b4eddfa9b..8a1bbe6fca 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -12,6 +12,8 @@ import ChatView, { ChatViewRef } from "./components/chat/ChatView" import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeViewProvider" +import BackgroundTaskReplayView from "./components/chat/BackgroundTaskReplayView" +import BackgroundTaskView from "./components/chat/BackgroundTaskView" import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" @@ -21,6 +23,7 @@ import { TooltipProvider } from "./components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" type Tab = "settings" | "history" | "chat" | "cloud" +type Tab = "settings" | "history" | "chat" | "bgTaskReplay" | "bgTask" interface DeleteMessageDialogState { isOpen: boolean @@ -45,6 +48,7 @@ const tabsByMessageAction: Partial { @@ -61,6 +65,7 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") + const [replayTaskId, setReplayTaskId] = useState(null) const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ isOpen: false, @@ -99,6 +104,10 @@ const App = () => { // Handle switchTab action with tab parameter if (message.action === "switchTab" && message.tab) { const targetTab = message.tab as Tab + // If switching to bgTaskReplay, extract taskId from values + if (targetTab === "bgTaskReplay" && message.values?.taskId) { + setReplayTaskId(message.values.taskId as string) + } switchTab(targetTab) // Extract targetSection from values if provided const targetSection = message.values?.section as string | undefined @@ -185,6 +194,16 @@ const App = () => { ) : ( <> + {tab === "bgTaskReplay" && replayTaskId && ( + { + setReplayTaskId(null) + switchTab("chat") + }} + /> + )} + {tab === "bgTask" && switchTab("chat")} />} {tab === "history" && switchTab("chat")} />} {tab === "settings" && ( setTab("chat")} targetSection={currentSection} /> diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index f759d1eb0d..76353f8a0b 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -40,6 +40,24 @@ vi.mock("@src/components/history/HistoryView", () => ({ }, })) +vi.mock("@src/components/chat/BackgroundTaskView", () => ({ + __esModule: true, + default: function BackgroundTaskView({ onClose }: { onClose: () => void }) { + return ( +
+ Background Task View +
+ ) + }, +})) + +vi.mock("@src/components/chat/BackgroundTaskReplayView", () => ({ + __esModule: true, + default: function BackgroundTaskReplayView() { + return
Background Task Replay View
+ }, +})) + vi.mock("@src/components/mcp/McpView", () => ({ __esModule: true, default: function McpView() { @@ -212,6 +230,38 @@ describe("App", () => { expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument() }) + it("switches to background tasks view when receiving backgroundTasksButtonClicked action", async () => { + render() + + act(() => { + triggerMessage("backgroundTasksButtonClicked") + }) + + const bgTaskView = await screen.findByTestId("background-task-view") + expect(bgTaskView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("returns to chat view when clicking done in background tasks view", async () => { + render() + + act(() => { + triggerMessage("backgroundTasksButtonClicked") + }) + + const bgTaskView = await screen.findByTestId("background-task-view") + + act(() => { + bgTaskView.click() + }) + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("false") + expect(screen.queryByTestId("background-task-view")).not.toBeInTheDocument() + }) + it.each(["history"])("returns to chat view when clicking done in %s view", async (view) => { render() diff --git a/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx new file mode 100644 index 0000000000..89fa9b15b5 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx @@ -0,0 +1,152 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useEvent } from "react-use" +import { ArrowLeft, Play, CheckCircle2, AlertCircle, Loader2 } from "lucide-react" + +import type { BackgroundTaskUpdate, ExtensionMessage } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +const MAX_UPDATES = 20 + +export interface BackgroundTaskLiveViewProps { + taskId: string + onClose: () => void +} + +function getUpdateIcon(update: BackgroundTaskUpdate) { + if (update.kind === "error") { + return + } + if (update.status === "started") { + return + } + if (update.status === "completed") { + return + } + return +} + +function formatUpdateLabel(update: BackgroundTaskUpdate): string { + const tool = update.toolName ?? "unknown" + if (update.kind === "error") { + return `${tool} -- errored${update.errorMessage ? `: ${update.errorMessage}` : ""}` + } + if (update.kind === "tool_call") { + return `${tool} -- started` + } + if (update.kind === "tool_result") { + return `${tool} -- completed` + } + if (update.kind === "status_change") { + return `Status: ${update.status ?? "unknown"}` + } + return tool +} + +/** + * Compact live view that streams real-time progress updates for an active + * background task. Shows a rolling window of the last 20 tool-call updates + * with status icons. + */ +const BackgroundTaskLiveView = memo(({ taskId, onClose }: BackgroundTaskLiveViewProps) => { + const [updates, setUpdates] = useState([]) + const scrollRef = useRef(null) + + // Subscribe to background task progress on mount, unsubscribe on unmount + useEffect(() => { + vscode.postMessage({ type: "subscribeToBackgroundTask", text: taskId }) + return () => { + vscode.postMessage({ type: "unsubscribeFromBackgroundTask" }) + } + }, [taskId]) + + // Listen for progress updates + const handleMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if ( + message.type === "backgroundTaskProgress" && + message.backgroundTaskId === taskId && + message.backgroundTaskProgress + ) { + setUpdates((prev) => { + const next = [...prev, message.backgroundTaskProgress!] + // Keep only the last N updates (rolling window) + if (next.length > MAX_UPDATES) { + return next.slice(next.length - MAX_UPDATES) + } + return next + }) + } + }, + [taskId], + ) + + useEvent("message", handleMessage) + + // Auto-scroll to bottom when new updates arrive + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [updates]) + + return ( +
+ {/* Header */} +
+ + + Live progress · {updates.length} updates + + +
+ + {/* Update list */} +
+ {updates.length === 0 ? ( +
+ +

+ Waiting for updates from background task... +

+
+ ) : ( +
+ {updates.map((update, index) => ( +
+ {getUpdateIcon(update)} + {formatUpdateLabel(update)} + + {new Date(update.timestamp).toLocaleTimeString()} + +
+ ))} +
+ )} +
+
+ ) +}) + +BackgroundTaskLiveView.displayName = "BackgroundTaskLiveView" + +export default BackgroundTaskLiveView diff --git a/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx b/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx new file mode 100644 index 0000000000..625956bf53 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx @@ -0,0 +1,139 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useEvent } from "react-use" +import { ArrowLeft, Loader2 } from "lucide-react" + +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import ChatRow from "./ChatRow" + +export interface BackgroundTaskReplayViewProps { + taskId: string + onClose: () => void +} + +/** + * A read-only view that displays the full message history of a background task. + * This is a thin wrapper around ChatRow components -- it loads messages from disk + * via the extension and renders them without any input controls or approval buttons. + */ +const BackgroundTaskReplayView = memo(({ taskId, onClose }: BackgroundTaskReplayViewProps) => { + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedMessages, setExpandedMessages] = useState>(new Set()) + const scrollContainerRef = useRef(null) + + // Request messages from the extension on mount + useEffect(() => { + setLoading(true) + setError(null) + vscode.postMessage({ type: "requestBackgroundTaskMessages", text: taskId }) + }, [taskId]) + + // Listen for the response + const handleMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if (message.type === "backgroundTaskMessages" && message.backgroundTaskId === taskId) { + setMessages(message.backgroundTaskMessages ?? []) + setLoading(false) + } + }, + [taskId], + ) + + useEvent("message", handleMessage) + + const handleToggleExpand = useCallback((ts: number) => { + setExpandedMessages((prev) => { + const next = new Set(prev) + if (next.has(ts)) { + next.delete(ts) + } else { + next.add(ts) + } + return next + }) + }, []) + + if (loading) { + return ( +
+ +

Loading task messages...

+
+ ) + } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + return ( +
+ {/* Header bar */} +
+ + + Task replay (read-only) · {messages.length} messages + +
+ + {/* Message list */} +
+ {messages.length === 0 ? ( +
+

+ No messages found for this task. +

+
+ ) : ( + messages.map((msg, index) => ( + {}} + /> + )) + )} +
+
+ ) +}) + +BackgroundTaskReplayView.displayName = "BackgroundTaskReplayView" + +export default BackgroundTaskReplayView diff --git a/webview-ui/src/components/chat/BackgroundTaskView.tsx b/webview-ui/src/components/chat/BackgroundTaskView.tsx new file mode 100644 index 0000000000..e4b22e3f05 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskView.tsx @@ -0,0 +1,83 @@ +import { memo, useCallback, useState } from "react" +import { ArrowLeft } from "lucide-react" + +import { useExtensionState } from "@src/context/ExtensionStateContext" + +import BackgroundTasksList from "./BackgroundTasksList" +import BackgroundTaskReplayView from "./BackgroundTaskReplayView" +import BackgroundTaskLiveView from "./BackgroundTaskLiveView" + +type BackgroundTaskSubView = "list" | "replay" | "live" + +export interface BackgroundTaskViewProps { + onClose: () => void +} + +/** + * Full-tab container for the background tasks feature (Phase 6b/6c). + * Manages navigation between BackgroundTasksList, BackgroundTaskReplayView, + * and BackgroundTaskLiveView. + */ +const BackgroundTaskView = memo(({ onClose }: BackgroundTaskViewProps) => { + const [subView, setSubView] = useState("list") + const [selectedTaskId, setSelectedTaskId] = useState(null) + const { taskHistory } = useExtensionState() + + const handleSelectTask = useCallback( + (taskId: string) => { + setSelectedTaskId(taskId) + // Route to live view for active tasks, replay for completed + const task = taskHistory.find((t) => t.id === taskId) + if (task?.status === "active") { + setSubView("live") + } else { + setSubView("replay") + } + }, + [taskHistory], + ) + + const handleBackToList = useCallback(() => { + setSelectedTaskId(null) + setSubView("list") + }, []) + + return ( +
+ {/* Top header bar -- only shown in list view since replay has its own header */} + {subView === "list" && ( +
+ + Background Tasks +
+ )} + + {/* Sub-view content */} +
+ {subView === "list" && } + {subView === "replay" && selectedTaskId && ( + + )} + {subView === "live" && selectedTaskId && ( + + )} +
+
+ ) +}) + +BackgroundTaskView.displayName = "BackgroundTaskView" + +export default BackgroundTaskView diff --git a/webview-ui/src/components/chat/BackgroundTasksList.tsx b/webview-ui/src/components/chat/BackgroundTasksList.tsx new file mode 100644 index 0000000000..93f05d22dc --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTasksList.tsx @@ -0,0 +1,165 @@ +import { memo, useMemo } from "react" +import { Clock, CheckCircle2, AlertCircle, Play } from "lucide-react" + +import type { HistoryItem } from "@roo-code/types" + +import { useExtensionState } from "@src/context/ExtensionStateContext" + +export interface BackgroundTasksListProps { + onSelectTask: (taskId: string) => void +} + +type TaskStatus = "active" | "completed" | "delegated" | "unknown" + +function getTaskStatus(item: HistoryItem): TaskStatus { + return item.status ?? "unknown" +} + +function getStatusIcon(status: TaskStatus) { + switch (status) { + case "active": + return + case "completed": + return + case "delegated": + return + default: + return + } +} + +function getStatusLabel(status: TaskStatus): string { + switch (status) { + case "active": + return "Running" + case "completed": + return "Completed" + case "delegated": + return "Delegated" + default: + return "Unknown" + } +} + +function formatTimestamp(ts: number): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + + if (diffMins < 1) { + return "just now" + } + if (diffMins < 60) { + return `${diffMins}m ago` + } + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) { + return `${diffHours}h ago` + } + const diffDays = Math.floor(diffHours / 24) + return `${diffDays}d ago` +} + +function truncateTask(task: string, maxLen: number = 80): string { + if (task.length <= maxLen) { + return task + } + return task.slice(0, maxLen) + "..." +} + +/** + * Displays a list of background tasks (subtasks / child tasks) from the task history. + * Each item shows status, task description, mode, and timestamp. + * Clicking a task navigates to its replay view. + */ +const BackgroundTasksList = memo(({ onSelectTask }: BackgroundTasksListProps) => { + const { taskHistory, currentTaskItem } = useExtensionState() + + // Filter to show tasks that have a parentTaskId (i.e., subtasks / background tasks) + // Exclude the current foreground task + const backgroundTasks = useMemo(() => { + return taskHistory + .filter((item) => item.parentTaskId && item.id !== currentTaskItem?.id) + .sort((a, b) => b.ts - a.ts) + }, [taskHistory, currentTaskItem?.id]) + + const activeTasks = useMemo(() => backgroundTasks.filter((t) => t.status === "active"), [backgroundTasks]) + + if (backgroundTasks.length === 0) { + return ( +
+

No background tasks yet.

+

+ Background tasks will appear here when subtasks are spawned via the new_task tool. +

+
+ ) + } + + return ( +
+ {/* Summary header */} +
+ {activeTasks.length > 0 && ( + + + {activeTasks.length} active + + )} + {backgroundTasks.length} total +
+ + {/* Task list */} +
+ {backgroundTasks.map((item) => { + const status = getTaskStatus(item) + return ( + + ) + })} +
+
+ ) +}) + +BackgroundTasksList.displayName = "BackgroundTasksList" + +export default BackgroundTasksList diff --git a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx deleted file mode 100644 index 1830e11866..0000000000 --- a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React, { useState, useCallback, 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 [confirmingCancel, setConfirmingCancel] = useState(false) - const isRunning = task.status === "running" - - const handleCancelClick = useCallback(() => { - if (!confirmingCancel) { - setConfirmingCancel(true) - // Auto-reset after 3 seconds if user doesn't confirm - setTimeout(() => setConfirmingCancel(false), 3000) - return - } - // Second click confirms cancellation - setConfirmingCancel(false) - vscode.postMessage({ type: "cancelBackgroundTask", taskId: task.taskId }) - }, [confirmingCancel, 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. - * - * Phase 6+ evolution notes: - * - This panel can be promoted to a tab-based view alongside the main chat - * by extracting the task list into a shared component and rendering it in - * both the sidebar panel and a dedicated "Background Tasks" tab. - * - For real-time progress streaming, each BackgroundTaskItem could accept - * a `progressMessages` prop with the last N tool-call summaries. - * - For conversation replay, clicking a completed task could open its full - * message history in a read-only chat view (reuse ChatView with a - * `readOnly` flag and the task's clineMessages). - */ -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 76d3dc4b5f..e14b914c4b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1674,7 +1674,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction - {areButtonsVisible && (
({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +function simulateBackgroundTaskProgress(taskId: string, update: Record) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskProgress", + backgroundTaskId: taskId, + backgroundTaskProgress: update, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskLiveView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("subscribes to background task on mount and unsubscribes on unmount", () => { + const { unmount } = render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "subscribeToBackgroundTask", + text: "task-123", + }) + + unmount() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "unsubscribeFromBackgroundTask", + }) + }) + + it("shows empty state initially", () => { + render() + + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + expect(screen.getByText(/Waiting for updates/)).toBeTruthy() + }) + + it("renders progress updates when received", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(1) + }) + + expect(screen.getByText(/read_file -- started/)).toBeTruthy() + }) + + it("shows update count in header", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + simulateBackgroundTaskProgress("task-123", { + kind: "tool_result", + timestamp: Date.now(), + toolName: "read_file", + status: "completed", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/2 updates/)).toBeTruthy() + }) + }) + + it("ignores progress updates for different task IDs", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-different", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + // Should still show empty state + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + // Send an update so the view renders fully + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + expect(screen.getByTestId("live-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("live-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("displays error updates with error message", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "error", + timestamp: Date.now(), + toolName: "execute_command", + status: "errored", + errorMessage: "Permission denied", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/execute_command -- errored: Permission denied/)).toBeTruthy() + }) + }) + + it("caps updates at the rolling window size of 20", async () => { + render() + + act(() => { + for (let i = 0; i < 25; i++) { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now() + i, + toolName: `tool_${i}`, + status: "started", + }) + } + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(20) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx new file mode 100644 index 0000000000..0e9b2a19f4 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx @@ -0,0 +1,147 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx + +import React from "react" +import { render, screen, act, waitFor } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import BackgroundTaskReplayView from "../BackgroundTaskReplayView" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ChatRow to avoid pulling in heavy dependencies +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) { + return
{message.text ?? "message"}
+ }, +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn().mockReturnValue({ + clineMessages: [], + mcpServers: [], + mode: "code", + apiConfiguration: {}, + currentTaskItem: null, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +function simulateBackgroundTaskMessages(taskId: string, messages: any[]) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskReplayView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("requests messages on mount", () => { + render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + }) + + it("shows loading state initially", () => { + render() + + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("renders messages when received from extension", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ]) + }) + + await waitFor(() => { + const rows = screen.getAllByTestId("chat-row") + expect(rows).toHaveLength(2) + }) + }) + + it("shows empty state when task has no messages", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-empty", []) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-empty-state")).toBeTruthy() + }) + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [{ ts: 1000, type: "say", say: "text", text: "Hello" }]) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("replay-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("ignores messages for a different task ID", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-different", [ + { ts: 1000, type: "say", say: "text", text: "Wrong task" }, + ]) + }) + + // Should still show loading since the task ID didn't match + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("shows message count in header after loading", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Msg 1" }, + { ts: 2000, type: "say", say: "text", text: "Msg 2" }, + { ts: 3000, type: "say", say: "text", text: "Msg 3" }, + ]) + }) + + await waitFor(() => { + expect(screen.getByText(/3 messages/)).toBeTruthy() + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx new file mode 100644 index 0000000000..fbb166d438 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx @@ -0,0 +1,176 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskView.spec.tsx + +import React from "react" +import { render, screen, fireEvent, act } from "@/utils/test-utils" + +import BackgroundTaskView from "../BackgroundTaskView" + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn().mockReturnValue({ + taskHistory: [ + { + id: "bg-task-1", + number: 1, + ts: Date.now() - 60000, + task: "Research API docs", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.001, + parentTaskId: "parent-1", + status: "completed", + mode: "ask", + }, + { + id: "bg-task-2", + number: 2, + ts: Date.now(), + task: "Implement feature", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.002, + parentTaskId: "parent-1", + status: "active", + mode: "code", + }, + ], + currentTaskItem: null, + clineMessages: [], + mcpServers: [], + mode: "code", + apiConfiguration: {}, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// Mock ChatRow for BackgroundTaskReplayView +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) { + return
{message.text ?? "message"}
+ }, +})) + +// Mock BackgroundTaskLiveView +vi.mock("../BackgroundTaskLiveView", () => ({ + default: function MockBackgroundTaskLiveView({ taskId, onClose }: { taskId: string; onClose: () => void }) { + return ( +
+ + Live view for {taskId} +
+ ) + }, +})) + +describe("BackgroundTaskView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the list view by default", () => { + render() + + expect(screen.getByTestId("background-task-view")).toBeTruthy() + expect(screen.getByTestId("background-task-view-header")).toBeTruthy() + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + expect(screen.getByText("Background Tasks")).toBeTruthy() + }) + + it("calls onClose when back-to-chat button is clicked", () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId("background-task-view-back")) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it("navigates to replay view when a task is clicked", () => { + render() + + // Click on a task to open replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + + // Should now show the replay view (in loading state), not the list + expect(screen.getByTestId("replay-loading")).toBeTruthy() + expect(screen.queryByTestId("background-tasks-list")).toBeNull() + }) + + it("navigates back to list from replay view via back button", () => { + render() + + // Navigate to replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + expect(screen.getByTestId("replay-loading")).toBeTruthy() + + // Simulate messages arriving so replay-back-button appears + act(() => { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskMessages", + backgroundTaskId: "bg-task-1", + backgroundTaskMessages: [{ ts: 1000, type: "say", say: "text", text: "Hello" }], + }, + }) + window.dispatchEvent(event) + }) + + // Click back button in replay view + fireEvent.click(screen.getByTestId("replay-back-button")) + + // Should return to list view + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + }) + + it("hides the top header when in replay view (replay has its own header)", () => { + render() + + // Header should be visible in list view + expect(screen.getByTestId("background-task-view-header")).toBeTruthy() + + // Navigate to replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + + // Top header should be hidden -- replay has its own back button + expect(screen.queryByTestId("background-task-view-header")).toBeNull() + }) + + it("navigates to live view when an active task is clicked", () => { + render() + + // Click on the active task (bg-task-2 has status "active") + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + + // Should show the live view, not the replay view + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + expect(screen.queryByTestId("replay-loading")).toBeNull() + expect(screen.queryByTestId("background-tasks-list")).toBeNull() + }) + + it("navigates back to list from live view via back button", () => { + render() + + // Navigate to live view for active task + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + + // Click back button in live view + fireEvent.click(screen.getByTestId("live-back-button")) + + // Should return to list view + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx new file mode 100644 index 0000000000..2778a8d6d7 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx @@ -0,0 +1,153 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTasksList.spec.tsx + +import React from "react" +import { render, screen, fireEvent } from "@/utils/test-utils" + +import BackgroundTasksList from "../BackgroundTasksList" + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +const mockUseExtensionState = vi.fn() + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: (...args: any[]) => mockUseExtensionState(...args), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +function createHistoryItem(overrides: Record = {}) { + return { + id: "task-1", + number: 1, + ts: Date.now(), + task: "Test background task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.001, + parentTaskId: "parent-1", + status: "completed" as const, + mode: "code", + ...overrides, + } +} + +describe("BackgroundTasksList", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ + taskHistory: [], + currentTaskItem: null, + }) + }) + + it("shows empty state when no background tasks exist", () => { + render() + + expect(screen.getByTestId("background-tasks-empty")).toBeTruthy() + expect(screen.getByText(/No background tasks yet/)).toBeTruthy() + }) + + it("shows tasks that have a parentTaskId", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", task: "Background task one", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-2", task: "Foreground task (no parent)", parentTaskId: undefined }), + createHistoryItem({ id: "task-3", task: "Background task two", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy() + expect(screen.getByTestId("background-task-item-task-3")).toBeTruthy() + // Foreground task without parentTaskId should NOT appear + expect(screen.queryByTestId("background-task-item-task-2")).toBeNull() + }) + + it("excludes the current foreground task from the list", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", task: "Background subtask", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-current", task: "Current task", parentTaskId: "parent-1" }), + ], + currentTaskItem: { id: "task-current" }, + }) + + render() + + expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy() + expect(screen.queryByTestId("background-task-item-task-current")).toBeNull() + }) + + it("calls onSelectTask when a task item is clicked", () => { + const onSelectTask = vi.fn() + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", task: "Click me", parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + fireEvent.click(screen.getByTestId("background-task-item-task-1")) + expect(onSelectTask).toHaveBeenCalledWith("task-1") + }) + + it("shows task status badges", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-active", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-done", status: "completed", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("Running")).toBeTruthy() + expect(screen.getByText("Completed")).toBeTruthy() + }) + + it("shows active count in summary header", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-2", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-3", status: "completed", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("2 active")).toBeTruthy() + expect(screen.getByText("3 total")).toBeTruthy() + }) + + it("shows mode badge when task has a mode", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", mode: "architect", parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("architect")).toBeTruthy() + }) + + it("truncates long task descriptions", () => { + const longTask = "A".repeat(100) + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", task: longTask, parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + // Should be truncated at 80 chars + "..." + expect(screen.getByText("A".repeat(80) + "...")).toBeTruthy() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx deleted file mode 100644 index 45d370ad09..0000000000 --- a/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react" - -import { vscode } from "@src/utils/vscode" - -import type { BackgroundTaskStatusInfo } from "@roo-code/types" - -// Mock vscode -vi.mock("@src/utils/vscode", () => ({ - 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 require two clicks to cancel (confirmation pattern)", () => { - mockBackgroundTasks.push({ - taskId: "task-cancel-me", - parentTaskId: "parent-1", - status: "running", - startedAt: Date.now(), - }) - - render() - const cancelButton = screen.getByTitle("Cancel background task") - - // First click shows confirmation text, does NOT send message - fireEvent.click(cancelButton) - expect(vscode.postMessage).not.toHaveBeenCalled() - expect(screen.getByText("Cancel?")).toBeDefined() - - // Second click confirms and sends the cancel message - const confirmButton = screen.getByTitle("Click again to confirm cancellation") - fireEvent.click(confirmButton) - 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() - }) -})