feat: implement Phase 6a conversation replay for background tasks

- Add requestBackgroundTaskMessages/backgroundTaskMessages message types
- Add extension handler to load task messages from disk via readTaskMessages
- Create BackgroundTaskReplayView component (read-only ChatRow wrapper)
- Wire up bgTaskReplay tab in App.tsx with switchTab support
- Add extension handler tests (3 tests)
- Add component tests (7 tests)
This commit is contained in:
Roo Code 2026-05-12 13:22:27 +00:00
parent 2c00731970
commit 770a2209ea
6 changed files with 405 additions and 3 deletions

View file

@ -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

View file

@ -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()
})
})

View file

@ -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

View file

@ -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<Tab>("chat")
const [replayTaskId, setReplayTaskId] = useState<string | null>(null)
const [deleteMessageDialogState, setDeleteMessageDialogState] = useState<DeleteMessageDialogState>({
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 = () => {
<WelcomeView />
) : (
<>
{tab === "bgTaskReplay" && replayTaskId && (
<BackgroundTaskReplayView
taskId={replayTaskId}
onClose={() => {
setReplayTaskId(null)
switchTab("chat")
}}
/>
)}
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
{tab === "settings" && (
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />

View file

@ -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<ClineMessage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedMessages, setExpandedMessages] = useState<Set<number>>(new Set())
const scrollContainerRef = useRef<HTMLDivElement>(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 (
<div
className="flex flex-col items-center justify-center h-full"
style={{ padding: "20px" }}
data-testid="replay-loading">
<Loader2 className="animate-spin" size={24} />
<p className="text-vscode-descriptionForeground mt-2">Loading task messages...</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full" style={{ padding: "20px" }}>
<p className="text-vscode-errorForeground">{error}</p>
<button
className="mt-2 text-vscode-textLink-foreground hover:underline cursor-pointer"
onClick={onClose}>
Go back
</button>
</div>
)
}
return (
<div className="flex flex-col h-full" data-testid="background-task-replay-view">
{/* Header bar */}
<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="replay-back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
<span className="text-vscode-descriptionForeground text-sm ml-2">
Task replay (read-only) &middot; {messages.length} messages
</span>
</div>
{/* Message list */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto"
style={{ padding: "0 20px" }}
data-testid="replay-message-list">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-vscode-descriptionForeground" data-testid="replay-empty-state">
No messages found for this task.
</p>
</div>
) : (
messages.map((msg, index) => (
<ChatRow
key={msg.ts}
message={msg}
isExpanded={expandedMessages.has(msg.ts)}
isLast={index === messages.length - 1}
isStreaming={false}
onToggleExpand={handleToggleExpand}
onHeightChange={() => {}}
/>
))
)}
</div>
</div>
)
})
BackgroundTaskReplayView.displayName = "BackgroundTaskReplayView"
export default BackgroundTaskReplayView

View file

@ -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 <div data-testid="chat-row">{message.text ?? "message"}</div>
},
}))
// 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(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "requestBackgroundTaskMessages",
text: "task-123",
})
})
it("shows loading state initially", () => {
render(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
expect(screen.getByTestId("replay-loading")).toBeTruthy()
})
it("renders messages when received from extension", async () => {
render(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskReplayView taskId="task-empty" onClose={vi.fn()} />)
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(<BackgroundTaskReplayView taskId="task-123" onClose={onClose} />)
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(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
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(<BackgroundTaskReplayView taskId="task-123" onClose={vi.fn()} />)
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()
})
})
})