Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 770a220

Browse files
committed
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)
1 parent 2c00731 commit 770a220

6 files changed

Lines changed: 405 additions & 3 deletions

File tree

packages/types/src/vscode-extension-host.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export interface ExtensionMessage {
9292
| "folderSelected"
9393
| "skills"
9494
| "fileContent"
95+
| "backgroundTaskMessages"
9596
text?: string
9697
/** For fileContent: { path, content, error? } */
9798
fileContent?: { path: string; content: string | null; error?: string }
@@ -160,6 +161,8 @@ export interface ExtensionMessage {
160161
tools?: SerializedCustomToolDefinition[] // For customToolsResult
161162
skills?: SkillMetadata[] // For skills response
162163
modes?: { slug: string; name: string }[] // For modes response
164+
backgroundTaskMessages?: ClineMessage[] // For backgroundTaskMessages: loaded messages for a background task replay
165+
backgroundTaskId?: string // For backgroundTaskMessages: the task ID these messages belong to
163166
aggregatedCosts?: {
164167
// For taskWithAggregatedCosts response
165168
totalCost: number
@@ -514,6 +517,8 @@ export interface WebviewMessage {
514517
| "createWorktreeInclude"
515518
| "checkoutBranch"
516519
| "browseForWorktreePath"
520+
// Background task replay messages
521+
| "requestBackgroundTaskMessages"
517522
// Skills messages
518523
| "requestSkills"
519524
| "createSkill"
@@ -524,7 +529,7 @@ export interface WebviewMessage {
524529
text?: string
525530
taskId?: string
526531
editedMessageContent?: string
527-
tab?: "settings" | "history" | "mcp" | "modes" | "chat"
532+
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay"
528533
disabled?: boolean
529534
context?: string
530535
dataUri?: string
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts
2+
3+
import { webviewMessageHandler } from "../webviewMessageHandler"
4+
import type { ClineProvider } from "../ClineProvider"
5+
6+
vi.mock("../../task-persistence", () => ({
7+
saveTaskMessages: vi.fn(),
8+
readTaskMessages: vi.fn(),
9+
}))
10+
11+
import { readTaskMessages } from "../../task-persistence"
12+
13+
const mockPostMessageToWebview = vi.fn()
14+
15+
const mockClineProvider = {
16+
contextProxy: {
17+
globalStorageUri: { fsPath: "/mock/global/storage" },
18+
getValue: vi.fn(),
19+
setValue: vi.fn(),
20+
},
21+
postMessageToWebview: mockPostMessageToWebview,
22+
getStateToPostToWebview: vi.fn().mockResolvedValue({}),
23+
} as unknown as ClineProvider
24+
25+
describe("webviewMessageHandler - requestBackgroundTaskMessages", () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
})
29+
30+
it("loads task messages from disk and posts them to the webview", async () => {
31+
const mockMessages = [
32+
{ ts: 1000, type: "say", say: "text", text: "Hello" },
33+
{ ts: 2000, type: "say", say: "text", text: "World" },
34+
]
35+
vi.mocked(readTaskMessages).mockResolvedValue(mockMessages as any)
36+
37+
await webviewMessageHandler(mockClineProvider, {
38+
type: "requestBackgroundTaskMessages",
39+
text: "task-123",
40+
})
41+
42+
expect(readTaskMessages).toHaveBeenCalledWith({
43+
taskId: "task-123",
44+
globalStoragePath: "/mock/global/storage",
45+
})
46+
47+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({
48+
type: "backgroundTaskMessages",
49+
backgroundTaskId: "task-123",
50+
backgroundTaskMessages: mockMessages,
51+
})
52+
})
53+
54+
it("returns empty array when task has no messages", async () => {
55+
vi.mocked(readTaskMessages).mockResolvedValue([])
56+
57+
await webviewMessageHandler(mockClineProvider, {
58+
type: "requestBackgroundTaskMessages",
59+
text: "task-empty",
60+
})
61+
62+
expect(readTaskMessages).toHaveBeenCalledWith({
63+
taskId: "task-empty",
64+
globalStoragePath: "/mock/global/storage",
65+
})
66+
67+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({
68+
type: "backgroundTaskMessages",
69+
backgroundTaskId: "task-empty",
70+
backgroundTaskMessages: [],
71+
})
72+
})
73+
74+
it("does nothing when taskId is not provided", async () => {
75+
await webviewMessageHandler(mockClineProvider, {
76+
type: "requestBackgroundTaskMessages",
77+
// no text/taskId provided
78+
})
79+
80+
expect(readTaskMessages).not.toHaveBeenCalled()
81+
expect(mockPostMessageToWebview).not.toHaveBeenCalled()
82+
})
83+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { customToolRegistry } from "@roo-code/core"
2323

2424
import { type ApiMessage } from "../task-persistence/apiMessages"
25-
import { saveTaskMessages } from "../task-persistence"
25+
import { saveTaskMessages, readTaskMessages } from "../task-persistence"
2626

2727
import { ClineProvider } from "./ClineProvider"
2828
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
@@ -767,6 +767,19 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
767767
case "showTaskWithId":
768768
provider.showTaskWithId(message.text!)
769769
break
770+
case "requestBackgroundTaskMessages": {
771+
const taskId = message.text
772+
if (taskId) {
773+
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
774+
const messages = await readTaskMessages({ taskId, globalStoragePath })
775+
await provider.postMessageToWebview({
776+
type: "backgroundTaskMessages",
777+
backgroundTaskId: taskId,
778+
backgroundTaskMessages: messages,
779+
})
780+
}
781+
break
782+
}
770783
case "condenseTaskContextRequest":
771784
provider.condenseTaskContext(message.text!)
772785
break

webview-ui/src/App.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import ChatView, { ChatViewRef } from "./components/chat/ChatView"
1212
import HistoryView from "./components/history/HistoryView"
1313
import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView"
1414
import WelcomeView from "./components/welcome/WelcomeViewProvider"
15+
import BackgroundTaskReplayView from "./components/chat/BackgroundTaskReplayView"
1516
import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog"
1617
import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog"
1718
import ErrorBoundary from "./components/ErrorBoundary"
1819
import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick"
1920
import { TooltipProvider } from "./components/ui/tooltip"
2021
import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip"
2122

22-
type Tab = "settings" | "history" | "chat"
23+
type Tab = "settings" | "history" | "chat" | "bgTaskReplay"
2324

2425
interface DeleteMessageDialogState {
2526
isOpen: boolean
@@ -50,6 +51,7 @@ const App = () => {
5051

5152
const [showAnnouncement, setShowAnnouncement] = useState(false)
5253
const [tab, setTab] = useState<Tab>("chat")
54+
const [replayTaskId, setReplayTaskId] = useState<string | null>(null)
5355

5456
const [deleteMessageDialogState, setDeleteMessageDialogState] = useState<DeleteMessageDialogState>({
5557
isOpen: false,
@@ -88,6 +90,10 @@ const App = () => {
8890
// Handle switchTab action with tab parameter
8991
if (message.action === "switchTab" && message.tab) {
9092
const targetTab = message.tab as Tab
93+
// If switching to bgTaskReplay, extract taskId from values
94+
if (targetTab === "bgTaskReplay" && message.values?.taskId) {
95+
setReplayTaskId(message.values.taskId as string)
96+
}
9197
switchTab(targetTab)
9298
// Extract targetSection from values if provided
9399
const targetSection = message.values?.section as string | undefined
@@ -174,6 +180,15 @@ const App = () => {
174180
<WelcomeView />
175181
) : (
176182
<>
183+
{tab === "bgTaskReplay" && replayTaskId && (
184+
<BackgroundTaskReplayView
185+
taskId={replayTaskId}
186+
onClose={() => {
187+
setReplayTaskId(null)
188+
switchTab("chat")
189+
}}
190+
/>
191+
)}
177192
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
178193
{tab === "settings" && (
179194
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { memo, useCallback, useEffect, useRef, useState } from "react"
2+
import { useEvent } from "react-use"
3+
import { ArrowLeft, Loader2 } from "lucide-react"
4+
5+
import type { ClineMessage, ExtensionMessage } from "@roo-code/types"
6+
7+
import { vscode } from "@src/utils/vscode"
8+
9+
import ChatRow from "./ChatRow"
10+
11+
export interface BackgroundTaskReplayViewProps {
12+
taskId: string
13+
onClose: () => void
14+
}
15+
16+
/**
17+
* A read-only view that displays the full message history of a background task.
18+
* This is a thin wrapper around ChatRow components -- it loads messages from disk
19+
* via the extension and renders them without any input controls or approval buttons.
20+
*/
21+
const BackgroundTaskReplayView = memo(({ taskId, onClose }: BackgroundTaskReplayViewProps) => {
22+
const [messages, setMessages] = useState<ClineMessage[]>([])
23+
const [loading, setLoading] = useState(true)
24+
const [error, setError] = useState<string | null>(null)
25+
const [expandedMessages, setExpandedMessages] = useState<Set<number>>(new Set())
26+
const scrollContainerRef = useRef<HTMLDivElement>(null)
27+
28+
// Request messages from the extension on mount
29+
useEffect(() => {
30+
setLoading(true)
31+
setError(null)
32+
vscode.postMessage({ type: "requestBackgroundTaskMessages", text: taskId })
33+
}, [taskId])
34+
35+
// Listen for the response
36+
const handleMessage = useCallback(
37+
(event: MessageEvent) => {
38+
const message: ExtensionMessage = event.data
39+
if (message.type === "backgroundTaskMessages" && message.backgroundTaskId === taskId) {
40+
setMessages(message.backgroundTaskMessages ?? [])
41+
setLoading(false)
42+
}
43+
},
44+
[taskId],
45+
)
46+
47+
useEvent("message", handleMessage)
48+
49+
const handleToggleExpand = useCallback((ts: number) => {
50+
setExpandedMessages((prev) => {
51+
const next = new Set(prev)
52+
if (next.has(ts)) {
53+
next.delete(ts)
54+
} else {
55+
next.add(ts)
56+
}
57+
return next
58+
})
59+
}, [])
60+
61+
if (loading) {
62+
return (
63+
<div
64+
className="flex flex-col items-center justify-center h-full"
65+
style={{ padding: "20px" }}
66+
data-testid="replay-loading">
67+
<Loader2 className="animate-spin" size={24} />
68+
<p className="text-vscode-descriptionForeground mt-2">Loading task messages...</p>
69+
</div>
70+
)
71+
}
72+
73+
if (error) {
74+
return (
75+
<div className="flex flex-col items-center justify-center h-full" style={{ padding: "20px" }}>
76+
<p className="text-vscode-errorForeground">{error}</p>
77+
<button
78+
className="mt-2 text-vscode-textLink-foreground hover:underline cursor-pointer"
79+
onClick={onClose}>
80+
Go back
81+
</button>
82+
</div>
83+
)
84+
}
85+
86+
return (
87+
<div className="flex flex-col h-full" data-testid="background-task-replay-view">
88+
{/* Header bar */}
89+
<div
90+
className="flex items-center gap-2 px-4 py-2 border-b"
91+
style={{
92+
borderColor: "var(--vscode-panel-border)",
93+
backgroundColor: "var(--vscode-sideBar-background)",
94+
}}>
95+
<button
96+
onClick={onClose}
97+
className="flex items-center gap-1 text-vscode-textLink-foreground hover:underline cursor-pointer bg-transparent border-none p-0"
98+
data-testid="replay-back-button">
99+
<ArrowLeft size={16} />
100+
<span>Back</span>
101+
</button>
102+
<span className="text-vscode-descriptionForeground text-sm ml-2">
103+
Task replay (read-only) &middot; {messages.length} messages
104+
</span>
105+
</div>
106+
107+
{/* Message list */}
108+
<div
109+
ref={scrollContainerRef}
110+
className="flex-1 overflow-y-auto"
111+
style={{ padding: "0 20px" }}
112+
data-testid="replay-message-list">
113+
{messages.length === 0 ? (
114+
<div className="flex items-center justify-center h-full">
115+
<p className="text-vscode-descriptionForeground" data-testid="replay-empty-state">
116+
No messages found for this task.
117+
</p>
118+
</div>
119+
) : (
120+
messages.map((msg, index) => (
121+
<ChatRow
122+
key={msg.ts}
123+
message={msg}
124+
isExpanded={expandedMessages.has(msg.ts)}
125+
isLast={index === messages.length - 1}
126+
isStreaming={false}
127+
onToggleExpand={handleToggleExpand}
128+
onHeightChange={() => {}}
129+
/>
130+
))
131+
)}
132+
</div>
133+
</div>
134+
)
135+
})
136+
137+
BackgroundTaskReplayView.displayName = "BackgroundTaskReplayView"
138+
139+
export default BackgroundTaskReplayView

0 commit comments

Comments
 (0)