diff --git a/docs/architecture/phase-6-background-task-visibility.md b/docs/architecture/phase-6-background-task-visibility.md new file mode 100644 index 0000000000..7f9ecb420b --- /dev/null +++ b/docs/architecture/phase-6-background-task-visibility.md @@ -0,0 +1,327 @@ +# Phase 6: Background Task Visibility and Interaction + +> Architectural design document for Issue #12330 +> Phase 6 of "Support parallel execution of specialized agents and improve context handoff between modes" + +## 1. Context + +Phase 5 (Background Tasks Panel UI) is complete. This document proposes the scope, priority, and architecture for Phase 6, which focuses on enabling better visibility and interaction with background tasks. + +## 2. Current Architecture + +### Task Lifecycle + +`ClineProvider` maintains a `clineStack: Task[]` (LIFO). Only the top-of-stack task is "current" -- all state updates, webview messages, and user interactions route through `getCurrentTask()`. + +``` +ClineProvider +├── clineStack: Task[] # LIFO stack, sequential execution +├── taskHistoryStore # Per-task file persistence +├── getCurrentTask() # Returns top of stack +├── addClineToStack(task) # Push new task +└── removeClineFromStack() # Pop completed task +``` + +### Task Persistence + +| Layer | File | Purpose | +|-------|------|---------| +| Messages | `taskMessages.ts` | Save/load `ClineMessage[]` per task | +| API History | `apiMessages.ts` | Save/load API conversation history | +| History Items | `TaskHistoryStore.ts` | Per-task metadata files with in-memory cache | +| Metadata | `taskMetadata.ts` | Task metadata helpers | + +### Webview Communication + +The extension sends typed `ExtensionMessage` objects to the webview. Key message types: + +- `state` -- Full state snapshot (includes `clineMessages`, `currentTaskId`) +- `taskHistoryUpdated` -- Full history list refresh +- `taskHistoryItemUpdated` -- Single history item update + +Currently, `postStateToWebviewWithoutTaskHistory()` sends state for only the current task. There is no mechanism to send updates for background tasks. + +### Subtask Support + +Parent-child relationships exist via `parentTaskId` and `childIds` on `HistoryItem`. The `new_task` tool creates subtasks that push onto the stack. When a subtask completes, it pops and returns control to the parent. + +## 3. Agreed Scope for Phase 6 + +**In scope (Items 1-3):** +1. Full conversation replay for completed background tasks +2. Tab switching / multi-task view +3. Real-time progress streaming for active background tasks + +**Deferred to Phase 7 (Items 4-5):** +4. Write-capable background tasks + basic file locking +5. Persistent background task history across sessions + +## 4. Feasibility Analysis + +### Item 1: Full Conversation Replay + +**Complexity: Medium | Risk: Low** + +`readTaskMessages(taskId, globalStoragePath)` already loads the full `ClineMessage[]` array from disk for any task. The existing `ChatView` component renders these messages. The main work is creating a read-only wrapper that: + +- Accepts a `taskId` prop instead of reading from global state +- Loads messages on mount via a new webview message +- Hides input controls (chat box, approval buttons) +- Renders tool calls, outputs, and assistant responses in the same format + +**Why it's low risk:** No changes to task execution, persistence, or the foreground task flow. Purely additive UI + a new message handler. + +### Item 2: Tab Switching / Multi-task View + +**Complexity: Medium-High | Risk: Medium** + +The webview already has a tab system in `App.tsx` (`tab === "history"`, `tab === "settings"`, `tab === "chat"`). Adding a background tasks view requires: + +- A new tab or panel within the chat view +- A list of active/completed background tasks with status indicators +- Navigation to open a task's replay view or live view +- State management to track which background task is currently being viewed + +**Key challenge:** The webview currently receives state for only one task. Viewing a background task must not disrupt the foreground task's state. This requires either: + - (a) A separate message channel for background task data, or + - (b) A secondary state context in the webview that can hold background task data alongside the primary task state + +Option (a) is cleaner and avoids polluting the existing state management. + +### Item 3: Real-time Progress Streaming + +**Complexity: High | Risk: Medium-High** + +Currently, `Task.ts` calls `provider.postStateToWebviewWithoutTaskHistory()` to update the UI. This method sends the full state for the current task only. For background tasks to stream progress: + +1. `Task.ts` must emit incremental updates even when it is not the "current" task +2. A new message type (`backgroundTaskProgress`) must carry task-scoped updates +3. The webview must handle concurrent update streams without degrading performance +4. Throttling/batching is needed to prevent excessive re-renders + +**Why it's harder:** Requires changes to the core task execution loop (`Task.ts`), not just additive UI. The task currently assumes it IS the visible task when posting updates. + +## 5. Recommended Priority Order + +``` +Phase 6a: Conversation Replay (Foundation -- standalone value) + │ + ▼ +Phase 6b: Tab/Panel Switching (Navigation framework, depends on 6a) + │ + ▼ +Phase 6c: Real-time Progress Streaming (Highest complexity, builds on 6b) +``` + +Each sub-phase is independently shippable and testable. + +## 6. Detailed Design + +### 6a. Conversation Replay + +#### New Message Types + +```typescript +// Webview → Extension +interface RequestBackgroundTaskMessages { + type: "requestBackgroundTaskMessages" + taskId: string +} + +// Extension → Webview +interface BackgroundTaskMessages { + type: "backgroundTaskMessages" + taskId: string + messages: ClineMessage[] +} +``` + +#### Extension Handler (webviewMessageHandler.ts) + +```typescript +case "requestBackgroundTaskMessages": { + const taskId = message.taskId + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages(taskId, globalStoragePath) + provider.postMessageToWebview({ + type: "backgroundTaskMessages", + taskId, + messages: messages ?? [], + }) + break +} +``` + +#### Webview Component + +``` +BackgroundTaskReplayView +├── Props: { taskId: string, onClose: () => void } +├── State: messages (ClineMessage[]), loading (boolean) +├── On mount: sends requestBackgroundTaskMessages +├── On message: receives backgroundTaskMessages, filters by taskId +├── Renders: read-only message list (reuses ChatRow components) +└── No input controls, no approval buttons +``` + +### 6b. Tab/Panel Switching + +#### UI Structure + +A new icon is added to the existing tab bar (alongside chat, history, settings) as the entry point. The background task view occupies the full tab area. + +``` +App.tsx +├── tab === "chat" → ChatView (foreground task) +├── tab === "history" → HistoryView +├── tab === "settings" → SettingsView +└── tab === "bgTask" → BackgroundTaskView + ├── BackgroundTasksList (task list with status badges + error badge on tab icon) + │ ├── Active tasks + │ └── Completed tasks + └── BackgroundTaskReplayView (from 6a) OR BackgroundTaskLiveView (from 6c) +``` + +#### State Management + +```typescript +// New webview state (in App.tsx or dedicated context) +interface BackgroundTaskViewState { + selectedTaskId: string | null + viewMode: "replay" | "live" +} +``` + +#### Navigation Flow + +1. User clicks background tasks icon in the tab bar +2. App switches to `tab === "bgTask"` +3. BackgroundTasksList shows available tasks +4. User clicks a task → sets `selectedTaskId` +5. If task is completed → opens BackgroundTaskReplayView +6. If task is active → opens BackgroundTaskLiveView (Phase 6c) + +### 6c. Real-time Progress Streaming (Minimal Viable Version) + +> **Design principle:** Keep Phase 6c tightly scoped to avoid expanding the phase. Ship the simplest useful version first; richer detail can be added incrementally in later phases. + +#### MVP Scope + +The minimal viable version streams only: +- **Tool name + status** (started / completed / errored) -- not full parameters or output +- **Last N updates** (rolling window of ~20 items) -- older entries are discarded client-side +- **Status changes** (running, paused, completed, errored) + +What is explicitly **out of scope** for the MVP: +- Full tool call parameters or output payloads +- Assistant text streaming +- Persistent storage of streamed updates (replay from disk covers completed tasks) + +#### New Message Types + +```typescript +// Extension → Webview (incremental updates) +interface BackgroundTaskProgress { + type: "backgroundTaskProgress" + taskId: string + update: BackgroundTaskUpdate +} + +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" +} +``` + +Note: `assistant_text` is excluded from the MVP. The update interface uses typed optional fields instead of `data: any` to keep the contract narrow and safe. + +#### Task.ts Changes + +Add a method that emits progress regardless of whether the task is "current": + +```typescript +// In Task.ts +private emitBackgroundProgress(update: BackgroundTaskUpdate) { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit background updates when this task is NOT the current task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + taskId: this.taskId, + update, + }) +} +``` + +The hook points in Task.ts should be minimal -- emit at tool call start and tool call end only. Avoid adding hooks inside the LLM streaming loop for the MVP. + +#### Throttling Strategy + +- Batch updates in 500ms windows (conservative default; can be tuned down later) +- Cap at 5 updates per batch per task +- Drop older updates if buffer exceeds threshold (keep last N = 20) +- Priority ordering: status_change > error > tool_result > tool_call + +#### Webview: BackgroundTaskLiveView + +``` +BackgroundTaskLiveView +├── Props: { taskId: string } +├── State: updates (BackgroundTaskUpdate[], capped at last 20), status +├── Subscribes to backgroundTaskProgress messages filtered by taskId +├── Renders: compact list of recent tool calls with status icons +├── Auto-scrolls to latest update +└── Shows task status badge (running, paused, completed, errored) +``` + +The live view intentionally shows a compact summary, not a full chat transcript. Users who want full detail can wait for the task to complete and use the replay view (6a). + +> **Confirmed:** Streaming is scoped to the currently selected background task only. The extension should not emit `backgroundTaskProgress` messages for tasks the user is not viewing. This keeps message traffic low and the implementation simple. + +## 7. Testing Strategy + +| Area | Test Type | Key Scenarios | +|------|-----------|---------------| +| Message handler | Unit (vitest) | Request/response for task messages, missing task, corrupt data | +| BackgroundTaskReplayView | Component (vitest + RTL) | Loading state, message rendering, empty state | +| Tab switching | Component (vitest + RTL) | Tab navigation, state preservation, back to foreground | +| Progress streaming | Unit (vitest) | Throttling, batching, concurrent tasks | +| Integration | E2E (if feasible) | Full flow: start bg task → view progress → replay after completion | + +## 8. Confirmed Decisions + +The following decisions were confirmed during design review and should guide implementation. + +### UI Layout + +1. **Background task view layout: Full tab** (`tab === "bgTask"`) + + Start with a full tab for simplicity in Phase 6. A sidebar/hybrid mode may be considered later based on user feedback. + +2. **Entry point placement: New tab bar icon** + + Add a new icon in the existing tab bar (alongside chat, history, settings). This is the most discoverable location without cluttering the chat view. + +3. **Replay view implementation: Thin wrapper around ChatRow components** + + Create a dedicated `BackgroundTaskReplayView` that wraps `ChatRow` components directly rather than reusing the full `ChatView`. This avoids inheriting input controls, scroll management, and approval button logic that don't apply to read-only replay. + +### Progress Streaming (6c) + +4. **Streaming granularity: Minimal level** + + Stream tool name + status only (started/completed/errored). This provides enough signal to know what the background task is doing without performance risk. Truncated arguments (medium level) can be added in a follow-up if users need more context. + +5. **Streaming scope: Currently selected task only** + + Only stream updates for the background task the user is currently viewing. This avoids unnecessary message traffic and keeps the implementation simple. + +6. **Error surfacing: Badge on the background tasks tab icon** + + Display a badge on the tab icon when a background task encounters an error. Toast notifications can be added later if users miss errors. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a4ef802efb..0a0f738004 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -92,6 +92,7 @@ export interface ExtensionMessage { | "folderSelected" | "skills" | "fileContent" + | "backgroundTaskMessages" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -160,6 +161,8 @@ export interface ExtensionMessage { tools?: SerializedCustomToolDefinition[] // For customToolsResult skills?: SkillMetadata[] // For skills response 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 aggregatedCosts?: { // For taskWithAggregatedCosts response totalCost: number @@ -514,6 +517,8 @@ export interface WebviewMessage { | "createWorktreeInclude" | "checkoutBranch" | "browseForWorktreePath" + // Background task replay messages + | "requestBackgroundTaskMessages" // Skills messages | "requestSkills" | "createSkill" @@ -524,7 +529,7 @@ export interface WebviewMessage { text?: string taskId?: string editedMessageContent?: string - tab?: "settings" | "history" | "mcp" | "modes" | "chat" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay" disabled?: boolean context?: string dataUri?: string diff --git a/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts new file mode 100644 index 0000000000..d9e68b79f6 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts @@ -0,0 +1,83 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +vi.mock("../../task-persistence", () => ({ + saveTaskMessages: vi.fn(), + readTaskMessages: vi.fn(), +})) + +import { readTaskMessages } from "../../task-persistence" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), +} as unknown as ClineProvider + +describe("webviewMessageHandler - requestBackgroundTaskMessages", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("loads task messages from disk and posts them to the webview", async () => { + const mockMessages = [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ] + vi.mocked(readTaskMessages).mockResolvedValue(mockMessages as any) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-123", + backgroundTaskMessages: mockMessages, + }) + }) + + it("returns empty array when task has no messages", async () => { + vi.mocked(readTaskMessages).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-empty", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-empty", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-empty", + backgroundTaskMessages: [], + }) + }) + + it("does nothing when taskId is not provided", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + // no text/taskId provided + }) + + expect(readTaskMessages).not.toHaveBeenCalled() + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fac7ed10d5..999cb005cb 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -22,7 +22,7 @@ import { import { customToolRegistry } from "@roo-code/core" import { type ApiMessage } from "../task-persistence/apiMessages" -import { saveTaskMessages } from "../task-persistence" +import { saveTaskMessages, readTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" @@ -767,6 +767,19 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We case "showTaskWithId": provider.showTaskWithId(message.text!) break + case "requestBackgroundTaskMessages": { + const taskId = message.text + if (taskId) { + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages({ taskId, globalStoragePath }) + await provider.postMessageToWebview({ + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }) + } + break + } case "condenseTaskContextRequest": provider.condenseTaskContext(message.text!) break diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 99847dc8eb..94735a5516 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -12,6 +12,7 @@ import ChatView, { ChatViewRef } from "./components/chat/ChatView" import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeViewProvider" +import BackgroundTaskReplayView from "./components/chat/BackgroundTaskReplayView" import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" @@ -19,7 +20,7 @@ import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonI import { TooltipProvider } from "./components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" -type Tab = "settings" | "history" | "chat" +type Tab = "settings" | "history" | "chat" | "bgTaskReplay" interface DeleteMessageDialogState { isOpen: boolean @@ -50,6 +51,7 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") + const [replayTaskId, setReplayTaskId] = useState(null) const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ isOpen: false, @@ -88,6 +90,10 @@ const App = () => { // Handle switchTab action with tab parameter if (message.action === "switchTab" && message.tab) { const targetTab = message.tab as Tab + // If switching to bgTaskReplay, extract taskId from values + if (targetTab === "bgTaskReplay" && message.values?.taskId) { + setReplayTaskId(message.values.taskId as string) + } switchTab(targetTab) // Extract targetSection from values if provided const targetSection = message.values?.section as string | undefined @@ -174,6 +180,15 @@ const App = () => { ) : ( <> + {tab === "bgTaskReplay" && replayTaskId && ( + { + setReplayTaskId(null) + switchTab("chat") + }} + /> + )} {tab === "history" && switchTab("chat")} />} {tab === "settings" && ( setTab("chat")} targetSection={currentSection} /> diff --git a/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx b/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx new file mode 100644 index 0000000000..625956bf53 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx @@ -0,0 +1,139 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useEvent } from "react-use" +import { ArrowLeft, Loader2 } from "lucide-react" + +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import ChatRow from "./ChatRow" + +export interface BackgroundTaskReplayViewProps { + taskId: string + onClose: () => void +} + +/** + * A read-only view that displays the full message history of a background task. + * This is a thin wrapper around ChatRow components -- it loads messages from disk + * via the extension and renders them without any input controls or approval buttons. + */ +const BackgroundTaskReplayView = memo(({ taskId, onClose }: BackgroundTaskReplayViewProps) => { + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedMessages, setExpandedMessages] = useState>(new Set()) + const scrollContainerRef = useRef(null) + + // Request messages from the extension on mount + useEffect(() => { + setLoading(true) + setError(null) + vscode.postMessage({ type: "requestBackgroundTaskMessages", text: taskId }) + }, [taskId]) + + // Listen for the response + const handleMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if (message.type === "backgroundTaskMessages" && message.backgroundTaskId === taskId) { + setMessages(message.backgroundTaskMessages ?? []) + setLoading(false) + } + }, + [taskId], + ) + + useEvent("message", handleMessage) + + const handleToggleExpand = useCallback((ts: number) => { + setExpandedMessages((prev) => { + const next = new Set(prev) + if (next.has(ts)) { + next.delete(ts) + } else { + next.add(ts) + } + return next + }) + }, []) + + if (loading) { + return ( +
+ +

Loading task messages...

+
+ ) + } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + return ( +
+ {/* Header bar */} +
+ + + Task replay (read-only) · {messages.length} messages + +
+ + {/* Message list */} +
+ {messages.length === 0 ? ( +
+

+ No messages found for this task. +

+
+ ) : ( + messages.map((msg, index) => ( + {}} + /> + )) + )} +
+
+ ) +}) + +BackgroundTaskReplayView.displayName = "BackgroundTaskReplayView" + +export default BackgroundTaskReplayView diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx new file mode 100644 index 0000000000..0e9b2a19f4 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx @@ -0,0 +1,147 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx + +import React from "react" +import { render, screen, act, waitFor } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import BackgroundTaskReplayView from "../BackgroundTaskReplayView" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ChatRow to avoid pulling in heavy dependencies +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) { + return
{message.text ?? "message"}
+ }, +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn().mockReturnValue({ + clineMessages: [], + mcpServers: [], + mode: "code", + apiConfiguration: {}, + currentTaskItem: null, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +function simulateBackgroundTaskMessages(taskId: string, messages: any[]) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskReplayView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("requests messages on mount", () => { + render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + }) + + it("shows loading state initially", () => { + render() + + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("renders messages when received from extension", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ]) + }) + + await waitFor(() => { + const rows = screen.getAllByTestId("chat-row") + expect(rows).toHaveLength(2) + }) + }) + + it("shows empty state when task has no messages", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-empty", []) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-empty-state")).toBeTruthy() + }) + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [{ ts: 1000, type: "say", say: "text", text: "Hello" }]) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("replay-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("ignores messages for a different task ID", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-different", [ + { ts: 1000, type: "say", say: "text", text: "Wrong task" }, + ]) + }) + + // Should still show loading since the task ID didn't match + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("shows message count in header after loading", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Msg 1" }, + { ts: 2000, type: "say", say: "text", text: "Msg 2" }, + { ts: 3000, type: "say", say: "text", text: "Msg 3" }, + ]) + }) + + await waitFor(() => { + expect(screen.getByText(/3 messages/)).toBeTruthy() + }) + }) +})