feat: implement Phase 6c real-time progress streaming for background tasks

Add BackgroundTaskLiveView component with compact tool-name + status display,
backgroundTaskProgress message type, emitBackgroundProgress in Task.ts with
500ms throttle and last-20 rolling window, scoped to currently viewed task only.

Changes:
- Add BackgroundTaskUpdate interface and backgroundTaskProgress message type
- Add subscribeToBackgroundTask/unsubscribeFromBackgroundTask webview messages
- Add viewedBackgroundTaskId tracking to ClineProvider
- Add emitBackgroundProgress method to Task.ts with 500ms throttle, 5-per-batch cap
- Hook progress emission into presentAssistantMessage.ts (tool start, complete, error)
- Create BackgroundTaskLiveView webview component with rolling update window
- Update BackgroundTaskView to route active tasks to live view, completed to replay
- 10 new tests across 3 test files (all passing)

Issue #12330 Phase 6c
This commit is contained in:
Roo Code 2026-05-12 14:03:22 +00:00
parent 670032f5b8
commit 347566a339
10 changed files with 567 additions and 9 deletions

View file

@ -16,6 +16,18 @@ import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-lim
import type { SkillMetadata } from "./skills.js"
import type { WorktreeIncludeStatus } from "./worktree.js"
/**
* Incremental progress update for a background task (Phase 6c).
* MVP: tool name + status only. No full parameters or output payloads.
*/
export interface BackgroundTaskUpdate {
kind: "tool_call" | "tool_result" | "status_change" | "error"
timestamp: number
toolName?: string // e.g. "read_file", "execute_command"
status?: string // e.g. "started", "completed", "errored"
errorMessage?: string // Only for kind === "error"
}
/**
* ExtensionMessage
* Extension -> Webview | CLI
@ -93,6 +105,7 @@ export interface ExtensionMessage {
| "skills"
| "fileContent"
| "backgroundTaskMessages"
| "backgroundTaskProgress"
text?: string
/** For fileContent: { path, content, error? } */
fileContent?: { path: string; content: string | null; error?: string }
@ -164,6 +177,7 @@ export interface ExtensionMessage {
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
@ -518,8 +532,10 @@ export interface WebviewMessage {
| "createWorktreeInclude"
| "checkoutBranch"
| "browseForWorktreePath"
// Background task replay messages
// Background task messages
| "requestBackgroundTaskMessages"
| "subscribeToBackgroundTask"
| "unsubscribeFromBackgroundTask"
// Skills messages
| "requestSkills"
| "createSkill"

View file

@ -486,6 +486,14 @@ export async function presentAssistantMessage(cline: Task) {
}
hasToolResult = true
// Phase 6c: Emit background progress when a tool completes
cline.emitBackgroundProgress({
kind: "tool_result",
timestamp: Date.now(),
toolName: block.name,
status: "completed",
})
}
const askApproval = async (
@ -547,6 +555,15 @@ export async function presentAssistantMessage(cline: Task) {
`Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`,
)
// Phase 6c: Emit background progress on error
cline.emitBackgroundProgress({
kind: "error",
timestamp: Date.now(),
toolName: block.name,
status: "errored",
errorMessage: error.message,
})
pushToolResult(formatResponse.toolError(errorString))
}
@ -648,6 +665,16 @@ export async function presentAssistantMessage(cline: Task) {
}
}
// Phase 6c: Emit background progress when a tool starts executing
if (!block.partial) {
cline.emitBackgroundProgress({
kind: "tool_call",
timestamp: Date.now(),
toolName: block.name,
status: "started",
})
}
switch (block.name) {
case "write_to_file":
await checkpointSaveAndMark(cline)

View file

@ -30,6 +30,7 @@ import {
type ClineSay,
type ClineAsk,
type ToolProgressStatus,
type BackgroundTaskUpdate,
type HistoryItem,
type CreateTaskOptions,
type ModelInfo,
@ -4523,6 +4524,68 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
// --- Phase 6c: Background task progress streaming ---
private backgroundProgressBuffer: BackgroundTaskUpdate[] = []
private backgroundProgressTimer: ReturnType<typeof setTimeout> | null = null
private static readonly BACKGROUND_PROGRESS_THROTTLE_MS = 500
private static readonly BACKGROUND_PROGRESS_MAX_BATCH = 5
/**
* Emit a progress update for this task if it is a background task currently
* being viewed by the user. Updates are batched in 500ms windows and capped
* at 5 per batch.
*/
public emitBackgroundProgress(update: BackgroundTaskUpdate): void {
const provider = this.providerRef.deref()
if (!provider) return
// Only emit when this task is NOT the current (foreground) task
if (provider.getCurrentTask()?.taskId === this.taskId) return
// Only emit when the user is actively viewing this background task
if (provider.viewedBackgroundTaskId !== this.taskId) return
this.backgroundProgressBuffer.push(update)
// If no flush is pending, schedule one
if (!this.backgroundProgressTimer) {
this.backgroundProgressTimer = setTimeout(() => {
this.flushBackgroundProgress()
}, Task.BACKGROUND_PROGRESS_THROTTLE_MS)
}
}
private flushBackgroundProgress(): void {
this.backgroundProgressTimer = null
const provider = this.providerRef.deref()
if (!provider) {
this.backgroundProgressBuffer = []
return
}
// Take at most MAX_BATCH items, prioritizing by kind
const priorityOrder: Record<string, number> = {
status_change: 0,
error: 1,
tool_result: 2,
tool_call: 3,
}
const sorted = this.backgroundProgressBuffer.sort(
(a, b) => (priorityOrder[a.kind] ?? 4) - (priorityOrder[b.kind] ?? 4),
)
const batch = sorted.slice(0, Task.BACKGROUND_PROGRESS_MAX_BATCH)
this.backgroundProgressBuffer = []
for (const update of batch) {
provider.postMessageToWebview({
type: "backgroundTaskProgress",
backgroundTaskId: this.taskId,
backgroundTaskProgress: update,
})
}
}
// Getters
public get taskStatus(): TaskStatus {

View file

@ -149,6 +149,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

View file

@ -0,0 +1,52 @@
// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
const mockPostMessageToWebview = vi.fn()
const mockClineProvider = {
contextProxy: {
globalStorageUri: { fsPath: "/mock/global/storage" },
getValue: vi.fn(),
setValue: vi.fn(),
},
postMessageToWebview: mockPostMessageToWebview,
getStateToPostToWebview: vi.fn().mockResolvedValue({}),
viewedBackgroundTaskId: null as string | null,
} as unknown as ClineProvider
describe("webviewMessageHandler - background task progress subscription", () => {
beforeEach(() => {
vi.clearAllMocks()
;(mockClineProvider as any).viewedBackgroundTaskId = null
})
it("sets viewedBackgroundTaskId on subscribeToBackgroundTask", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "subscribeToBackgroundTask",
text: "task-456",
})
expect((mockClineProvider as any).viewedBackgroundTaskId).toBe("task-456")
})
it("clears viewedBackgroundTaskId on unsubscribeFromBackgroundTask", async () => {
;(mockClineProvider as any).viewedBackgroundTaskId = "task-456"
await webviewMessageHandler(mockClineProvider, {
type: "unsubscribeFromBackgroundTask",
})
expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull()
})
it("handles subscribeToBackgroundTask with no text gracefully", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "subscribeToBackgroundTask",
// no text
})
expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull()
})
})

