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