mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
4b2b91fcdc
commit
15f4be4abf
8 changed files with 646 additions and 11 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
168
webview-ui/src/components/chat/BackgroundTasksPanel.tsx
Normal file
168
webview-ui/src/components/chat/BackgroundTasksPanel.tsx
Normal 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
|
||||
|
|
@ -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] ${
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue