mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-10 03:28:03 +00:00
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:
parent
c5e405deb9
commit
35bed0c1b6
30 changed files with 1903 additions and 1344 deletions
327
docs/architecture/phase-6-background-task-visibility.md
Normal file
327
docs/architecture/phase-6-background-task-visibility.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const commandIds = [
|
|||
"acceptInput",
|
||||
"focusPanel",
|
||||
"toggleAutoApprove",
|
||||
"backgroundTasksButtonClicked",
|
||||
] as const
|
||||
|
||||
export type CommandId = (typeof commandIds)[number]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export default {
|
|||
description: BACKGROUND_PARAMETER_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ["mode", "message", "todos", "background"],
|
||||
required: ["mode", "message", "todos"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"> {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
152
webview-ui/src/components/chat/BackgroundTaskLiveView.tsx
Normal file
152
webview-ui/src/components/chat/BackgroundTaskLiveView.tsx
Normal 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 · {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
|
||||
139
webview-ui/src/components/chat/BackgroundTaskReplayView.tsx
Normal file
139
webview-ui/src/components/chat/BackgroundTaskReplayView.tsx
Normal 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) · {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
|
||||
83
webview-ui/src/components/chat/BackgroundTaskView.tsx
Normal file
83
webview-ui/src/components/chat/BackgroundTaskView.tsx
Normal 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
|
||||
165
webview-ui/src/components/chat/BackgroundTasksList.tsx
Normal file
165
webview-ui/src/components/chat/BackgroundTasksList.tsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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] ${
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue