feat: implement Phase 6b Tab/Panel Switching for background tasks

Add a dedicated BackgroundTaskView as a full tab in the webview, with a
new tab bar icon entry point. Users can navigate between BackgroundTasksList
(showing all subtasks with status badges, mode, and timestamps) and
BackgroundTaskReplayView (from Phase 6a) without disrupting the foreground
task state.

Changes:
- Add backgroundTasksButtonClicked command type and registration
- Add BackgroundTaskView container component with list/replay sub-views
- Add BackgroundTasksList component showing subtasks from task history
- Add bgTask tab type to App.tsx routing
- Register command in package.json with sidebar and editor menu entries
- 30 tests across 5 test files (all passing)

Issue #12330 Phase 6b
This commit is contained in:
Roo Code 2026-05-12 13:47:52 +00:00
parent 770a2209ea
commit 670032f5b8
10 changed files with 604 additions and 4 deletions

View file

@ -105,6 +105,7 @@ export interface ExtensionMessage {
| "chatButtonClicked"
| "settingsButtonClicked"
| "historyButtonClicked"
| "backgroundTasksButtonClicked"
| "didBecomeVisible"
| "focusInput"
| "switchTab"
@ -529,7 +530,7 @@ export interface WebviewMessage {
text?: string
taskId?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay"
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay" | "bgTask"
disabled?: boolean
context?: string
dataUri?: string

View file

@ -45,6 +45,7 @@ export const commandIds = [
"acceptInput",
"focusPanel",
"toggleAutoApprove",
"backgroundTasksButtonClicked",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -108,6 +108,15 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
visibleProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" })
},
backgroundTasksButtonClicked: () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
if (!visibleProvider) {
return
}
visibleProvider.postMessageToWebview({ type: "action", action: "backgroundTasksButtonClicked" })
},
newTask: handleNewTask,
setCustomStoragePath: async () => {
const { promptForCustomStoragePath } = await import("../utils/storage")

View file

@ -159,6 +159,11 @@
"command": "roo-cline.toggleAutoApprove",
"title": "%command.toggleAutoApprove.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.backgroundTasksButtonClicked",
"title": "Background Tasks",
"icon": "$(server-process)"
}
],
"menus": {
@ -219,9 +224,14 @@
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.popoutButtonClicked",
"command": "roo-cline.backgroundTasksButtonClicked",
"group": "overflow@2",
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.popoutButtonClicked",
"group": "overflow@3",
"when": "view == roo-cline.SidebarProvider"
}
],
"editor/title": [
@ -241,9 +251,14 @@
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.popoutButtonClicked",
"command": "roo-cline.backgroundTasksButtonClicked",
"group": "overflow@2",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.popoutButtonClicked",
"group": "overflow@3",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
}
]
},

View file

@ -13,6 +13,7 @@ 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 BackgroundTaskView from "./components/chat/BackgroundTaskView"
import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog"
import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog"
import ErrorBoundary from "./components/ErrorBoundary"
@ -20,7 +21,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" | "bgTaskReplay"
type Tab = "settings" | "history" | "chat" | "bgTaskReplay" | "bgTask"
interface DeleteMessageDialogState {
isOpen: boolean
@ -44,6 +45,7 @@ const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]
chatButtonClicked: "chat",
settingsButtonClicked: "settings",
historyButtonClicked: "history",
backgroundTasksButtonClicked: "bgTask",
}
const App = () => {
@ -189,6 +191,7 @@ const App = () => {
}}
/>
)}
{tab === "bgTask" && <BackgroundTaskView onClose={() => switchTab("chat")} />}
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
{tab === "settings" && (
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />

View file

@ -40,6 +40,24 @@ vi.mock("@src/components/history/HistoryView", () => ({
},
}))
vi.mock("@src/components/chat/BackgroundTaskView", () => ({
__esModule: true,
default: function BackgroundTaskView({ onClose }: { onClose: () => void }) {
return (
<div data-testid="background-task-view" onClick={onClose}>
Background Task View
</div>
)
},
}))
vi.mock("@src/components/chat/BackgroundTaskReplayView", () => ({
__esModule: true,
default: function BackgroundTaskReplayView() {
return <div data-testid="background-task-replay-view">Background Task Replay View</div>
},
}))
vi.mock("@src/components/mcp/McpView", () => ({
__esModule: true,
default: function McpView() {
@ -206,6 +224,38 @@ describe("App", () => {
expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument()
})
it("switches to background tasks view when receiving backgroundTasksButtonClicked action", async () => {
render(<AppWithProviders />)
act(() => {
triggerMessage("backgroundTasksButtonClicked")
})
const bgTaskView = await screen.findByTestId("background-task-view")
expect(bgTaskView).toBeInTheDocument()
const chatView = screen.getByTestId("chat-view")
expect(chatView.getAttribute("data-hidden")).toBe("true")
})
it("returns to chat view when clicking done in background tasks view", async () => {
render(<AppWithProviders />)
act(() => {
triggerMessage("backgroundTasksButtonClicked")
})
const bgTaskView = await screen.findByTestId("background-task-view")
act(() => {
bgTaskView.click()
})
const chatView = screen.getByTestId("chat-view")
expect(chatView.getAttribute("data-hidden")).toBe("false")
expect(screen.queryByTestId("background-task-view")).not.toBeInTheDocument()
})
it.each(["history"])("returns to chat view when clicking done in %s view", async (view) => {
render(<AppWithProviders />)

View file

@ -0,0 +1,67 @@
import { memo, useCallback, useState } from "react"
import { ArrowLeft } from "lucide-react"
import BackgroundTasksList from "./BackgroundTasksList"
import BackgroundTaskReplayView from "./BackgroundTaskReplayView"
type BackgroundTaskSubView = "list" | "replay"
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.
*/
const BackgroundTaskView = memo(({ onClose }: BackgroundTaskViewProps) => {
const [subView, setSubView] = useState<BackgroundTaskSubView>("list")
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const handleSelectTask = useCallback((taskId: string) => {
setSelectedTaskId(taskId)
setSubView("replay")
}, [])
const handleBackToList = useCallback(() => {
setSelectedTaskId(null)
setSubView("list")
}, [])
return (
<div className="flex flex-col h-full" data-testid="background-task-view">
{/* Top header bar -- only shown in list view since replay has its own header */}
{subView === "list" && (
<div
className="flex items-center gap-2 px-4 py-2 border-b"
style={{
borderColor: "var(--vscode-panel-border)",
backgroundColor: "var(--vscode-sideBar-background)",
}}
data-testid="background-task-view-header">
<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="background-task-view-back">
<ArrowLeft size={16} />
<span>Back to chat</span>
</button>
<span className="text-vscode-descriptionForeground text-sm ml-2 font-medium">Background Tasks</span>
</div>
)}
{/* Sub-view content */}
<div className="flex-1 overflow-hidden">
{subView === "list" && <BackgroundTasksList onSelectTask={handleSelectTask} />}
{subView === "replay" && selectedTaskId && (
<BackgroundTaskReplayView taskId={selectedTaskId} onClose={handleBackToList} />
)}
</div>
</div>
)
})
BackgroundTaskView.displayName = "BackgroundTaskView"
export default BackgroundTaskView

View file

@ -0,0 +1,165 @@
import { memo, useMemo } from "react"
import { Clock, CheckCircle2, AlertCircle, Play } from "lucide-react"
import type { HistoryItem } from "@roo-code/types"
import { useExtensionState } from "@src/context/ExtensionStateContext"
export interface BackgroundTasksListProps {
onSelectTask: (taskId: string) => void
}
type TaskStatus = "active" | "completed" | "delegated" | "unknown"
function getTaskStatus(item: HistoryItem): TaskStatus {
return item.status ?? "unknown"
}
function getStatusIcon(status: TaskStatus) {
switch (status) {
case "active":
return <Play size={14} className="text-vscode-charts-green" />
case "completed":
return <CheckCircle2 size={14} className="text-vscode-descriptionForeground" />
case "delegated":
return <Clock size={14} className="text-vscode-charts-yellow" />
default:
return <AlertCircle size={14} className="text-vscode-descriptionForeground" />
}
}
function getStatusLabel(status: TaskStatus): string {
switch (status) {
case "active":
return "Running"
case "completed":
return "Completed"
case "delegated":
return "Delegated"
default:
return "Unknown"
}
}
function formatTimestamp(ts: number): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) {
return "just now"
}
if (diffMins < 60) {
return `${diffMins}m ago`
}
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) {
return `${diffHours}h ago`
}
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
}
function truncateTask(task: string, maxLen: number = 80): string {
if (task.length <= maxLen) {
return task
}
return task.slice(0, maxLen) + "..."
}
/**
* Displays a list of background tasks (subtasks / child tasks) from the task history.
* Each item shows status, task description, mode, and timestamp.
* Clicking a task navigates to its replay view.
*/
const BackgroundTasksList = memo(({ onSelectTask }: BackgroundTasksListProps) => {
const { taskHistory, currentTaskItem } = useExtensionState()
// Filter to show tasks that have a parentTaskId (i.e., subtasks / background tasks)
// Exclude the current foreground task
const backgroundTasks = useMemo(() => {
return taskHistory
.filter((item) => item.parentTaskId && item.id !== currentTaskItem?.id)
.sort((a, b) => b.ts - a.ts)
}, [taskHistory, currentTaskItem?.id])
const activeTasks = useMemo(() => backgroundTasks.filter((t) => t.status === "active"), [backgroundTasks])
if (backgroundTasks.length === 0) {
return (
<div
className="flex flex-col items-center justify-center h-full"
style={{ padding: "40px 20px" }}
data-testid="background-tasks-empty">
<p className="text-vscode-descriptionForeground text-sm text-center">No background tasks yet.</p>
<p className="text-vscode-descriptionForeground text-xs text-center mt-2">
Background tasks will appear here when subtasks are spawned via the new_task tool.
</p>
</div>
)
}
return (
<div className="flex flex-col h-full" data-testid="background-tasks-list">
{/* Summary header */}
<div
className="flex items-center gap-2 px-4 py-2 text-xs text-vscode-descriptionForeground border-b"
style={{ borderColor: "var(--vscode-panel-border)" }}>
{activeTasks.length > 0 && (
<span className="flex items-center gap-1">
<Play size={12} className="text-vscode-charts-green" />
{activeTasks.length} active
</span>
)}
<span>{backgroundTasks.length} total</span>
</div>
{/* Task list */}
<div className="flex-1 overflow-y-auto">
{backgroundTasks.map((item) => {
const status = getTaskStatus(item)
return (
<button
key={item.id}
className="w-full text-left px-4 py-3 border-b cursor-pointer bg-transparent hover:bg-vscode-list-hoverBackground transition-colors"
style={{
borderColor: "var(--vscode-panel-border)",
color: "var(--vscode-foreground)",
}}
onClick={() => onSelectTask(item.id)}
data-testid={`background-task-item-${item.id}`}>
<div className="flex items-center gap-2 mb-1">
{getStatusIcon(status)}
<span className="text-xs font-medium">{getStatusLabel(status)}</span>
{item.mode && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: "var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
}}>
{item.mode}
</span>
)}
<span className="text-xs text-vscode-descriptionForeground ml-auto">
{formatTimestamp(item.ts)}
</span>
</div>
<div className="text-sm">{truncateTask(item.task)}</div>
{item.totalCost > 0 && (
<div className="text-xs text-vscode-descriptionForeground mt-1">
Cost: ${item.totalCost.toFixed(4)}
</div>
)}
</button>
)
})}
</div>
</div>
)
})
BackgroundTasksList.displayName = "BackgroundTasksList"
export default BackgroundTasksList

View file

@ -0,0 +1,136 @@
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskView.spec.tsx
import React from "react"
import { render, screen, fireEvent, act } from "@/utils/test-utils"
import BackgroundTaskView from "../BackgroundTaskView"
// Mock use-sound
vi.mock("use-sound", () => ({
default: vi.fn().mockImplementation(() => [vi.fn()]),
}))
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock ExtensionStateContext
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn().mockReturnValue({
taskHistory: [
{
id: "bg-task-1",
number: 1,
ts: Date.now() - 60000,
task: "Research API docs",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.001,
parentTaskId: "parent-1",
status: "completed",
mode: "ask",
},
{
id: "bg-task-2",
number: 2,
ts: Date.now(),
task: "Implement feature",
tokensIn: 200,
tokensOut: 100,
totalCost: 0.002,
parentTaskId: "parent-1",
status: "active",
mode: "code",
},
],
currentTaskItem: null,
clineMessages: [],
mcpServers: [],
mode: "code",
apiConfiguration: {},
}),
ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// Mock ChatRow for BackgroundTaskReplayView
vi.mock("../ChatRow", () => ({
default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) {
return <div data-testid="chat-row">{message.text ?? "message"}</div>
},
}))
describe("BackgroundTaskView", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders the list view by default", () => {
render(<BackgroundTaskView onClose={vi.fn()} />)
expect(screen.getByTestId("background-task-view")).toBeTruthy()
expect(screen.getByTestId("background-task-view-header")).toBeTruthy()
expect(screen.getByTestId("background-tasks-list")).toBeTruthy()
expect(screen.getByText("Background Tasks")).toBeTruthy()
})
it("calls onClose when back-to-chat button is clicked", () => {
const onClose = vi.fn()
render(<BackgroundTaskView onClose={onClose} />)
fireEvent.click(screen.getByTestId("background-task-view-back"))
expect(onClose).toHaveBeenCalledTimes(1)
})
it("navigates to replay view when a task is clicked", () => {
render(<BackgroundTaskView onClose={vi.fn()} />)
// Click on a task to open replay
fireEvent.click(screen.getByTestId("background-task-item-bg-task-1"))
// Should now show the replay view (in loading state), not the list
expect(screen.getByTestId("replay-loading")).toBeTruthy()
expect(screen.queryByTestId("background-tasks-list")).toBeNull()
})
it("navigates back to list from replay view via back button", () => {
render(<BackgroundTaskView onClose={vi.fn()} />)
// Navigate to replay
fireEvent.click(screen.getByTestId("background-task-item-bg-task-1"))
expect(screen.getByTestId("replay-loading")).toBeTruthy()
// Simulate messages arriving so replay-back-button appears
act(() => {
const event = new MessageEvent("message", {
data: {
type: "backgroundTaskMessages",
backgroundTaskId: "bg-task-1",
backgroundTaskMessages: [{ ts: 1000, type: "say", say: "text", text: "Hello" }],
},
})
window.dispatchEvent(event)
})
// Click back button in replay view
fireEvent.click(screen.getByTestId("replay-back-button"))
// Should return to list view
expect(screen.getByTestId("background-tasks-list")).toBeTruthy()
})
it("hides the top header when in replay view (replay has its own header)", () => {
render(<BackgroundTaskView onClose={vi.fn()} />)
// Header should be visible in list view
expect(screen.getByTestId("background-task-view-header")).toBeTruthy()
// Navigate to replay
fireEvent.click(screen.getByTestId("background-task-item-bg-task-1"))
// Top header should be hidden -- replay has its own back button
expect(screen.queryByTestId("background-task-view-header")).toBeNull()
})
})

