mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-06 02:47:56 +00:00
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:
parent
2c00731970
commit
770a2209ea
6 changed files with 405 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
139
webview-ui/src/components/chat/BackgroundTaskReplayView.tsx
Normal file
139
webview-ui/src/components/chat/BackgroundTaskReplayView.tsx
Normal 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) · {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
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue