mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
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:
parent
770a2209ea
commit
670032f5b8
10 changed files with 604 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const commandIds = [
|
|||
"acceptInput",
|
||||
"focusPanel",
|
||||
"toggleAutoApprove",
|
||||
"backgroundTasksButtonClicked",
|
||||
] as const
|
||||
|
||||
export type CommandId = (typeof commandIds)[number]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
67
webview-ui/src/components/chat/BackgroundTaskView.tsx
Normal file
67
webview-ui/src/components/chat/BackgroundTaskView.tsx
Normal 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
|
||||
165
webview-ui/src/components/chat/BackgroundTasksList.tsx
Normal file
165
webview-ui/src/components/chat/BackgroundTasksList.tsx
Normal 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
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue