feat: Phase 4+5+6 - Background Concurrency, Panel UI, Task Visibility (#12330)

Phase 4: Background Read-Only Concurrency
Phase 5: Background Tasks Panel UI
Phase 6a: Conversation Replay for background tasks
Phase 6b: Tab/Panel Switching for background tasks
Phase 6c: Real-time Progress Streaming
This commit is contained in:
Roo Code 2026-05-12 14:42:02 +00:00
parent c5e405deb9
commit 35bed0c1b6
30 changed files with 1903 additions and 1344 deletions

View file

@ -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.

View file

@ -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

View file

@ -46,6 +46,7 @@ export const commandIds = [
"acceptInput",
"focusPanel",
"toggleAutoApprove",
"backgroundTasksButtonClicked",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -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")

View file

@ -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)

View file

@ -49,7 +49,7 @@ export default {
description: BACKGROUND_PARAMETER_DESCRIPTION,
},
},
required: ["mode", "message", "todos", "background"],
required: ["mode", "message", "todos"],
additionalProperties: false,
},
},

View file

@ -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<typeof setTimeout>
}
/**
* 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<string, BackgroundTaskInfo> = 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<void> {
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<void> {
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<void> {
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<void> {
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)
}
}
}

View file

@ -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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
}
}
// --- Phase 6c: Background task progress streaming ---
private backgroundProgressBuffer: BackgroundTaskUpdate[] = []
private backgroundProgressTimer: ReturnType<typeof setTimeout> | 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<string, number> = {
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 {

View file

@ -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")
})
})
})

View file

@ -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)

View file

@ -29,8 +29,8 @@ export class NewTaskTool extends BaseTool<"new_task"> {
async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
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,

View file

@ -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<typeof setTimeout> | null = null
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
private pendingOperations: Map<string, PendingEditOperation> = 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<boolean>("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<Task> {
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<void> {
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.
*/

View file

@ -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()
})
})

View file

@ -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()
})
})

View file

@ -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()

View file

@ -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"
}
]
},

View file