View file

@ -0,0 +1,153 @@
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTasksList.spec.tsx
import React from "react"
import { render, screen, fireEvent } from "@/utils/test-utils"
import BackgroundTasksList from "../BackgroundTasksList"
// Mock use-sound
vi.mock("use-sound", () => ({
default: vi.fn().mockImplementation(() => [vi.fn()]),
}))
const mockUseExtensionState = vi.fn()
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: (...args: any[]) => mockUseExtensionState(...args),
ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
function createHistoryItem(overrides: Record<string, any> = {}) {
return {
id: "task-1",
number: 1,
ts: Date.now(),
task: "Test background task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.001,
parentTaskId: "parent-1",
status: "completed" as const,
mode: "code",
...overrides,
}
}
describe("BackgroundTasksList", () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseExtensionState.mockReturnValue({
taskHistory: [],
currentTaskItem: null,
})
})
it("shows empty state when no background tasks exist", () => {
render(<BackgroundTasksList onSelectTask={vi.fn()} />)
expect(screen.getByTestId("background-tasks-empty")).toBeTruthy()
expect(screen.getByText(/No background tasks yet/)).toBeTruthy()
})
it("shows tasks that have a parentTaskId", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: [
createHistoryItem({ id: "task-1", task: "Background task one", parentTaskId: "parent-1" }),
createHistoryItem({ id: "task-2", task: "Foreground task (no parent)", parentTaskId: undefined }),
createHistoryItem({ id: "task-3", task: "Background task two", parentTaskId: "parent-1" }),
],
currentTaskItem: null,
})
render(<BackgroundTasksList onSelectTask={vi.fn()} />)
expect(screen.getByTestId("background-tasks-list")).toBeTruthy()
expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy()
expect(screen.getByTestId("background-task-item-task-3")).toBeTruthy()
// Foreground task without parentTaskId should NOT appear
expect(screen.queryByTestId("background-task-item-task-2")).toBeNull()
})
it("excludes the current foreground task from the list", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: [
createHistoryItem({ id: "task-1", task: "Background subtask", parentTaskId: "parent-1" }),
createHistoryItem({ id: "task-current", task: "Current task", parentTaskId: "parent-1" }),
],
currentTaskItem: { id: "task-current" },
})
render(<BackgroundTasksList onSelectTask={vi.fn()} />)
expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy()
expect(screen.queryByTestId("background-task-item-task-current")).toBeNull()
})
it("calls onSelectTask when a task item is clicked", () => {
const onSelectTask = vi.fn()
mockUseExtensionState.mockReturnValue({
taskHistory: [createHistoryItem({ id: "task-1", task: "Click me", parentTaskId: "parent-1" })],
currentTaskItem: null,
})
render(<BackgroundTasksList onSelectTask={onSelectTask} />)
fireEvent.click(screen.getByTestId("background-task-item-task-1"))
expect(onSelectTask).toHaveBeenCalledWith("task-1")
})
it("shows task status badges", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: [
createHistoryItem({ id: "task-active", status: "active", parentTaskId: "parent-1" }),
createHistoryItem({ id: "task-done", status: "completed", parentTaskId: "parent-1" }),
],
currentTaskItem: null,
})
render(<BackgroundTasksList onSelectTask={vi.fn()} />)
expect(screen.getByText("Running")).toBeTruthy()
expect(screen.getByText("Completed")).toBeTruthy()
})
it("shows active count in summary header", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: [
createHistoryItem({ id: "task-1", status: "active", parentTaskId: "parent-1" }),
createHistoryItem({ id: "task-2", status: "active", parentTaskId: "parent-1" }),
createHistoryItem({ id: "task-3", status: "completed", parentTaskId: "parent-1" }),
],
currentTaskItem: null,
})
render(<BackgroundTasksList onSelectTask={vi.fn()} />)
expect(screen.getByText("2 active")).toBeTruthy()
expect(screen.getByText("3 total")).toBeTruthy()
})
it("shows mode badge when task has a mode", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: [createHistoryItem({ id: "task-1", mode: "architect", parentTaskId: "parent-1" })],
currentTaskItem: null,
})
render(<BackgroundTasksList onSelectTask={vi.fn()} />)
expect(screen.getByText("architect")).toBeTruthy()
})
it("truncates long task descriptions", () => {
const longTask = "A".repeat(100)
mockUseExtensionState.mockReturnValue({
taskHistory: [createHistoryItem({ id: "task-1", task: longTask, parentTaskId: "parent-1" })],
currentTaskItem: null,
})
render(<BackgroundTasksList onSelectTask={vi.fn()} />)
// Should be truncated at 80 chars + "..."
expect(screen.getByText("A".repeat(80) + "...")).toBeTruthy()
})
})