diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 62c69f3ad4..bd479a500b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -16,6 +16,18 @@ import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-lim import type { SkillMetadata } from "./skills.js" import type { WorktreeIncludeStatus } from "./worktree.js" +/** + * Incremental progress update for a background task (Phase 6c). + * MVP: tool name + status only. No full parameters or output payloads. + */ +export interface BackgroundTaskUpdate { + kind: "tool_call" | "tool_result" | "status_change" | "error" + timestamp: number + toolName?: string // e.g. "read_file", "execute_command" + status?: string // e.g. "started", "completed", "errored" + errorMessage?: string // Only for kind === "error" +} + /** * ExtensionMessage * Extension -> Webview | CLI @@ -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" diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 49ce56a305..a4db190dd6 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -486,6 +486,14 @@ export async function presentAssistantMessage(cline: Task) { } hasToolResult = true + + // Phase 6c: Emit background progress when a tool completes + cline.emitBackgroundProgress({ + kind: "tool_result", + timestamp: Date.now(), + toolName: block.name, + status: "completed", + }) } const askApproval = async ( @@ -547,6 +555,15 @@ export async function presentAssistantMessage(cline: Task) { `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, ) + // Phase 6c: Emit background progress on error + cline.emitBackgroundProgress({ + kind: "error", + timestamp: Date.now(), + toolName: block.name, + status: "errored", + errorMessage: error.message, + }) + pushToolResult(formatResponse.toolError(errorString)) } @@ -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) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 97f07fcc7a..c5dd8164f3 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -30,6 +30,7 @@ import { type ClineSay, type ClineAsk, type ToolProgressStatus, + type BackgroundTaskUpdate, type HistoryItem, type CreateTaskOptions, type ModelInfo, @@ -4523,6 +4524,68 @@ export class Task extends EventEmitter implements TaskLike { } } + // --- Phase 6c: Background task progress streaming --- + + private backgroundProgressBuffer: BackgroundTaskUpdate[] = [] + private backgroundProgressTimer: ReturnType | null = null + private static readonly BACKGROUND_PROGRESS_THROTTLE_MS = 500 + private static readonly BACKGROUND_PROGRESS_MAX_BATCH = 5 + + /** + * Emit a progress update for this task if it is a background task currently + * being viewed by the user. Updates are batched in 500ms windows and capped + * at 5 per batch. + */ + public emitBackgroundProgress(update: BackgroundTaskUpdate): void { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit when this task is NOT the current (foreground) task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + // Only emit when the user is actively viewing this background task + if (provider.viewedBackgroundTaskId !== this.taskId) return + + this.backgroundProgressBuffer.push(update) + + // If no flush is pending, schedule one + if (!this.backgroundProgressTimer) { + this.backgroundProgressTimer = setTimeout(() => { + this.flushBackgroundProgress() + }, Task.BACKGROUND_PROGRESS_THROTTLE_MS) + } + } + + private flushBackgroundProgress(): void { + this.backgroundProgressTimer = null + const provider = this.providerRef.deref() + if (!provider) { + this.backgroundProgressBuffer = [] + return + } + + // Take at most MAX_BATCH items, prioritizing by kind + const priorityOrder: Record = { + status_change: 0, + error: 1, + tool_result: 2, + tool_call: 3, + } + const sorted = this.backgroundProgressBuffer.sort( + (a, b) => (priorityOrder[a.kind] ?? 4) - (priorityOrder[b.kind] ?? 4), + ) + const batch = sorted.slice(0, Task.BACKGROUND_PROGRESS_MAX_BATCH) + this.backgroundProgressBuffer = [] + + for (const update of batch) { + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + backgroundTaskId: this.taskId, + backgroundTaskProgress: update, + }) + } + } + // Getters public get taskStatus(): TaskStatus { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index aecdb17f31..e47a1b4fd4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 diff --git a/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts new file mode 100644 index 0000000000..a5f8138254 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts @@ -0,0 +1,52 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), + viewedBackgroundTaskId: null as string | null, +} as unknown as ClineProvider + +describe("webviewMessageHandler - background task progress subscription", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(mockClineProvider as any).viewedBackgroundTaskId = null + }) + + it("sets viewedBackgroundTaskId on subscribeToBackgroundTask", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + text: "task-456", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBe("task-456") + }) + + it("clears viewedBackgroundTaskId on unsubscribeFromBackgroundTask", async () => { + ;(mockClineProvider as any).viewedBackgroundTaskId = "task-456" + + await webviewMessageHandler(mockClineProvider, { + type: "unsubscribeFromBackgroundTask", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) + + it("handles subscribeToBackgroundTask with no text gracefully", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + // no text + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 999cb005cb..9a912a5f46 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 diff --git a/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx new file mode 100644 index 0000000000..89fa9b15b5 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx @@ -0,0 +1,152 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useEvent } from "react-use" +import { ArrowLeft, Play, CheckCircle2, AlertCircle, Loader2 } from "lucide-react" + +import type { BackgroundTaskUpdate, ExtensionMessage } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +const MAX_UPDATES = 20 + +export interface BackgroundTaskLiveViewProps { + taskId: string + onClose: () => void +} + +function getUpdateIcon(update: BackgroundTaskUpdate) { + if (update.kind === "error") { + return + } + if (update.status === "started") { + return + } + if (update.status === "completed") { + return + } + return +} + +function formatUpdateLabel(update: BackgroundTaskUpdate): string { + const tool = update.toolName ?? "unknown" + if (update.kind === "error") { + return `${tool} -- errored${update.errorMessage ? `: ${update.errorMessage}` : ""}` + } + if (update.kind === "tool_call") { + return `${tool} -- started` + } + if (update.kind === "tool_result") { + return `${tool} -- completed` + } + if (update.kind === "status_change") { + return `Status: ${update.status ?? "unknown"}` + } + return tool +} + +/** + * Compact live view that streams real-time progress updates for an active + * background task. Shows a rolling window of the last 20 tool-call updates + * with status icons. + */ +const BackgroundTaskLiveView = memo(({ taskId, onClose }: BackgroundTaskLiveViewProps) => { + const [updates, setUpdates] = useState([]) + const scrollRef = useRef(null) + + // Subscribe to background task progress on mount, unsubscribe on unmount + useEffect(() => { + vscode.postMessage({ type: "subscribeToBackgroundTask", text: taskId }) + return () => { + vscode.postMessage({ type: "unsubscribeFromBackgroundTask" }) + } + }, [taskId]) + + // Listen for progress updates + const handleMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if ( + message.type === "backgroundTaskProgress" && + message.backgroundTaskId === taskId && + message.backgroundTaskProgress + ) { + setUpdates((prev) => { + const next = [...prev, message.backgroundTaskProgress!] + // Keep only the last N updates (rolling window) + if (next.length > MAX_UPDATES) { + return next.slice(next.length - MAX_UPDATES) + } + return next + }) + } + }, + [taskId], + ) + + useEvent("message", handleMessage) + + // Auto-scroll to bottom when new updates arrive + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [updates]) + + return ( +
+ {/* Header */} +
+ + + Live progress · {updates.length} updates + + +
+ + {/* Update list */} +
+ {updates.length === 0 ? ( +
+ +

+ Waiting for updates from background task... +

+
+ ) : ( +
+ {updates.map((update, index) => ( +
+ {getUpdateIcon(update)} + {formatUpdateLabel(update)} + + {new Date(update.timestamp).toLocaleTimeString()} + +
+ ))} +
+ )} +
+
+ ) +}) + +BackgroundTaskLiveView.displayName = "BackgroundTaskLiveView" + +export default BackgroundTaskLiveView diff --git a/webview-ui/src/components/chat/BackgroundTaskView.tsx b/webview-ui/src/components/chat/BackgroundTaskView.tsx index 3a987f005d..e4b22e3f05 100644 --- a/webview-ui/src/components/chat/BackgroundTaskView.tsx +++ b/webview-ui/src/components/chat/BackgroundTaskView.tsx @@ -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("list") const [selectedTaskId, setSelectedTaskId] = useState(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 && ( )} + {subView === "live" && selectedTaskId && ( + + )} ) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx new file mode 100644 index 0000000000..d8bd28bcd6 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx @@ -0,0 +1,181 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx + +import React from "react" +import { render, screen, act, waitFor } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import BackgroundTaskLiveView from "../BackgroundTaskLiveView" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +function simulateBackgroundTaskProgress(taskId: string, update: Record) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskProgress", + backgroundTaskId: taskId, + backgroundTaskProgress: update, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskLiveView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("subscribes to background task on mount and unsubscribes on unmount", () => { + const { unmount } = render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "subscribeToBackgroundTask", + text: "task-123", + }) + + unmount() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "unsubscribeFromBackgroundTask", + }) + }) + + it("shows empty state initially", () => { + render() + + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + expect(screen.getByText(/Waiting for updates/)).toBeTruthy() + }) + + it("renders progress updates when received", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(1) + }) + + expect(screen.getByText(/read_file -- started/)).toBeTruthy() + }) + + it("shows update count in header", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + simulateBackgroundTaskProgress("task-123", { + kind: "tool_result", + timestamp: Date.now(), + toolName: "read_file", + status: "completed", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/2 updates/)).toBeTruthy() + }) + }) + + it("ignores progress updates for different task IDs", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-different", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + // Should still show empty state + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + // Send an update so the view renders fully + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + expect(screen.getByTestId("live-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("live-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("displays error updates with error message", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "error", + timestamp: Date.now(), + toolName: "execute_command", + status: "errored", + errorMessage: "Permission denied", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/execute_command -- errored: Permission denied/)).toBeTruthy() + }) + }) + + it("caps updates at the rolling window size of 20", async () => { + render() + + act(() => { + for (let i = 0; i < 25; i++) { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now() + i, + toolName: `tool_${i}`, + status: "started", + }) + } + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(20) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx index fec4c47e87..fbb166d438 100644 --- a/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx @@ -62,6 +62,20 @@ vi.mock("../ChatRow", () => ({ }, })) +// Mock BackgroundTaskLiveView +vi.mock("../BackgroundTaskLiveView", () => ({ + default: function MockBackgroundTaskLiveView({ taskId, onClose }: { taskId: string; onClose: () => void }) { + return ( +
+ + Live view for {taskId} +
+ ) + }, +})) + 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() + + // Click on the active task (bg-task-2 has status "active") + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + + // Should show the live view, not the replay view + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + expect(screen.queryByTestId("replay-loading")).toBeNull() + expect(screen.queryByTestId("background-tasks-list")).toBeNull() + }) + + it("navigates back to list from live view via back button", () => { + render() + + // Navigate to live view for active task + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + + // Click back button in live view + fireEvent.click(screen.getByTestId("live-back-button")) + + // Should return to list view + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + }) })