diff --git a/docs/architecture/phase-6-background-task-visibility.md b/docs/architecture/phase-6-background-task-visibility.md new file mode 100644 index 0000000000..7f9ecb420b --- /dev/null +++ b/docs/architecture/phase-6-background-task-visibility.md @@ -0,0 +1,327 @@ +# Phase 6: Background Task Visibility and Interaction + +> Architectural design document for Issue #12330 +> Phase 6 of "Support parallel execution of specialized agents and improve context handoff between modes" + +## 1. Context + +Phase 5 (Background Tasks Panel UI) is complete. This document proposes the scope, priority, and architecture for Phase 6, which focuses on enabling better visibility and interaction with background tasks. + +## 2. Current Architecture + +### Task Lifecycle + +`ClineProvider` maintains a `clineStack: Task[]` (LIFO). Only the top-of-stack task is "current" -- all state updates, webview messages, and user interactions route through `getCurrentTask()`. + +``` +ClineProvider +├── clineStack: Task[] # LIFO stack, sequential execution +├── taskHistoryStore # Per-task file persistence +├── getCurrentTask() # Returns top of stack +├── addClineToStack(task) # Push new task +└── removeClineFromStack() # Pop completed task +``` + +### Task Persistence + +| Layer | File | Purpose | +|-------|------|---------| +| Messages | `taskMessages.ts` | Save/load `ClineMessage[]` per task | +| API History | `apiMessages.ts` | Save/load API conversation history | +| History Items | `TaskHistoryStore.ts` | Per-task metadata files with in-memory cache | +| Metadata | `taskMetadata.ts` | Task metadata helpers | + +### Webview Communication + +The extension sends typed `ExtensionMessage` objects to the webview. Key message types: + +- `state` -- Full state snapshot (includes `clineMessages`, `currentTaskId`) +- `taskHistoryUpdated` -- Full history list refresh +- `taskHistoryItemUpdated` -- Single history item update + +Currently, `postStateToWebviewWithoutTaskHistory()` sends state for only the current task. There is no mechanism to send updates for background tasks. + +### Subtask Support + +Parent-child relationships exist via `parentTaskId` and `childIds` on `HistoryItem`. The `new_task` tool creates subtasks that push onto the stack. When a subtask completes, it pops and returns control to the parent. + +## 3. Agreed Scope for Phase 6 + +**In scope (Items 1-3):** +1. Full conversation replay for completed background tasks +2. Tab switching / multi-task view +3. Real-time progress streaming for active background tasks + +**Deferred to Phase 7 (Items 4-5):** +4. Write-capable background tasks + basic file locking +5. Persistent background task history across sessions + +## 4. Feasibility Analysis + +### Item 1: Full Conversation Replay + +**Complexity: Medium | Risk: Low** + +`readTaskMessages(taskId, globalStoragePath)` already loads the full `ClineMessage[]` array from disk for any task. The existing `ChatView` component renders these messages. The main work is creating a read-only wrapper that: + +- Accepts a `taskId` prop instead of reading from global state +- Loads messages on mount via a new webview message +- Hides input controls (chat box, approval buttons) +- Renders tool calls, outputs, and assistant responses in the same format + +**Why it's low risk:** No changes to task execution, persistence, or the foreground task flow. Purely additive UI + a new message handler. + +### Item 2: Tab Switching / Multi-task View + +**Complexity: Medium-High | Risk: Medium** + +The webview already has a tab system in `App.tsx` (`tab === "history"`, `tab === "settings"`, `tab === "chat"`). Adding a background tasks view requires: + +- A new tab or panel within the chat view +- A list of active/completed background tasks with status indicators +- Navigation to open a task's replay view or live view +- State management to track which background task is currently being viewed + +**Key challenge:** The webview currently receives state for only one task. Viewing a background task must not disrupt the foreground task's state. This requires either: + - (a) A separate message channel for background task data, or + - (b) A secondary state context in the webview that can hold background task data alongside the primary task state + +Option (a) is cleaner and avoids polluting the existing state management. + +### Item 3: Real-time Progress Streaming + +**Complexity: High | Risk: Medium-High** + +Currently, `Task.ts` calls `provider.postStateToWebviewWithoutTaskHistory()` to update the UI. This method sends the full state for the current task only. For background tasks to stream progress: + +1. `Task.ts` must emit incremental updates even when it is not the "current" task +2. A new message type (`backgroundTaskProgress`) must carry task-scoped updates +3. The webview must handle concurrent update streams without degrading performance +4. Throttling/batching is needed to prevent excessive re-renders + +**Why it's harder:** Requires changes to the core task execution loop (`Task.ts`), not just additive UI. The task currently assumes it IS the visible task when posting updates. + +## 5. Recommended Priority Order + +``` +Phase 6a: Conversation Replay (Foundation -- standalone value) + │ + ▼ +Phase 6b: Tab/Panel Switching (Navigation framework, depends on 6a) + │ + ▼ +Phase 6c: Real-time Progress Streaming (Highest complexity, builds on 6b) +``` + +Each sub-phase is independently shippable and testable. + +## 6. Detailed Design + +### 6a. Conversation Replay + +#### New Message Types + +```typescript +// Webview → Extension +interface RequestBackgroundTaskMessages { + type: "requestBackgroundTaskMessages" + taskId: string +} + +// Extension → Webview +interface BackgroundTaskMessages { + type: "backgroundTaskMessages" + taskId: string + messages: ClineMessage[] +} +``` + +#### Extension Handler (webviewMessageHandler.ts) + +```typescript +case "requestBackgroundTaskMessages": { + const taskId = message.taskId + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages(taskId, globalStoragePath) + provider.postMessageToWebview({ + type: "backgroundTaskMessages", + taskId, + messages: messages ?? [], + }) + break +} +``` + +#### Webview Component + +``` +BackgroundTaskReplayView +├── Props: { taskId: string, onClose: () => void } +├── State: messages (ClineMessage[]), loading (boolean) +├── On mount: sends requestBackgroundTaskMessages +├── On message: receives backgroundTaskMessages, filters by taskId +├── Renders: read-only message list (reuses ChatRow components) +└── No input controls, no approval buttons +``` + +### 6b. Tab/Panel Switching + +#### UI Structure + +A new icon is added to the existing tab bar (alongside chat, history, settings) as the entry point. The background task view occupies the full tab area. + +``` +App.tsx +├── tab === "chat" → ChatView (foreground task) +├── tab === "history" → HistoryView +├── tab === "settings" → SettingsView +└── tab === "bgTask" → BackgroundTaskView + ├── BackgroundTasksList (task list with status badges + error badge on tab icon) + │ ├── Active tasks + │ └── Completed tasks + └── BackgroundTaskReplayView (from 6a) OR BackgroundTaskLiveView (from 6c) +``` + +#### State Management + +```typescript +// New webview state (in App.tsx or dedicated context) +interface BackgroundTaskViewState { + selectedTaskId: string | null + viewMode: "replay" | "live" +} +``` + +#### Navigation Flow + +1. User clicks background tasks icon in the tab bar +2. App switches to `tab === "bgTask"` +3. BackgroundTasksList shows available tasks +4. User clicks a task → sets `selectedTaskId` +5. If task is completed → opens BackgroundTaskReplayView +6. If task is active → opens BackgroundTaskLiveView (Phase 6c) + +### 6c. Real-time Progress Streaming (Minimal Viable Version) + +> **Design principle:** Keep Phase 6c tightly scoped to avoid expanding the phase. Ship the simplest useful version first; richer detail can be added incrementally in later phases. + +#### MVP Scope + +The minimal viable version streams only: +- **Tool name + status** (started / completed / errored) -- not full parameters or output +- **Last N updates** (rolling window of ~20 items) -- older entries are discarded client-side +- **Status changes** (running, paused, completed, errored) + +What is explicitly **out of scope** for the MVP: +- Full tool call parameters or output payloads +- Assistant text streaming +- Persistent storage of streamed updates (replay from disk covers completed tasks) + +#### New Message Types + +```typescript +// Extension → Webview (incremental updates) +interface BackgroundTaskProgress { + type: "backgroundTaskProgress" + taskId: string + update: BackgroundTaskUpdate +} + +interface BackgroundTaskUpdate { + kind: "tool_call" | "tool_result" | "status_change" | "error" + timestamp: number + toolName?: string // e.g. "read_file", "execute_command" + status?: string // e.g. "started", "completed", "errored" + errorMessage?: string // Only for kind === "error" +} +``` + +Note: `assistant_text` is excluded from the MVP. The update interface uses typed optional fields instead of `data: any` to keep the contract narrow and safe. + +#### Task.ts Changes + +Add a method that emits progress regardless of whether the task is "current": + +```typescript +// In Task.ts +private emitBackgroundProgress(update: BackgroundTaskUpdate) { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit background updates when this task is NOT the current task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + taskId: this.taskId, + update, + }) +} +``` + +The hook points in Task.ts should be minimal -- emit at tool call start and tool call end only. Avoid adding hooks inside the LLM streaming loop for the MVP. + +#### Throttling Strategy + +- Batch updates in 500ms windows (conservative default; can be tuned down later) +- Cap at 5 updates per batch per task +- Drop older updates if buffer exceeds threshold (keep last N = 20) +- Priority ordering: status_change > error > tool_result > tool_call + +#### Webview: BackgroundTaskLiveView + +``` +BackgroundTaskLiveView +├── Props: { taskId: string } +├── State: updates (BackgroundTaskUpdate[], capped at last 20), status +├── Subscribes to backgroundTaskProgress messages filtered by taskId +├── Renders: compact list of recent tool calls with status icons +├── Auto-scrolls to latest update +└── Shows task status badge (running, paused, completed, errored) +``` + +The live view intentionally shows a compact summary, not a full chat transcript. Users who want full detail can wait for the task to complete and use the replay view (6a). + +> **Confirmed:** Streaming is scoped to the currently selected background task only. The extension should not emit `backgroundTaskProgress` messages for tasks the user is not viewing. This keeps message traffic low and the implementation simple. + +## 7. Testing Strategy + +| Area | Test Type | Key Scenarios | +|------|-----------|---------------| +| Message handler | Unit (vitest) | Request/response for task messages, missing task, corrupt data | +| BackgroundTaskReplayView | Component (vitest + RTL) | Loading state, message rendering, empty state | +| Tab switching | Component (vitest + RTL) | Tab navigation, state preservation, back to foreground | +| Progress streaming | Unit (vitest) | Throttling, batching, concurrent tasks | +| Integration | E2E (if feasible) | Full flow: start bg task → view progress → replay after completion | + +## 8. Confirmed Decisions + +The following decisions were confirmed during design review and should guide implementation. + +### UI Layout + +1. **Background task view layout: Full tab** (`tab === "bgTask"`) + + Start with a full tab for simplicity in Phase 6. A sidebar/hybrid mode may be considered later based on user feedback. + +2. **Entry point placement: New tab bar icon** + + Add a new icon in the existing tab bar (alongside chat, history, settings). This is the most discoverable location without cluttering the chat view. + +3. **Replay view implementation: Thin wrapper around ChatRow components** + + Create a dedicated `BackgroundTaskReplayView` that wraps `ChatRow` components directly rather than reusing the full `ChatView`. This avoids inheriting input controls, scroll management, and approval button logic that don't apply to read-only replay. + +### Progress Streaming (6c) + +4. **Streaming granularity: Minimal level** + + Stream tool name + status only (started/completed/errored). This provides enough signal to know what the background task is doing without performance risk. Truncated arguments (medium level) can be added in a follow-up if users need more context. + +5. **Streaming scope: Currently selected task only** + + Only stream updates for the background task the user is currently viewing. This avoids unnecessary message traffic and keeps the implementation simple. + +6. **Error surfacing: Badge on the background tasks tab icon** + + Display a badge on the tab icon when a background task encounters an error. Toast notifications can be added later if users miss errors. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 52f07493c1..d47cf2d490 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -16,6 +16,18 @@ import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-lim import type { SkillMetadata } from "./skills.js" import type { WorktreeIncludeStatus } from "./worktree.js" +/** + * Incremental progress update for a background task (Phase 6c). + * MVP: tool name + status only. No full parameters or output payloads. + */ +export interface BackgroundTaskUpdate { + kind: "tool_call" | "tool_result" | "status_change" | "error" + timestamp: number + toolName?: string // e.g. "read_file", "execute_command" + status?: string // e.g. "started", "completed", "errored" + errorMessage?: string // Only for kind === "error" +} + /** * ExtensionMessage * Extension -> Webview | CLI @@ -94,6 +106,8 @@ export interface ExtensionMessage { | "folderSelected" | "skills" | "fileContent" + | "backgroundTaskMessages" + | "backgroundTaskProgress" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -107,6 +121,7 @@ export interface ExtensionMessage { | "settingsButtonClicked" | "historyButtonClicked" | "cloudButtonClicked" + | "backgroundTasksButtonClicked" | "didBecomeVisible" | "focusInput" | "switchTab" @@ -166,6 +181,9 @@ export interface ExtensionMessage { tools?: SerializedCustomToolDefinition[] // For customToolsResult skills?: SkillMetadata[] // For skills response modes?: { slug: string; name: string }[] // For modes response + backgroundTaskMessages?: ClineMessage[] // For backgroundTaskMessages: loaded messages for a background task replay + backgroundTaskId?: string // For backgroundTaskMessages: the task ID these messages belong to + backgroundTaskProgress?: BackgroundTaskUpdate // For backgroundTaskProgress: incremental update for a background task aggregatedCosts?: { // For taskWithAggregatedCosts response totalCost: number @@ -541,6 +559,10 @@ export interface WebviewMessage { | "createWorktreeInclude" | "checkoutBranch" | "browseForWorktreePath" + // Background task messages + | "requestBackgroundTaskMessages" + | "subscribeToBackgroundTask" + | "unsubscribeFromBackgroundTask" // Skills messages | "requestSkills" | "createSkill" @@ -552,6 +574,7 @@ export interface WebviewMessage { taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "cloud" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay" | "bgTask" disabled?: boolean context?: string dataUri?: string diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 7ea5377888..b709e710ae 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -46,6 +46,7 @@ export const commandIds = [ "acceptInput", "focusPanel", "toggleAutoApprove", + "backgroundTasksButtonClicked", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 44de751bcb..6e794fb75b 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -117,6 +117,15 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" }) }, + backgroundTasksButtonClicked: () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + visibleProvider.postMessageToWebview({ type: "action", action: "backgroundTasksButtonClicked" }) + }, newTask: handleNewTask, setCustomStoragePath: async () => { const { promptForCustomStoragePath } = await import("../utils/storage") diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index dc0c98c7d1..d3393d0961 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -486,6 +486,14 @@ export async function presentAssistantMessage(cline: Task) { } hasToolResult = true + + // Phase 6c: Emit background progress when a tool completes + cline.emitBackgroundProgress({ + kind: "tool_result", + timestamp: Date.now(), + toolName: block.name, + status: "completed", + }) } const askApproval = async ( @@ -547,6 +555,15 @@ export async function presentAssistantMessage(cline: Task) { `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, ) + // Phase 6c: Emit background progress on error + cline.emitBackgroundProgress({ + kind: "error", + timestamp: Date.now(), + toolName: block.name, + status: "errored", + errorMessage: error.message, + }) + pushToolResult(formatResponse.toolError(errorString)) } @@ -649,6 +666,16 @@ export async function presentAssistantMessage(cline: Task) { } } + // Phase 6c: Emit background progress when a tool starts executing + if (!block.partial) { + cline.emitBackgroundProgress({ + kind: "tool_call", + timestamp: Date.now(), + toolName: block.name, + status: "started", + }) + } + switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index 97a7eb1da2..f846eaf73a 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -14,6 +14,7 @@ const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a mar const TASK_QUEUE_PARAMETER_DESCRIPTION = `Optional JSON array of additional subtasks to execute sequentially after the first subtask completes. Each element is an object with "mode" (string) and "message" (string). Example: [{"mode":"code","message":"Implement feature X"},{"mode":"debug","message":"Test feature X"}]. When provided, the system automatically transitions between subtasks without returning to the parent, collecting all results. The parent receives aggregated results when the entire queue completes.` const PERMISSIONS_PARAMETER_DESCRIPTION = `Optional JSON object defining permission boundaries for the subtask. Allows the parent to restrict the subtask's access. Supports: filePatterns (array of regex patterns for allowed file paths), commandPatterns (array of regex patterns for allowed commands), allowedTools (array of tool names the subtask may use), deniedTools (array of tool names the subtask may NOT use). Example: {"filePatterns":["src/components/.*"],"commandPatterns":["npm test.*"],"deniedTools":["execute_command"]}` +const BACKGROUND_PARAMETER_DESCRIPTION = `When set to "true", the task runs in the background concurrently with the current task. Background tasks are restricted to read-only tools only (read_file, list_files, search_files, codebase_search). Results are delivered asynchronously when the background task completes. Use for research, analysis, or documentation lookup while continuing other work.` export default { type: "function", @@ -43,6 +44,9 @@ export default { permissions: { type: ["string", "null"], description: PERMISSIONS_PARAMETER_DESCRIPTION, + background: { + type: ["string", "null"], + description: BACKGROUND_PARAMETER_DESCRIPTION, }, }, required: ["mode", "message", "todos"], diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a4d836af8b..812d39ff41 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -30,6 +30,7 @@ import { type ClineSay, type ClineAsk, type ToolProgressStatus, + type BackgroundTaskUpdate, type HistoryItem, type CreateTaskOptions, type ModelInfo, @@ -166,6 +167,10 @@ export interface TaskOptions extends CreateTaskOptions { * If not provided, the task falls back to the existing provider.getState() behavior. */ taskContext?: TaskContext + /** When true, the task runs in the background: webview updates are suppressed and all tool uses are auto-approved. */ + isBackgroundTask?: boolean + /** Callback invoked when a background task completes (via attempt_completion). */ + onBackgroundComplete?: (taskId: string, result: string) => void } export class Task extends EventEmitter implements TaskLike { @@ -455,6 +460,8 @@ export class Task extends EventEmitter implements TaskLike { initialStatus, taskContext, taskPermissions, + isBackgroundTask = false, + onBackgroundComplete, }: TaskOptions) { super() @@ -525,6 +532,8 @@ export class Task extends EventEmitter implements TaskLike { this.taskNumber = taskNumber this.initialStatus = initialStatus this.taskContext = taskContext + this.isBackgroundTask = isBackgroundTask + this.onBackgroundComplete = onBackgroundComplete this.assistantMessageParser = undefined @@ -4582,6 +4591,68 @@ export class Task extends EventEmitter implements TaskLike { } } + // --- Phase 6c: Background task progress streaming --- + + private backgroundProgressBuffer: BackgroundTaskUpdate[] = [] + private backgroundProgressTimer: ReturnType | null = null + private static readonly BACKGROUND_PROGRESS_THROTTLE_MS = 500 + private static readonly BACKGROUND_PROGRESS_MAX_BATCH = 5 + + /** + * Emit a progress update for this task if it is a background task currently + * being viewed by the user. Updates are batched in 500ms windows and capped + * at 5 per batch. + */ + public emitBackgroundProgress(update: BackgroundTaskUpdate): void { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit when this task is NOT the current (foreground) task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + // Only emit when the user is actively viewing this background task + if (provider.viewedBackgroundTaskId !== this.taskId) return + + this.backgroundProgressBuffer.push(update) + + // If no flush is pending, schedule one + if (!this.backgroundProgressTimer) { + this.backgroundProgressTimer = setTimeout(() => { + this.flushBackgroundProgress() + }, Task.BACKGROUND_PROGRESS_THROTTLE_MS) + } + } + + private flushBackgroundProgress(): void { + this.backgroundProgressTimer = null + const provider = this.providerRef.deref() + if (!provider) { + this.backgroundProgressBuffer = [] + return + } + + // Take at most MAX_BATCH items, prioritizing by kind + const priorityOrder: Record = { + status_change: 0, + error: 1, + tool_result: 2, + tool_call: 3, + } + const sorted = this.backgroundProgressBuffer.sort( + (a, b) => (priorityOrder[a.kind] ?? 4) - (priorityOrder[b.kind] ?? 4), + ) + const batch = sorted.slice(0, Task.BACKGROUND_PROGRESS_MAX_BATCH) + this.backgroundProgressBuffer = [] + + for (const update of batch) { + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + backgroundTaskId: this.taskId, + backgroundTaskProgress: update, + }) + } + } + // Getters public get taskStatus(): TaskStatus { diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index af0bfe5b68..e681c88d38 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,10 +1,7 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" -<<<<<<< HEAD import type { SubtaskQueueItem } from "@roo-code/types" -======= ->>>>>>> 6c51a5d52 (fix: three bugs in task permissions - parser, deniedTools exemption, pattern merging) import { type TaskPermissions, taskPermissionsSchema, toTaskPermissions } from "@roo-code/types" import { Task } from "../task/Task" @@ -22,13 +19,17 @@ interface NewTaskParams { todos?: string task_queue?: string permissions?: string + /** When true, the task runs in the background concurrently with the parent. Read-only tools only. */ + background?: string } export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { - const { mode, message, todos, task_queue, permissions: permissionsJson } = params + 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 try { @@ -172,6 +173,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { todos: todoItems, taskQueue: queueItems.length > 0 ? queueItems : undefined, ...(parsedPermissions ? { permissions: parsedPermissions } : {}), + background: isBackground, }) const didApprove = await askApproval("tool", toolMessage) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 68cdbe8ddd..0d86762ad0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -84,6 +84,12 @@ import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { buildTaskContext } from "../task/TaskContextBuilder" +import { BackgroundTaskRunner, BACKGROUND_TASK_ALLOWED_TOOLS } from "../task/BackgroundTaskRunner" +import { + BackgroundTaskRunner, + BACKGROUND_TASK_ALLOWED_TOOLS, + BackgroundTaskRunnerCallbacks, +} from "../task/BackgroundTaskRunner" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem, SubtaskQueueItem, TaskPermissions, ContextHandoffSummary } from "@roo-code/types" @@ -160,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 diff --git a/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts new file mode 100644 index 0000000000..d9e68b79f6 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts @@ -0,0 +1,83 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +vi.mock("../../task-persistence", () => ({ + saveTaskMessages: vi.fn(), + readTaskMessages: vi.fn(), +})) + +import { readTaskMessages } from "../../task-persistence" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), +} as unknown as ClineProvider + +describe("webviewMessageHandler - requestBackgroundTaskMessages", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("loads task messages from disk and posts them to the webview", async () => { + const mockMessages = [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ] + vi.mocked(readTaskMessages).mockResolvedValue(mockMessages as any) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-123", + backgroundTaskMessages: mockMessages, + }) + }) + + it("returns empty array when task has no messages", async () => { + vi.mocked(readTaskMessages).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-empty", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-empty", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-empty", + backgroundTaskMessages: [], + }) + }) + + it("does nothing when taskId is not provided", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + // no text/taskId provided + }) + + expect(readTaskMessages).not.toHaveBeenCalled() + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts new file mode 100644 index 0000000000..a5f8138254 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts @@ -0,0 +1,52 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), + viewedBackgroundTaskId: null as string | null, +} as unknown as ClineProvider + +describe("webviewMessageHandler - background task progress subscription", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(mockClineProvider as any).viewedBackgroundTaskId = null + }) + + it("sets viewedBackgroundTaskId on subscribeToBackgroundTask", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + text: "task-456", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBe("task-456") + }) + + it("clears viewedBackgroundTaskId on unsubscribeFromBackgroundTask", async () => { + ;(mockClineProvider as any).viewedBackgroundTaskId = "task-456" + + await webviewMessageHandler(mockClineProvider, { + type: "unsubscribeFromBackgroundTask", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) + + it("handles subscribeToBackgroundTask with no text gracefully", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + // no text + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 210cb258c6..6783eccfe4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -24,7 +24,7 @@ import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" import { type ApiMessage } from "../task-persistence/apiMessages" -import { saveTaskMessages } from "../task-persistence" +import { saveTaskMessages, readTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" @@ -814,6 +814,28 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We case "showTaskWithId": provider.showTaskWithId(message.text!) break + case "requestBackgroundTaskMessages": { + const taskId = message.text + if (taskId) { + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages({ taskId, globalStoragePath }) + await provider.postMessageToWebview({ + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }) + } + break + } + case "subscribeToBackgroundTask": { + const taskId = message.text + provider.viewedBackgroundTaskId = taskId ?? null + break + } + case "unsubscribeFromBackgroundTask": { + provider.viewedBackgroundTaskId = null + break + } case "condenseTaskContextRequest": provider.condenseTaskContext(message.text!) break diff --git a/src/package.json b/src/package.json index f578357706..fd1e9c5d45 100644 --- a/src/package.json +++ b/src/package.json @@ -164,6 +164,11 @@ "command": "roo-cline.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.backgroundTasksButtonClicked", + "title": "Background Tasks", + "icon": "$(server-process)" } ], "menus": { @@ -229,9 +234,14 @@ "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.backgroundTasksButtonClicked", "group": "overflow@2", "when": "view == roo-cline.SidebarProvider" + }, + { + "command": "roo-cline.popoutButtonClicked", + "group": "overflow@3", + "when": "view == roo-cline.SidebarProvider" } ], "editor/title": [ @@ -256,9 +266,14 @@ "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.backgroundTasksButtonClicked", "group": "overflow@2", "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" + }, + { + "command": "roo-cline.popoutButtonClicked", + "group": "overflow@3", + "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" } ] }, diff --git a/src/shared/tools.ts b/src/shared/tools.ts index c7569e00d2..0179e1c551 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -58,6 +58,7 @@ export const toolParamNames = [ "todos", "task_queue", "permissions", // new_task parameter for subtask permission boundaries + "background", // new_task parameter for background task execution "prompt", "image", // read_file parameters (native protocol) @@ -105,6 +106,8 @@ export type NativeToolArgs = { apply_patch: { patch: string } 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 }> @@ -244,6 +247,8 @@ export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" params: Partial, "mode" | "message" | "todos" | "task_queue">> params: Partial, "mode" | "message" | "todos" | "permissions">> + params: Partial, "mode" | "message" | "todos" | "background">> + params: Partial, "mode" | "message" | "todos">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 8b4eddfa9b..8a1bbe6fca 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -12,6 +12,8 @@ import ChatView, { ChatViewRef } from "./components/chat/ChatView" import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeViewProvider" +import BackgroundTaskReplayView from "./components/chat/BackgroundTaskReplayView" +import BackgroundTaskView from "./components/chat/BackgroundTaskView" import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" @@ -21,6 +23,7 @@ import { TooltipProvider } from "./components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" type Tab = "settings" | "history" | "chat" | "cloud" +type Tab = "settings" | "history" | "chat" | "bgTaskReplay" | "bgTask" interface DeleteMessageDialogState { isOpen: boolean @@ -45,6 +48,7 @@ const tabsByMessageAction: Partial { @@ -61,6 +65,7 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") + const [replayTaskId, setReplayTaskId] = useState(null) const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ isOpen: false, @@ -99,6 +104,10 @@ const App = () => { // Handle switchTab action with tab parameter if (message.action === "switchTab" && message.tab) { const targetTab = message.tab as Tab + // If switching to bgTaskReplay, extract taskId from values + if (targetTab === "bgTaskReplay" && message.values?.taskId) { + setReplayTaskId(message.values.taskId as string) + } switchTab(targetTab) // Extract targetSection from values if provided const targetSection = message.values?.section as string | undefined @@ -185,6 +194,16 @@ const App = () => { ) : ( <> + {tab === "bgTaskReplay" && replayTaskId && ( + { + setReplayTaskId(null) + switchTab("chat") + }} + /> + )} + {tab === "bgTask" && switchTab("chat")} />} {tab === "history" && switchTab("chat")} />} {tab === "settings" && ( setTab("chat")} targetSection={currentSection} /> diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index f759d1eb0d..76353f8a0b 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -40,6 +40,24 @@ vi.mock("@src/components/history/HistoryView", () => ({ }, })) +vi.mock("@src/components/chat/BackgroundTaskView", () => ({ + __esModule: true, + default: function BackgroundTaskView({ onClose }: { onClose: () => void }) { + return ( +
+ Background Task View +
+ ) + }, +})) + +vi.mock("@src/components/chat/BackgroundTaskReplayView", () => ({ + __esModule: true, + default: function BackgroundTaskReplayView() { + return
Background Task Replay View
+ }, +})) + vi.mock("@src/components/mcp/McpView", () => ({ __esModule: true, default: function McpView() { @@ -212,6 +230,38 @@ describe("App", () => { expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument() }) + it("switches to background tasks view when receiving backgroundTasksButtonClicked action", async () => { + render() + + act(() => { + triggerMessage("backgroundTasksButtonClicked") + }) + + const bgTaskView = await screen.findByTestId("background-task-view") + expect(bgTaskView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("returns to chat view when clicking done in background tasks view", async () => { + render() + + act(() => { + triggerMessage("backgroundTasksButtonClicked") + }) + + const bgTaskView = await screen.findByTestId("background-task-view") + + act(() => { + bgTaskView.click() + }) + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("false") + expect(screen.queryByTestId("background-task-view")).not.toBeInTheDocument() + }) + it.each(["history"])("returns to chat view when clicking done in %s view", async (view) => { render() diff --git a/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx new file mode 100644 index 0000000000..89fa9b15b5 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx @@ -0,0 +1,152 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useEvent } from "react-use" +import { ArrowLeft, Play, CheckCircle2, AlertCircle, Loader2 } from "lucide-react" + +import type { BackgroundTaskUpdate, ExtensionMessage } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +const MAX_UPDATES = 20 + +export interface BackgroundTaskLiveViewProps { + taskId: string + onClose: () => void +} + +function getUpdateIcon(update: BackgroundTaskUpdate) { + if (update.kind === "error") { + return + } + if (update.status === "started") { + return + } + if (update.status === "completed") { + return + } + return +} + +function formatUpdateLabel(update: BackgroundTaskUpdate): string { + const tool = update.toolName ?? "unknown" + if (update.kind === "error") { + return `${tool} -- errored${update.errorMessage ? `: ${update.errorMessage}` : ""}` + } + if (update.kind === "tool_call") { + return `${tool} -- started` + } + if (update.kind === "tool_result") { + return `${tool} -- completed` + } + if (update.kind === "status_change") { + return `Status: ${update.status ?? "unknown"}` + } + return tool +} + +/** + * Compact live view that streams real-time progress updates for an active + * background task. Shows a rolling window of the last 20 tool-call updates + * with status icons. + */ +const BackgroundTaskLiveView = memo(({ taskId, onClose }: BackgroundTaskLiveViewProps) => { + const [updates, setUpdates] = useState([]) + const scrollRef = useRef(null) + + // Subscribe to background task progress on mount, unsubscribe on unmount + useEffect(() => { + vscode.postMessage({ type: "subscribeToBackgroundTask", text: taskId }) + return () => { + vscode.postMessage({ type: "unsubscribeFromBackgroundTask" }) + } + }, [taskId]) + + // Listen for progress updates + const handleMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if ( + message.type === "backgroundTaskProgress" && + message.backgroundTaskId === taskId && + message.backgroundTaskProgress + ) { + setUpdates((prev) => { + const next = [...prev, message.backgroundTaskProgress!] + // Keep only the last N updates (rolling window) + if (next.length > MAX_UPDATES) { + return next.slice(next.length - MAX_UPDATES) + } + return next + }) + } + }, + [taskId], + ) + + useEvent("message", handleMessage) + + // Auto-scroll to bottom when new updates arrive + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [updates]) + + return ( +
+ {/* Header */} +
+ + + Live progress · {updates.length} updates + + +
+ + {/* Update list */} +
+ {updates.length === 0 ? ( +
+ +

+ Waiting for updates from background task... +

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

Loading task messages...

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

{error}

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

+ No messages found for this task. +

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

No background tasks yet.

+

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

+
+ ) + } + + return ( +
+ {/* Summary header */} +
+ {activeTasks.length > 0 && ( + + + {activeTasks.length} active + + )} + {backgroundTasks.length} total +
+ + {/* Task list */} +
+ {backgroundTasks.map((item) => { + const status = getTaskStatus(item) + return ( + + ) + })} +
+
+ ) +}) + +BackgroundTasksList.displayName = "BackgroundTasksList" + +export default BackgroundTasksList diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index dd9cbbe36a..e14b914c4b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -46,6 +46,7 @@ import { WorktreeSelector } from "./WorktreeSelector" import FileChangesPanel from "./FileChangesPanel" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import BackgroundTasksPanel from "./BackgroundTasksPanel" import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" import { Cloud } from "lucide-react" diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx new file mode 100644 index 0000000000..d8bd28bcd6 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx @@ -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) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskProgress", + backgroundTaskId: taskId, + backgroundTaskProgress: update, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskLiveView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("subscribes to background task on mount and unsubscribes on unmount", () => { + const { unmount } = render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "subscribeToBackgroundTask", + text: "task-123", + }) + + unmount() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "unsubscribeFromBackgroundTask", + }) + }) + + it("shows empty state initially", () => { + render() + + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + expect(screen.getByText(/Waiting for updates/)).toBeTruthy() + }) + + it("renders progress updates when received", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(1) + }) + + expect(screen.getByText(/read_file -- started/)).toBeTruthy() + }) + + it("shows update count in header", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + simulateBackgroundTaskProgress("task-123", { + kind: "tool_result", + timestamp: Date.now(), + toolName: "read_file", + status: "completed", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/2 updates/)).toBeTruthy() + }) + }) + + it("ignores progress updates for different task IDs", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-different", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + // Should still show empty state + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + // Send an update so the view renders fully + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + expect(screen.getByTestId("live-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("live-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("displays error updates with error message", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "error", + timestamp: Date.now(), + toolName: "execute_command", + status: "errored", + errorMessage: "Permission denied", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/execute_command -- errored: Permission denied/)).toBeTruthy() + }) + }) + + it("caps updates at the rolling window size of 20", async () => { + render() + + act(() => { + for (let i = 0; i < 25; i++) { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now() + i, + toolName: `tool_${i}`, + status: "started", + }) + } + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(20) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx new file mode 100644 index 0000000000..0e9b2a19f4 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx @@ -0,0 +1,147 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx + +import React from "react" +import { render, screen, act, waitFor } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import BackgroundTaskReplayView from "../BackgroundTaskReplayView" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ChatRow to avoid pulling in heavy dependencies +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) { + return
{message.text ?? "message"}
+ }, +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn().mockReturnValue({ + clineMessages: [], + mcpServers: [], + mode: "code", + apiConfiguration: {}, + currentTaskItem: null, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +function simulateBackgroundTaskMessages(taskId: string, messages: any[]) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskReplayView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("requests messages on mount", () => { + render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + }) + + it("shows loading state initially", () => { + render() + + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("renders messages when received from extension", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ]) + }) + + await waitFor(() => { + const rows = screen.getAllByTestId("chat-row") + expect(rows).toHaveLength(2) + }) + }) + + it("shows empty state when task has no messages", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-empty", []) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-empty-state")).toBeTruthy() + }) + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [{ ts: 1000, type: "say", say: "text", text: "Hello" }]) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("replay-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("ignores messages for a different task ID", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-different", [ + { ts: 1000, type: "say", say: "text", text: "Wrong task" }, + ]) + }) + + // Should still show loading since the task ID didn't match + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("shows message count in header after loading", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Msg 1" }, + { ts: 2000, type: "say", say: "text", text: "Msg 2" }, + { ts: 3000, type: "say", say: "text", text: "Msg 3" }, + ]) + }) + + await waitFor(() => { + expect(screen.getByText(/3 messages/)).toBeTruthy() + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx new file mode 100644 index 0000000000..fbb166d438 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx @@ -0,0 +1,176 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskView.spec.tsx + +import React from "react" +import { render, screen, fireEvent, act } from "@/utils/test-utils" + +import BackgroundTaskView from "../BackgroundTaskView" + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn().mockReturnValue({ + taskHistory: [ + { + id: "bg-task-1", + number: 1, + ts: Date.now() - 60000, + task: "Research API docs", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.001, + parentTaskId: "parent-1", + status: "completed", + mode: "ask", + }, + { + id: "bg-task-2", + number: 2, + ts: Date.now(), + task: "Implement feature", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.002, + parentTaskId: "parent-1", + status: "active", + mode: "code", + }, + ], + currentTaskItem: null, + clineMessages: [], + mcpServers: [], + mode: "code", + apiConfiguration: {}, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// Mock ChatRow for BackgroundTaskReplayView +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) { + return
{message.text ?? "message"}
+ }, +})) + +// Mock BackgroundTaskLiveView +vi.mock("../BackgroundTaskLiveView", () => ({ + default: function MockBackgroundTaskLiveView({ taskId, onClose }: { taskId: string; onClose: () => void }) { + return ( +
+ + Live view for {taskId} +
+ ) + }, +})) + +describe("BackgroundTaskView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the list view by default", () => { + render() + + expect(screen.getByTestId("background-task-view")).toBeTruthy() + expect(screen.getByTestId("background-task-view-header")).toBeTruthy() + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + expect(screen.getByText("Background Tasks")).toBeTruthy() + }) + + it("calls onClose when back-to-chat button is clicked", () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId("background-task-view-back")) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it("navigates to replay view when a task is clicked", () => { + render() + + // Click on a task to open replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + + // Should now show the replay view (in loading state), not the list + expect(screen.getByTestId("replay-loading")).toBeTruthy() + expect(screen.queryByTestId("background-tasks-list")).toBeNull() + }) + + it("navigates back to list from replay view via back button", () => { + render() + + // Navigate to replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + expect(screen.getByTestId("replay-loading")).toBeTruthy() + + // Simulate messages arriving so replay-back-button appears + act(() => { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskMessages", + backgroundTaskId: "bg-task-1", + backgroundTaskMessages: [{ ts: 1000, type: "say", say: "text", text: "Hello" }], + }, + }) + window.dispatchEvent(event) + }) + + // Click back button in replay view + fireEvent.click(screen.getByTestId("replay-back-button")) + + // Should return to list view + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + }) + + it("hides the top header when in replay view (replay has its own header)", () => { + render() + + // Header should be visible in list view + expect(screen.getByTestId("background-task-view-header")).toBeTruthy() + + // Navigate to replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + + // Top header should be hidden -- replay has its own back button + expect(screen.queryByTestId("background-task-view-header")).toBeNull() + }) + + it("navigates to live view when an active task is clicked", () => { + render() + + // Click on the active task (bg-task-2 has status "active") + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + + // Should show the live view, not the replay view + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + expect(screen.queryByTestId("replay-loading")).toBeNull() + expect(screen.queryByTestId("background-tasks-list")).toBeNull() + }) + + it("navigates back to list from live view via back button", () => { + render() + + // Navigate to live view for active task + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + + // Click back button in live view + fireEvent.click(screen.getByTestId("live-back-button")) + + // Should return to list view + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx new file mode 100644 index 0000000000..2778a8d6d7 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx @@ -0,0 +1,153 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTasksList.spec.tsx + +import React from "react" +import { render, screen, fireEvent } from "@/utils/test-utils" + +import BackgroundTasksList from "../BackgroundTasksList" + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +const mockUseExtensionState = vi.fn() + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: (...args: any[]) => mockUseExtensionState(...args), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +function createHistoryItem(overrides: Record = {}) { + return { + id: "task-1", + number: 1, + ts: Date.now(), + task: "Test background task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.001, + parentTaskId: "parent-1", + status: "completed" as const, + mode: "code", + ...overrides, + } +} + +describe("BackgroundTasksList", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ + taskHistory: [], + currentTaskItem: null, + }) + }) + + it("shows empty state when no background tasks exist", () => { + render() + + expect(screen.getByTestId("background-tasks-empty")).toBeTruthy() + expect(screen.getByText(/No background tasks yet/)).toBeTruthy() + }) + + it("shows tasks that have a parentTaskId", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", task: "Background task one", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-2", task: "Foreground task (no parent)", parentTaskId: undefined }), + createHistoryItem({ id: "task-3", task: "Background task two", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy() + expect(screen.getByTestId("background-task-item-task-3")).toBeTruthy() + // Foreground task without parentTaskId should NOT appear + expect(screen.queryByTestId("background-task-item-task-2")).toBeNull() + }) + + it("excludes the current foreground task from the list", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", task: "Background subtask", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-current", task: "Current task", parentTaskId: "parent-1" }), + ], + currentTaskItem: { id: "task-current" }, + }) + + render() + + expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy() + expect(screen.queryByTestId("background-task-item-task-current")).toBeNull() + }) + + it("calls onSelectTask when a task item is clicked", () => { + const onSelectTask = vi.fn() + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", task: "Click me", parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + fireEvent.click(screen.getByTestId("background-task-item-task-1")) + expect(onSelectTask).toHaveBeenCalledWith("task-1") + }) + + it("shows task status badges", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-active", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-done", status: "completed", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("Running")).toBeTruthy() + expect(screen.getByText("Completed")).toBeTruthy() + }) + + it("shows active count in summary header", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-2", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-3", status: "completed", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("2 active")).toBeTruthy() + expect(screen.getByText("3 total")).toBeTruthy() + }) + + it("shows mode badge when task has a mode", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", mode: "architect", parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("architect")).toBeTruthy() + }) + + it("truncates long task descriptions", () => { + const longTask = "A".repeat(100) + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", task: longTask, parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + // Should be truncated at 80 chars + "..." + expect(screen.getByText("A".repeat(80) + "...")).toBeTruthy() + }) +})