View file

@ -780,6 +780,15 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
}
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

View file

@ -0,0 +1,152 @@
import { memo, useCallback, useEffect, useRef, useState } from "react"
import { useEvent } from "react-use"
import { ArrowLeft, Play, CheckCircle2, AlertCircle, Loader2 } from "lucide-react"
import type { BackgroundTaskUpdate, ExtensionMessage } from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
const MAX_UPDATES = 20
export interface BackgroundTaskLiveViewProps {
taskId: string
onClose: () => void
}
function getUpdateIcon(update: BackgroundTaskUpdate) {
if (update.kind === "error") {
return <AlertCircle size={14} className="text-vscode-errorForeground" />
}
if (update.status === "started") {
return <Play size={14} className="text-vscode-charts-green" />
}
if (update.status === "completed") {
return <CheckCircle2 size={14} className="text-vscode-descriptionForeground" />
}
return <Loader2 size={14} className="animate-spin text-vscode-descriptionForeground" />
}
function formatUpdateLabel(update: BackgroundTaskUpdate): string {
const tool = update.toolName ?? "unknown"
if (update.kind === "error") {
return `${tool} -- errored${update.errorMessage ? `: ${update.errorMessage}` : ""}`
}
if (update.kind === "tool_call") {
return `${tool} -- started`
}
if (update.kind === "tool_result") {
return `${tool} -- completed`
}
if (update.kind === "status_change") {
return `Status: ${update.status ?? "unknown"}`
}
return tool
}
/**
* Compact live view that streams real-time progress updates for an active
* background task. Shows a rolling window of the last 20 tool-call updates
* with status icons.
*/
const BackgroundTaskLiveView = memo(({ taskId, onClose }: BackgroundTaskLiveViewProps) => {
const [updates, setUpdates] = useState<BackgroundTaskUpdate[]>([])
const scrollRef = useRef<HTMLDivElement>(null)
// Subscribe to background task progress on mount, unsubscribe on unmount
useEffect(() => {
vscode.postMessage({ type: "subscribeToBackgroundTask", text: taskId })
return () => {
vscode.postMessage({ type: "unsubscribeFromBackgroundTask" })
}
}, [taskId])
// Listen for progress updates
const handleMessage = useCallback(
(event: MessageEvent) => {
const message: ExtensionMessage = event.data
if (
message.type === "backgroundTaskProgress" &&
message.backgroundTaskId === taskId &&
message.backgroundTaskProgress
) {
setUpdates((prev) => {
const next = [...prev, message.backgroundTaskProgress!]
// Keep only the last N updates (rolling window)
if (next.length > MAX_UPDATES) {
return next.slice(next.length - MAX_UPDATES)
}
return next
})
}
},
[taskId],
)
useEvent("message", handleMessage)
// Auto-scroll to bottom when new updates arrive
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [updates])
return (
<div className="flex flex-col h-full" data-testid="background-task-live-view">
{/* Header */}
<div
className="flex items-center gap-2 px-4 py-2 border-b"
style={{
borderColor: "var(--vscode-panel-border)",
backgroundColor: "var(--vscode-sideBar-background)",
}}>
<button
onClick={onClose}
className="flex items-center gap-1 text-vscode-textLink-foreground hover:underline cursor-pointer bg-transparent border-none p-0"
data-testid="live-back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
<span className="text-vscode-descriptionForeground text-sm ml-2">
Live progress &middot; {updates.length} updates
</span>
<Loader2 size={14} className="animate-spin text-vscode-charts-green ml-auto" />
</div>
{/* Update list */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
style={{ padding: "8px 16px" }}
data-testid="live-update-list">
{updates.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full" data-testid="live-empty-state">
<Loader2 size={24} className="animate-spin text-vscode-descriptionForeground" />
<p className="text-vscode-descriptionForeground text-sm mt-2">
Waiting for updates from background task...
</p>
</div>
) : (
<div className="flex flex-col gap-1">
{updates.map((update, index) => (
<div
key={`${update.timestamp}-${index}`}
className="flex items-center gap-2 py-1 text-sm"
data-testid="live-update-item">
{getUpdateIcon(update)}
<span className="text-vscode-foreground">{formatUpdateLabel(update)}</span>
<span className="text-vscode-descriptionForeground text-xs ml-auto">
{new Date(update.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</div>
</div>
)
})
BackgroundTaskLiveView.displayName = "BackgroundTaskLiveView"
export default BackgroundTaskLiveView

View file

@ -1,28 +1,41 @@
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"
type BackgroundTaskSubView = "list" | "replay" | "live"
export interface BackgroundTaskViewProps {
onClose: () => void
}
/**
* Full-tab container for the background tasks feature (Phase 6b).
* Manages navigation between BackgroundTasksList and BackgroundTaskReplayView.
* Later, BackgroundTaskLiveView (Phase 6c) will be added as another sub-view.
* 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)
setSubView("replay")
}, [])
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)
@ -57,6 +70,9 @@ const BackgroundTaskView = memo(({ onClose }: BackgroundTaskViewProps) => {
{subView === "replay" && selectedTaskId && (
<BackgroundTaskReplayView taskId={selectedTaskId} onClose={handleBackToList} />
)}
{subView === "live" && selectedTaskId && (
<BackgroundTaskLiveView taskId={selectedTaskId} onClose={handleBackToList} />
)}
</div>
</div>
)

View file

@ -0,0 +1,181 @@
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx
import React from "react"
import { render, screen, act, waitFor } from "@/utils/test-utils"
import { vscode } from "@src/utils/vscode"
import BackgroundTaskLiveView from "../BackgroundTaskLiveView"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock use-sound
vi.mock("use-sound", () => ({
default: vi.fn().mockImplementation(() => [vi.fn()]),
}))
function simulateBackgroundTaskProgress(taskId: string, update: Record<string, unknown>) {
const event = new MessageEvent("message", {
data: {
type: "backgroundTaskProgress",
backgroundTaskId: taskId,
backgroundTaskProgress: update,
},
})
window.dispatchEvent(event)
}
describe("BackgroundTaskLiveView", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("subscribes to background task on mount and unsubscribes on unmount", () => {
const { unmount } = render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "subscribeToBackgroundTask",
text: "task-123",
})
unmount()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "unsubscribeFromBackgroundTask",
})
})
it("shows empty state initially", () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
expect(screen.getByTestId("live-empty-state")).toBeTruthy()
expect(screen.getByText(/Waiting for updates/)).toBeTruthy()
})
it("renders progress updates when received", async () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
act(() => {
simulateBackgroundTaskProgress("task-123", {
kind: "tool_call",
timestamp: Date.now(),
toolName: "read_file",
status: "started",
})
})
await waitFor(() => {
const items = screen.getAllByTestId("live-update-item")
expect(items).toHaveLength(1)
})
expect(screen.getByText(/read_file -- started/)).toBeTruthy()
})
it("shows update count in header", async () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
act(() => {
simulateBackgroundTaskProgress("task-123", {
kind: "tool_call",
timestamp: Date.now(),
toolName: "read_file",
status: "started",
})
simulateBackgroundTaskProgress("task-123", {
kind: "tool_result",
timestamp: Date.now(),
toolName: "read_file",
status: "completed",
})
})
await waitFor(() => {
expect(screen.getByText(/2 updates/)).toBeTruthy()
})
})
it("ignores progress updates for different task IDs", async () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
act(() => {
simulateBackgroundTaskProgress("task-different", {
kind: "tool_call",
timestamp: Date.now(),
toolName: "read_file",
status: "started",
})
})
// Should still show empty state
expect(screen.getByTestId("live-empty-state")).toBeTruthy()
})
it("calls onClose when back button is clicked", async () => {
const onClose = vi.fn()
render(<BackgroundTaskLiveView taskId="task-123" onClose={onClose} />)
// Send an update so the view renders fully
act(() => {
simulateBackgroundTaskProgress("task-123", {
kind: "tool_call",
timestamp: Date.now(),
toolName: "read_file",
status: "started",
})
})
await waitFor(() => {
expect(screen.getByTestId("live-back-button")).toBeTruthy()
})
act(() => {
screen.getByTestId("live-back-button").click()
})
expect(onClose).toHaveBeenCalled()
})
it("displays error updates with error message", async () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
act(() => {
simulateBackgroundTaskProgress("task-123", {
kind: "error",
timestamp: Date.now(),
toolName: "execute_command",
status: "errored",
errorMessage: "Permission denied",
})
})
await waitFor(() => {
expect(screen.getByText(/execute_command -- errored: Permission denied/)).toBeTruthy()
})
})
it("caps updates at the rolling window size of 20", async () => {
render(<BackgroundTaskLiveView taskId="task-123" onClose={vi.fn()} />)
act(() => {
for (let i = 0; i < 25; i++) {
simulateBackgroundTaskProgress("task-123", {
kind: "tool_call",
timestamp: Date.now() + i,
toolName: `tool_${i}`,
status: "started",
})
}
})
await waitFor(() => {
const items = screen.getAllByTestId("live-update-item")
expect(items).toHaveLength(20)
})
})
})

View file

@ -62,6 +62,20 @@ vi.mock("../ChatRow", () => ({
},
}))
// 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()
@ -133,4 +147,30 @@ describe("BackgroundTaskView", () => {
// 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()
})
})