feat: Phase 5 - Background Tasks Panel UI for parallel task visibility

Adds a collapsible Background Tasks Panel to the chat sidebar that shows
active and recently completed background tasks. This builds on the Phase 4
BackgroundTaskRunner to give users visibility into background work.

Key changes:
- BackgroundTaskStatusInfo type for exposing task status to the webview
- BackgroundTaskRunner tracks completed tasks with result summaries
- BackgroundTaskRunner.getTasksStatus() returns combined active + completed
- BackgroundTaskRunner.onStateChanged callback for UI refresh
- backgroundTasks field added to ExtensionState and getStateToPostToWebview
- cancelBackgroundTask webview message handler
- postBackgroundTasksToWebview() for lightweight status-only updates
- BackgroundTasksPanel React component with collapsible panel, cancel
  buttons, active count badge, and result summaries
- 31 backend tests (BackgroundTaskRunner) + 7 UI tests (panel component)
This commit is contained in:
Roo Code 2026-05-12 10:35:07 +00:00
parent 4b2b91fcdc
commit 15f4be4abf
8 changed files with 646 additions and 11 deletions

View file

@ -337,6 +337,9 @@ export type ExtensionState = Pick<
openAiCodexIsAuthenticated?: boolean
debug?: boolean
/** Background tasks status for the UI panel */
backgroundTasks?: BackgroundTaskStatusInfo[]
/**
* Monotonically increasing sequence number for clineMessages state pushes.
* When present, the frontend should only apply clineMessages from a state push
@ -346,6 +349,21 @@ export type ExtensionState = Pick<
clineMessagesSeq?: number
}
/**
* Status of a background task as exposed to the webview UI.
*/
export interface BackgroundTaskStatusInfo {
taskId: string
parentTaskId: string
status: "running" | "completed" | "cancelled" | "timed_out" | "error"
startedAt: number
completedAt?: number
/** Short summary of the result (from attempt_completion) */
resultSummary?: string
/** The mode slug the background task was running in */
mode?: string
}
export interface Command {
name: string
source: "global" | "project" | "built-in"
@ -514,6 +532,8 @@ export interface WebviewMessage {
| "createWorktreeInclude"
| "checkoutBranch"
| "browseForWorktreePath"
// Background task messages
| "cancelBackgroundTask"
// Skills messages
| "requestSkills"
| "createSkill"

View file

@ -10,6 +10,8 @@
* This is Phase 4 of the parallel execution roadmap: Background Read-Only Concurrency.
*/
import { BackgroundTaskStatusInfo } from "@roo-code/types"
import { Task, TaskOptions } from "./Task"
/** Read-only tools that background tasks are allowed to use. */
@ -46,11 +48,27 @@ export interface BackgroundTaskRunnerCallbacks {
onTaskError?: (taskId: string, parentTaskId: string, error: Error) => void
}
/** Maximum number of recently completed tasks to keep for UI display. */
const MAX_COMPLETED_TASKS = 10
export interface CompletedBackgroundTaskInfo {
taskId: string
parentTaskId: string
status: "completed" | "cancelled" | "timed_out" | "error"
startedAt: number
completedAt: number
resultSummary?: string
mode?: string
}
export class BackgroundTaskRunner {
private backgroundTasks: Map<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,
@ -108,12 +126,14 @@ export class BackgroundTaskRunner {
`[BackgroundTaskRunner] Registered background task ${task.taskId} ` +
`(parent: ${parentTaskId}, active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
)
this.notifyStateChanged()
}
/**
* Called when a background task completes. Cleans up tracking state.
*/
onTaskCompleted(taskId: string): BackgroundTaskInfo | undefined {
onTaskCompleted(taskId: string, resultSummary?: string): BackgroundTaskInfo | undefined {
const info = this.backgroundTasks.get(taskId)
if (!info) {
@ -123,11 +143,22 @@ export class BackgroundTaskRunner {
clearTimeout(info.timeoutHandle)
this.backgroundTasks.delete(taskId)
this.addCompletedTask({
taskId,
parentTaskId: info.parentTaskId,
status: "completed",
startedAt: info.startedAt,
completedAt: Date.now(),
resultSummary,
})
console.log(
`[BackgroundTaskRunner] Background task ${taskId} completed ` +
`(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
)
this.notifyStateChanged()
return info
}
@ -174,9 +205,12 @@ export class BackgroundTaskRunner {
clearTimeout(info.timeoutHandle)
let status: CompletedBackgroundTaskInfo["status"] = "cancelled"
try {
await info.task.abortTask(true)
} catch (error) {
status = "error"
const err = error instanceof Error ? error : new Error(String(error))
console.error(`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${err.message}`)
try {
@ -188,10 +222,20 @@ export class BackgroundTaskRunner {
this.backgroundTasks.delete(taskId)
this.addCompletedTask({
taskId,
parentTaskId: info.parentTaskId,
status,
startedAt: info.startedAt,
completedAt: Date.now(),
})
console.log(
`[BackgroundTaskRunner] Cancelled background task ${taskId} ` +
`(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
)
this.notifyStateChanged()
}
/**
@ -205,12 +249,79 @@ export class BackgroundTaskRunner {
}
}
/**
* Returns the combined status of all active and recently completed background tasks
* for display in the webview UI.
*/
getTasksStatus(): BackgroundTaskStatusInfo[] {
const activeTasks: BackgroundTaskStatusInfo[] = []
for (const [taskId, info] of this.backgroundTasks) {
activeTasks.push({
taskId,
parentTaskId: info.parentTaskId,
status: "running",
startedAt: info.startedAt,
})
}
const completedStatuses: BackgroundTaskStatusInfo[] = this.completedTasks.map((ct) => ({
taskId: ct.taskId,
parentTaskId: ct.parentTaskId,
status: ct.status,
startedAt: ct.startedAt,
completedAt: ct.completedAt,
resultSummary: ct.resultSummary,
mode: ct.mode,
}))
return [...activeTasks, ...completedStatuses]
}
/**
* Returns the list of recently completed tasks (for testing and direct access).
*/
getCompletedTasks(): readonly CompletedBackgroundTaskInfo[] {
return this.completedTasks
}
/**
* Clears completed tasks from the buffer.
*/
clearCompletedTasks(): void {
this.completedTasks = []
this.notifyStateChanged()
}
/**
* Add a completed task to the buffer, evicting the oldest if at capacity.
*/
private addCompletedTask(info: CompletedBackgroundTaskInfo): void {
this.completedTasks.push(info)
if (this.completedTasks.length > MAX_COMPLETED_TASKS) {
this.completedTasks = this.completedTasks.slice(-MAX_COMPLETED_TASKS)
}
}
/**
* Notify the owner that background task state has changed.
*/
private notifyStateChanged(): void {
try {
this.onStateChanged?.()
} catch {
// Callback errors must not break internal logic.
}
}
/**
* Handle timeout of a background task.
*/
private async timeoutTask(taskId: string): Promise<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`)
@ -220,6 +331,28 @@ export class BackgroundTaskRunner {
// Callback errors must not break cleanup.
}
await this.cancelTask(taskId)
// Record as timed_out before cancelling (cancelTask will record as cancelled otherwise)
clearTimeout(info?.timeoutHandle)
if (info) {
try {
await info.task.abortTask(true)
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
console.error(`[BackgroundTaskRunner] Error aborting timed-out task ${taskId}: ${err.message}`)
}
this.backgroundTasks.delete(taskId)
this.addCompletedTask({
taskId,
parentTaskId,
status: "timed_out",
startedAt,
completedAt: Date.now(),
})
this.notifyStateChanged()
} else {
await this.cancelTask(taskId)
}
}
}

View file

@ -223,4 +223,147 @@ describe("BackgroundTaskRunner", () => {
expect(runner.getTaskInfo("unknown")).toBeUndefined()
})
})
describe("getTasksStatus", () => {
it("should return empty array when no tasks", () => {
expect(runner.getTasksStatus()).toEqual([])
})
it("should return running tasks with correct status", () => {
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
const statuses = runner.getTasksStatus()
expect(statuses).toHaveLength(1)
expect(statuses[0].taskId).toBe("task-1")
expect(statuses[0].parentTaskId).toBe("parent-1")
expect(statuses[0].status).toBe("running")
expect(statuses[0].startedAt).toBeGreaterThan(0)
expect(statuses[0].completedAt).toBeUndefined()
})
it("should include completed tasks after onTaskCompleted", () => {
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
runner.onTaskCompleted("task-1", "Done!")
const statuses = runner.getTasksStatus()
expect(statuses).toHaveLength(1)
expect(statuses[0].taskId).toBe("task-1")
expect(statuses[0].status).toBe("completed")
expect(statuses[0].resultSummary).toBe("Done!")
expect(statuses[0].completedAt).toBeGreaterThan(0)
})
it("should include cancelled tasks after cancelTask", async () => {
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
await runner.cancelTask("task-1")
const statuses = runner.getTasksStatus()
expect(statuses).toHaveLength(1)
expect(statuses[0].status).toBe("cancelled")
})
it("should show both active and completed tasks", () => {
const task1 = createMockTask("task-1")
const task2 = createMockTask("task-2")
runner.registerTask(task1, "parent-1")
runner.registerTask(task2, "parent-1")
runner.onTaskCompleted("task-1", "Result 1")
const statuses = runner.getTasksStatus()
expect(statuses).toHaveLength(2)
// Active task
const active = statuses.find((s) => s.taskId === "task-2")
expect(active?.status).toBe("running")
// Completed task
const completed = statuses.find((s) => s.taskId === "task-1")
expect(completed?.status).toBe("completed")
})
})
describe("completed tasks buffer", () => {
it("should limit completed tasks to MAX_COMPLETED_TASKS (10)", () => {
// Register and complete 12 tasks
for (let i = 0; i < 12; i++) {
const task = createMockTask(`task-${i}`)
runner.registerTask(task, "parent-1")
runner.onTaskCompleted(`task-${i}`, `Result ${i}`)
}
const completed = runner.getCompletedTasks()
expect(completed).toHaveLength(10)
// Should keep the most recent 10
expect(completed[0].taskId).toBe("task-2")
expect(completed[9].taskId).toBe("task-11")
})
it("should clear completed tasks", () => {
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
runner.onTaskCompleted("task-1", "Done")
expect(runner.getCompletedTasks()).toHaveLength(1)
runner.clearCompletedTasks()
expect(runner.getCompletedTasks()).toHaveLength(0)
})
})
describe("onStateChanged callback", () => {
it("should be called when a task is registered", () => {
const callback = vi.fn()
runner.onStateChanged = callback
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
expect(callback).toHaveBeenCalledTimes(1)
})
it("should be called when a task is completed", () => {
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
const callback = vi.fn()
runner.onStateChanged = callback
runner.onTaskCompleted("task-1", "Done")
expect(callback).toHaveBeenCalledTimes(1)
})
it("should be called when a task is cancelled", async () => {
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
const callback = vi.fn()
runner.onStateChanged = callback
await runner.cancelTask("task-1")
expect(callback).toHaveBeenCalledTimes(1)
})
it("should not throw if onStateChanged is not set", () => {
const task = createMockTask("task-1")
runner.onStateChanged = undefined
expect(() => runner.registerTask(task, "parent-1")).not.toThrow()
})
})
describe("timeout tracking", () => {
it("should record timed_out status when task times out", async () => {
const task = createMockTask("task-1")
runner.registerTask(task, "parent-1")
// Advance past timeout
vi.advanceTimersByTime(DEFAULT_BACKGROUND_TASK_TIMEOUT_MS + 1000)
// Wait for async timeoutTask
await vi.runAllTimersAsync()
const statuses = runner.getTasksStatus()
const timedOut = statuses.find((s) => s.taskId === "task-1")
expect(timedOut?.status).toBe("timed_out")
})
})
})

View file

@ -141,14 +141,21 @@ export class ClineProvider
private recentTasksCache?: string[]
public readonly taskHistoryStore: TaskHistoryStore
private taskHistoryStoreInitialized = false
public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner(undefined, undefined, {
onTaskTimeout: (taskId, _parentTaskId) => {
vscode.window.showWarningMessage(`Background task ${taskId} timed out and was cancelled.`)
},
onTaskError: (taskId, _parentTaskId, error) => {
vscode.window.showWarningMessage(`Background task ${taskId} encountered an error: ${error.message}`)
},
})
public readonly backgroundTaskRunner: BackgroundTaskRunner = (() => {
const runner = new BackgroundTaskRunner(undefined, undefined, {
onTaskTimeout: (taskId: string, _parentTaskId: string) => {
vscode.window.showWarningMessage(`Background task ${taskId} timed out and was cancelled.`)
},
onTaskError: (taskId, _parentTaskId, error) => {
vscode.window.showWarningMessage(`Background task ${taskId} encountered an error: ${error.message}`)
},
})
runner.onStateChanged = () => {
// Push updated background task status to the webview whenever tasks change
this.postBackgroundTasksToWebview()
}
return runner
})()
private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
private pendingOperations: Map<string, PendingEditOperation> = new Map()
@ -1849,6 +1856,19 @@ export class ClineProvider
this.postMessageToWebview({ type: "state", state })
}
/**
* Push only the background tasks status to the webview.
* This is a lightweight update triggered by BackgroundTaskRunner.onStateChanged
* so the UI can refresh the panel without a full state push.
*/
postBackgroundTasksToWebview(): void {
const backgroundTasks = this.backgroundTaskRunner.getTasksStatus()
this.postMessageToWebview({
type: "state",
state: { backgroundTasks } as any,
})
}
/**
* Like postStateToWebview but intentionally omits taskHistory.
*
@ -2131,6 +2151,7 @@ export class ClineProvider
}
})(),
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
backgroundTasks: this.backgroundTaskRunner.getTasksStatus(),
}
}
@ -3017,7 +3038,7 @@ export class ClineProvider
* task's API conversation as a system message.
*/
private async handleBackgroundTaskComplete(taskId: string, result: string): Promise<void> {
const info = this.backgroundTaskRunner.onTaskCompleted(taskId)
const info = this.backgroundTaskRunner.onTaskCompleted(taskId, result)
if (!info) {
this.log(`[handleBackgroundTaskComplete] Task ${taskId} not found in background runner`)

View file

@ -1189,6 +1189,11 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
case "cancelTask":
await provider.cancelTask()
break
case "cancelBackgroundTask":
if (message.taskId) {
await provider.backgroundTaskRunner.cancelTask(message.taskId)
}
break
case "cancelAutoApproval":
// Cancel any pending auto-approval timeout for the current task
provider.getCurrentTask()?.cancelAutoApprovalTimeout()

View file

@ -0,0 +1,168 @@
import React, { useState, useMemo } from "react"
import type { BackgroundTaskStatusInfo } from "@roo-code/types"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
/**
* Format elapsed time in a human-readable way.
*/
function formatElapsed(startedAt: number, completedAt?: number): string {
const end = completedAt ?? Date.now()
const ms = end - startedAt
if (ms < 1000) {
return "<1s"
}
const seconds = Math.floor(ms / 1000)
if (seconds < 60) {
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}m ${remainingSeconds}s`
}
/**
* Get a status icon codicon class based on task status.
*/
function getStatusIcon(status: BackgroundTaskStatusInfo["status"]): string {
switch (status) {
case "running":
return "codicon-loading codicon-modifier-spin"
case "completed":
return "codicon-check"
case "cancelled":
return "codicon-circle-slash"
case "timed_out":
return "codicon-clock"
case "error":
return "codicon-error"
default:
return "codicon-question"
}
}
/**
* Get a color class for the status indicator.
*/
function getStatusColor(status: BackgroundTaskStatusInfo["status"]): string {
switch (status) {
case "running":
return "text-vscode-charts-blue"
case "completed":
return "text-vscode-charts-green"
case "cancelled":
return "text-vscode-charts-yellow"
case "timed_out":
return "text-vscode-charts-orange"
case "error":
return "text-vscode-errorForeground"
default:
return "text-vscode-descriptionForeground"
}
}
function BackgroundTaskItem({ task }: { task: BackgroundTaskStatusInfo }) {
const [showResult, setShowResult] = useState(false)
const isRunning = task.status === "running"
const handleCancel = () => {
vscode.postMessage({ type: "cancelBackgroundTask", taskId: task.taskId })
}
const shortId = task.taskId.slice(0, 8)
return (
<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 text-vscode-errorForeground hover:opacity-80 cursor-pointer bg-transparent border-none p-0 flex items-center gap-0.5"
onClick={handleCancel}
title="Cancel background task">
<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.
*/
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">
{tasks.map((task) => (
<BackgroundTaskItem key={task.taskId} task={task} />
))}
</div>
)}
</div>
)
}
export default BackgroundTasksPanel

View file

@ -41,6 +41,7 @@ import { CheckpointWarning } from "./CheckpointWarning"
import { QueuedMessages } from "./QueuedMessages"
import { WorktreeSelector } from "./WorktreeSelector"
import FileChangesPanel from "./FileChangesPanel"
import BackgroundTasksPanel from "./BackgroundTasksPanel"
import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle"
export interface ChatViewProps {
@ -1641,6 +1642,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
/>
</div>
<FileChangesPanel clineMessages={messages} />
<BackgroundTasksPanel />
{areButtonsVisible && (
<div
className={`flex h-9 items-center mb-1 px-[15px] ${

View file

@ -0,0 +1,143 @@
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 send cancelBackgroundTask message when cancel is clicked", () => {
mockBackgroundTasks.push({
taskId: "task-cancel-me",
parentTaskId: "parent-1",
status: "running",
startedAt: Date.now(),
})
render(<BackgroundTasksPanel />)
const cancelButton = screen.getByTitle("Cancel background task")
fireEvent.click(cancelButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "cancelBackgroundTask",
taskId: "task-cancel-me",
})
})
it("should show Result button for completed tasks with result summary", () => {
mockBackgroundTasks.push({
taskId: "task-done1",
parentTaskId: "parent-1",
status: "completed",
startedAt: Date.now() - 60000,
completedAt: Date.now(),
resultSummary: "Analysis complete: found 3 issues.",
})
render(<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()
})
})