@ -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<Pick<Record<ToolParamName, string>, "mode" | "message" | "todos" | "task_queue">>
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message" | "todos" | "permissions">>
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message" | "todos" | "background">>
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message" | "todos">>
}
export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> {

View file

@ -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<Record<NonNullable<ExtensionMessage["action"]
settingsButtonClicked: "settings",
historyButtonClicked: "history",
cloudButtonClicked: "cloud",
backgroundTasksButtonClicked: "bgTask",
}
const App = () => {
@ -61,6 +65,7 @@ const App = () => {
const [showAnnouncement, setShowAnnouncement] = useState(false)
const [tab, setTab] = useState<Tab>("chat")
const [replayTaskId, setReplayTaskId] = useState<string | null>(null)
const [deleteMessageDialogState, setDeleteMessageDialogState] = useState<DeleteMessageDialogState>({
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 = () => {
<WelcomeView />
) : (
<>
{tab === "bgTaskReplay" && replayTaskId && (
<BackgroundTaskReplayView
taskId={replayTaskId}
onClose={() => {
setReplayTaskId(null)
switchTab("chat")
}}
/>
)}
{tab === "bgTask" && <BackgroundTaskView onClose={() => switchTab("chat")} />}
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
{tab === "settings" && (
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />

View file

@ -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 (
<div data-testid="background-task-view" onClick={onClose}>
Background Task View
</div>
)
},
}))
vi.mock("@src/components/chat/BackgroundTaskReplayView", () => ({
__esModule: true,
default: function BackgroundTaskReplayView() {
return <div data-testid="background-task-replay-view">Background Task Replay View</div>
},
}))
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(<AppWithProviders />)
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(<AppWithProviders />)
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(<AppWithProviders />)

View file

@ -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 <AlertCircle size={14} className="text-vscode-errorForeground" />
}
if (update.status === "started") {
return <Play size={14} className="text-vscode-charts-green" />
}
if (update.status === "completed") {
return <CheckCircle2 size={14} className="text-vscode-descriptionForeground" />
}
return <Loader2 size={14} className="animate-spin text-vscode-descriptionForeground" />
}
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<BackgroundTaskUpdate[]>([])
const scrollRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full" data-testid="background-task-live-view">
{/* Header */}
<div
className="flex items-center gap-2 px-4 py-2 border-b"
style={{
borderColor: "var(--vscode-panel-border)",
backgroundColor: "var(--vscode-sideBar-background)",
}}>
<button
onClick={onClose}
className="flex items-center gap-1 text-vscode-textLink-foreground hover:underline cursor-pointer bg-transparent border-none p-0"
data-testid="live-back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
<span className="text-vscode-descriptionForeground text-sm ml-2">
Live progress &middot; {updates.length} updates
</span>
<Loader2 size={14} className="animate-spin text-vscode-charts-green ml-auto" />
</div>
{/* Update list */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
style={{ padding: "8px 16px" }}
data-testid="live-update-list">
{updates.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full" data-testid="live-empty-state">
<Loader2 size={24} className="animate-spin text-vscode-descriptionForeground" />
<p className="text-vscode-descriptionForeground text-sm mt-2">
Waiting for updates from background task...
</p>
</div>
) : (
<div className="flex flex-col gap-1">
{updates.map((update, index) => (
<div
key={`${update.timestamp}-${index}`}
className="flex items-center gap-2 py-1 text-sm"
data-testid="live-update-item">
{getUpdateIcon(update)}
<span className="text-vscode-foreground">{formatUpdateLabel(update)}</span>
<span className="text-vscode-descriptionForeground text-xs ml-auto">
{new Date(update.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</div>
</div>
)
})
BackgroundTaskLiveView.displayName = "BackgroundTaskLiveView"
export default BackgroundTaskLiveView

View file

@ -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<ClineMessage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedMessages, setExpandedMessages] = useState<Set<number>>(new Set())
const scrollContainerRef = useRef<HTMLDivElement>(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 (
<div
className="flex flex-col items-center justify-center h-full"
style={{ padding: "20px" }}
data-testid="replay-loading">
<Loader2 className="animate-spin" size={24} />
<p className="text-vscode-descriptionForeground mt-2">Loading task messages...</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full" style={{ padding: "20px" }}>
<p className="text-vscode-errorForeground">{error}</p>
<button
className="mt-2 text-vscode-textLink-foreground hover:underline cursor-pointer"
onClick={onClose}>
Go back
</button>
</div>
)
}
return (
<div className="flex flex-col h-full" data-testid="background-task-replay-view">
{/* Header bar */}
<div
className="flex items-center gap-2 px-4 py-2 border-b"
style={{
borderColor: "var(--vscode-panel-border)",
backgroundColor: "var(--vscode-sideBar-background)",
}}>
<button
onClick={onClose}
className="flex items-center gap-1 text-vscode-textLink-foreground hover:underline cursor-pointer bg-transparent border-none p-0"
data-testid="replay-back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
<span className="text-vscode-descriptionForeground text-sm ml-2">
Task replay (read-only) &middot; {messages.length} messages
</span>
</div>
{/* Message list */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto"
style={{ padding: "0 20px" }}
data-testid="replay-message-list">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-vscode-descriptionForeground" data-testid="replay-empty-state">
No messages found for this task.
</p>
</div>
) : (
messages.map((msg, index) => (
<ChatRow
key={msg.ts}
message={msg}
isExpanded={expandedMessages.has(msg.ts)}
isLast={index === messages.length - 1}
isStreaming={false}
onToggleExpand={handleToggleExpand}
onHeightChange={() => {}}
/>
))
)}
</div>
</div>
)
})
BackgroundTaskReplayView.displayName = "BackgroundTaskReplayView"
export default BackgroundTaskReplayView

View file

@ -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<BackgroundTaskSubView>("list")
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(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 (
<div className="flex flex-col h-full" data-testid="background-task-view">
{/* Top header bar -- only shown in list view since replay has its own header */}
{subView === "list" && (
<div
className="flex items-center gap-2 px-4 py-2 border-b"
style={{
borderColor: "var(--vscode-panel-border)",
backgroundColor: "var(--vscode-sideBar-background)",
}}
data-testid="background-task-view-header">
<button
onClick={onClose}
className="flex items-center gap-1 text-vscode-textLink-foreground hover:underline cursor-pointer bg-transparent border-none p-0"
data-testid="background-task-view-back">
<ArrowLeft size={16} />
<span>Back to chat</span>
</button>
<span className="text-vscode-descriptionForeground text-sm ml-2 font-medium">Background Tasks</span>
</div>
)}
{/* Sub-view content */}
<div className="flex-1 overflow-hidden">
{subView === "list" && <BackgroundTasksList onSelectTask={handleSelectTask} />}
{subView === "replay" && selectedTaskId && (
<BackgroundTaskReplayView taskId={selectedTaskId} onClose={handleBackToList} />
)}
{subView === "live" && selectedTaskId && (
<BackgroundTaskLiveView taskId={selectedTaskId} onClose={handleBackToList} />
)}
</div>
</div>
)
})
BackgroundTaskView.displayName = "BackgroundTaskView"
export default BackgroundTaskView

View file

@ -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 <Play size={14} className="text-vscode-charts-green" />
case "completed":
return <CheckCircle2 size={14} className="text-vscode-descriptionForeground" />
case "delegated":
return <Clock size={14} className="text-vscode-charts-yellow" />
default:
return <AlertCircle size={14} className="text-vscode-descriptionForeground" />
}
}
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 (
<div
className="flex flex-col items-center justify-center h-full"
style={{ padding: "40px 20px" }}
data-testid="background-tasks-empty">
<p className="text-vscode-descriptionForeground text-sm text-center">No background tasks yet.</p>
<p className="text-vscode-descriptionForeground text-xs text-center mt-2">
Background tasks will appear here when subtasks are spawned via the new_task tool.
</p>
</div>
)
}
return (
<div className="flex flex-col h-full" data-testid="background-tasks-list">
{/* Summary header */}
<div
className="flex items-center gap-2 px-4 py-2 text-xs text-vscode-descriptionForeground border-b"
style={{ borderColor: "var(--vscode-panel-border)" }}>
{activeTasks.length > 0 && (
<span className="flex items-center gap-1">
<Play size={12} className="text-vscode-charts-green" />
{activeTasks.length} active
</span>
)}
<span>{backgroundTasks.length} total</span>
</div>
{/* Task list */}
<div className="flex-1 overflow-y-auto">
{backgroundTasks.map((item) => {
const status = getTaskStatus(item)
return (
<button
key={item.id}
className="w-full text-left px-4 py-3 border-b cursor-pointer bg-transparent hover:bg-vscode-list-hoverBackground transition-colors"
style={{
borderColor: "var(--vscode-panel-border)",
color: "var(--vscode-foreground)",
}}
onClick={() => onSelectTask(item.id)}
data-testid={`background-task-item-${item.id}`}>
<div className="flex items-center gap-2 mb-1">
{getStatusIcon(status)}
<span className="text-xs font-medium">{getStatusLabel(status)}</span>
{item.mode && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: "var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
}}>
{item.mode}
</span>
)}
<span className="text-xs text-vscode-descriptionForeground ml-auto">
{formatTimestamp(item.ts)}
</span>
</div>
<div className="text-sm">{truncateTask(item.task)}</div>
{item.totalCost > 0 && (
<div className="text-xs text-vscode-descriptionForeground mt-1">
Cost: ${item.totalCost.toFixed(4)}
</div>
)}
</button>
)
})}
</div>
</div>
)
})
BackgroundTasksList.displayName = "BackgroundTasksList"
export default BackgroundTasksList

View file

@ -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 (
<div className="flex flex-col border border-vscode-panel-border rounded px-2 py-1.5 mb-1 last:mb-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span
className={`codicon ${getStatusIcon(task.status)} ${getStatusColor(task.status)} flex-shrink-0`}
/>
<span className="text-xs text-vscode-foreground truncate" title={task.taskId}>
{shortId}
</span>
<span className="text-xs text-vscode-descriptionForeground flex-shrink-0">
{formatElapsed(task.startedAt, task.completedAt)}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{task.resultSummary && !isRunning && (
<button
className="text-xs text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground cursor-pointer bg-transparent border-none p-0"
onClick={() => setShowResult(!showResult)}
title="Toggle result">
{showResult ? "Hide" : "Result"}
</button>
)}
{isRunning && (
<button
className={`text-xs cursor-pointer bg-transparent border-none p-0 flex items-center gap-0.5 ${
confirmingCancel
? "text-vscode-errorForeground font-medium"
: "text-vscode-errorForeground hover:opacity-80"
}`}
onClick={handleCancelClick}
title={confirmingCancel ? "Click again to confirm cancellation" : "Cancel background task"}>
{confirmingCancel ? (
<span>Cancel?</span>
) : (
<span className="codicon codicon-stop-circle text-xs" />
)}
</button>
)}
</div>
</div>
{showResult && task.resultSummary && (
<div className="mt-1 text-xs text-vscode-descriptionForeground bg-vscode-editor-background rounded p-1.5 max-h-24 overflow-y-auto whitespace-pre-wrap break-words">
{task.resultSummary.length > 500 ? task.resultSummary.slice(0, 500) + "..." : task.resultSummary}
</div>
)}
</div>
)
}
/**
* 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 (
<div className="border-t border-vscode-panel-border">
<button
className="flex items-center justify-between w-full px-3 py-1.5 bg-transparent border-none cursor-pointer hover:bg-vscode-list-hoverBackground"
onClick={() => setIsCollapsed(!isCollapsed)}>
<div className="flex items-center gap-1.5">
<span
className={`codicon ${isCollapsed ? "codicon-chevron-right" : "codicon-chevron-down"} text-xs`}
/>
<span className="text-xs font-medium text-vscode-foreground">Background Tasks</span>
{activeCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-vscode-badge-background text-vscode-badge-foreground">
{activeCount}
</span>
)}
</div>
<span className="text-[10px] text-vscode-descriptionForeground">{tasks.length} total</span>
</button>
{!isCollapsed && (
<div className="px-2 pb-1.5 max-h-[200px] overflow-y-auto">
{tasks.map((task) => (
<BackgroundTaskItem key={task.taskId} task={task} />
))}
</div>
)}
</div>
)
}
export default BackgroundTasksPanel

View file

@ -1674,7 +1674,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
/>
</div>
<FileChangesPanel clineMessages={messages} />
<BackgroundTasksPanel />
{areButtonsVisible && (
<div
className={`flex h-9 items-center mb-1 px-[15px] ${

View file

@ -0,0 +1,181 @@
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx
import React from "react"
import { render, screen, act, waitFor } from "@/utils/test-utils"
import { vscode } from "@src/utils/vscode"
import BackgroundTaskLiveView from "../BackgroundTaskLiveView"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock use-sound
vi.mock("use-sound", () => ({
default: vi.fn().mockImplementation(() => [vi.fn()]),
}))
function simulateBackgroundTaskProgress(taskId: string, update: Record<string, unknown>) {
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(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "subscribeToBackgroundTask",
text: "task-123",
})
unmount()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "unsubscribeFromBackgroundTask",
})
})
it("shows empty state initially", () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
expect(screen.getByTestId("live-empty-state")).toBeTruthy()
expect(screen.getByText(/Waiting for updates/)).toBeTruthy()
})
it("renders progress updates when received", async () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskLiveView taskId="task-123" onClose={onClose} />)
// 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(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
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)
})
})
})

View file

@ -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 <div data-testid="chat-row">{message.text ?? "message"}</div>
},
}))
// 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(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "requestBackgroundTaskMessages",
text: "task-123",
})
})
it("shows loading state initially", () => {
render(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
expect(screen.getByTestId("replay-loading")).toBeTruthy()
})
it("renders messages when received from extension", async () => {
render(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskReplayView taskId="task-empty" onClose={vi.fn()} />)
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(<BackgroundTaskReplayView taskId="task-123" onClose={onClose} />)
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(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
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()
})
})
})

View file

@ -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 <div data-testid="chat-row">{message.text ?? "message"}</div>
},
}))
// Mock BackgroundTaskLiveView
vi.mock("../BackgroundTaskLiveView", () => ({
default: function MockBackgroundTaskLiveView({ taskId, onClose }: { taskId: string; onClose: () => void }) {
return (
<div data-testid="background-task-live-view">
<button data-testid="live-back-button" onClick={onClose}>
Back
</button>
<span>Live view for {taskId}</span>
</div>
)
},
}))
describe("BackgroundTaskView", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders the list view by default", () => {
render(<BackgroundTaskView onClose={vi.fn()} />)
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(<BackgroundTaskView onClose={onClose} />)
fireEvent.click(screen.getByTestId("background-task-view-back"))
expect(onClose).toHaveBeenCalledTimes(1)
})
it("navigates to replay view when a task is clicked", () => {
render(<BackgroundTaskView onClose={vi.fn()} />)
// 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(<BackgroundTaskView onClose={vi.fn()} />)
// 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(<BackgroundTaskView onClose={vi.fn()} />)
// 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(<BackgroundTaskView onClose={vi.fn()} />)
// 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(<BackgroundTaskView onClose={vi.fn()} />)
// 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()
})
})

View file

@ -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<string, any> = {}) {
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(<BackgroundTasksList onSelectTask={vi.fn()} />)
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(<BackgroundTasksList onSelectTask={vi.fn()} />)
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(<BackgroundTasksList onSelectTask={vi.fn()} />)
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(<BackgroundTasksList onSelectTask={onSelectTask} />)
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(<BackgroundTasksList onSelectTask={vi.fn()} />)
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(<BackgroundTasksList onSelectTask={vi.fn()} />)
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(<BackgroundTasksList onSelectTask={vi.fn()} />)
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(<BackgroundTasksList onSelectTask={vi.fn()} />)
// Should be truncated at 80 chars + "..."
expect(screen.getByText("A".repeat(80) + "...")).toBeTruthy()
})
})

View file

@ -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(<BackgroundTasksPanel />)
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(<BackgroundTasksPanel />)
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(<BackgroundTasksPanel />)
// 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(<BackgroundTasksPanel />)
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(<BackgroundTasksPanel />)
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(<BackgroundTasksPanel />)
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(<BackgroundTasksPanel />)
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()
})
})