From b946f5d2497f08700e4748ed6070491ecc992344 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 16:13:40 +0000 Subject: [PATCH] feat: add persistent background task history (Phase 7c) - Add background field to HistoryItem schema - Add interrupted status for background tasks stopped mid-execution - Add background option to TaskMetadataOptions - Detect and mark interrupted background tasks on TaskHistoryStore init - Add showBackgroundTasks filter to useTaskSearch hook - Add background task filter toggle to HistoryView UI - Add background/interrupted visual indicators in TaskItemFooter - Add i18n translation keys for background task labels - Add unit tests for all changes --- .../autocomplete/triggers/HistoryTrigger.tsx | 4 +- apps/cli/src/ui/types.ts | 3 +- packages/types/src/history.ts | 3 +- packages/types/src/task.ts | 2 +- src/core/task-persistence/TaskHistoryStore.ts | 21 +++++++ .../__tests__/TaskHistoryStore.spec.ts | 54 ++++++++++++++++ src/core/task-persistence/taskMetadata.ts | 6 +- src/core/task/Task.ts | 4 +- .../src/components/history/HistoryView.tsx | 26 ++++++++ .../src/components/history/TaskItemFooter.tsx | 16 ++++- .../history/__tests__/TaskItemFooter.spec.tsx | 29 +++++++++ .../history/__tests__/useTaskSearch.spec.tsx | 61 +++++++++++++++++++ .../src/components/history/useTaskSearch.ts | 8 ++- webview-ui/src/i18n/locales/en/history.json | 11 +++- 14 files changed, 237 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx index 443fdfa979..86e2fad9b4 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx @@ -21,7 +21,7 @@ export interface HistoryResult extends AutocompleteItem { /** Mode the task was run in */ mode?: string /** Task status */ - status?: "active" | "completed" | "delegated" + status?: "active" | "completed" | "delegated" | "interrupted" } /** @@ -178,7 +178,7 @@ export function toHistoryResult(item: { totalCost?: number workspace?: string mode?: string - status?: "active" | "completed" | "delegated" + status?: "active" | "completed" | "delegated" | "interrupted" }): HistoryResult { return { key: item.id, // Use task ID as the unique key diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index 3c45377c67..48d59d643d 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -109,7 +109,8 @@ export interface TaskHistoryItem { totalCost?: number workspace?: string mode?: string - status?: "active" | "completed" | "delegated" + status?: "active" | "completed" | "delegated" | "interrupted" + background?: boolean tokensIn?: number tokensOut?: number } diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index a60d1a75b6..67286692e8 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -20,7 +20,8 @@ export const historyItemSchema = z.object({ workspace: z.string().optional(), mode: z.string().optional(), apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature - status: z.enum(["active", "completed", "delegated"]).optional(), + background: z.boolean().optional(), // true if this was a background task + status: z.enum(["active", "completed", "delegated", "interrupted"]).optional(), delegatedToId: z.string().optional(), // Last child this parent delegated to childIds: z.array(z.string()).optional(), // All children spawned by this task awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated) diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 7447dc772e..572302861b 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -90,7 +90,7 @@ export interface CreateTaskOptions { experiments?: Record initialTodos?: TodoItem[] /** Initial status for the task's history item (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" + initialStatus?: "active" | "delegated" | "completed" | "interrupted" /** Whether to start the task loop immediately (default: true). * When false, the caller must invoke `task.start()` manually. */ startTask?: boolean diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 4157d8b9fb..b8f7084127 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -88,6 +88,9 @@ export class TaskHistoryStore { // 2. Reconcile cache against actual task directories on disk await this.reconcile() + // 2b. Mark interrupted background tasks (were active when VS Code closed) + this.markInterruptedBackgroundTasks() + // 3. Start fs.watch for cross-instance reactivity this.startWatcher() @@ -233,6 +236,24 @@ export class TaskHistoryStore { }) } + // ────────────────────────────── Background Task Recovery ────────────────────────────── + + /** + * Mark background tasks that were still active when VS Code closed as "interrupted". + * This runs after cache is loaded and reconciled during initialization. + */ + private markInterruptedBackgroundTasks(): void { + for (const [id, item] of this.cache) { + if (item.background && item.status === "active") { + this.cache.set(id, { ...item, status: "interrupted" }) + // Best-effort write of updated status to disk (fire-and-forget during init) + this.writeTaskFile({ ...item, status: "interrupted" }).catch((err) => { + console.error(`[TaskHistoryStore] Failed to mark background task ${id} as interrupted:`, err) + }) + } + } + } + // ────────────────────────────── Reconciliation ────────────────────────────── /** diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 8adc486160..c42a7d88ff 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -439,4 +439,58 @@ describe("TaskHistoryStore", () => { expect(store.get("gone-task")).toBeUndefined() }) }) + + describe("markInterruptedBackgroundTasks()", () => { + it("marks active background tasks as interrupted on initialize", async () => { + // Create a background task with active status before initializing + const taskDir = path.join(tmpDir, "tasks", "bg-active-task") + await fs.mkdir(taskDir, { recursive: true }) + const bgItem = makeHistoryItem({ + id: "bg-active-task", + background: true, + status: "active", + }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem)) + + await store.initialize() + + const result = store.get("bg-active-task") + expect(result).toBeDefined() + expect(result!.status).toBe("interrupted") + expect(result!.background).toBe(true) + }) + + it("does not mark completed background tasks as interrupted", async () => { + const taskDir = path.join(tmpDir, "tasks", "bg-completed-task") + await fs.mkdir(taskDir, { recursive: true }) + const bgItem = makeHistoryItem({ + id: "bg-completed-task", + background: true, + status: "completed", + }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem)) + + await store.initialize() + + const result = store.get("bg-completed-task") + expect(result).toBeDefined() + expect(result!.status).toBe("completed") + }) + + it("does not mark non-background active tasks as interrupted", async () => { + const taskDir = path.join(tmpDir, "tasks", "fg-active-task") + await fs.mkdir(taskDir, { recursive: true }) + const fgItem = makeHistoryItem({ + id: "fg-active-task", + status: "active", + }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(fgItem)) + + await store.initialize() + + const result = store.get("fg-active-task") + expect(result).toBeDefined() + expect(result!.status).toBe("active") + }) + }) }) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 4b77126971..979750c351 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -24,7 +24,9 @@ export type TaskMetadataOptions = { /** Provider profile name for the task (sticky profile feature) */ apiConfigName?: string /** Initial status for the task (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" + initialStatus?: "active" | "delegated" | "completed" | "interrupted" + /** Whether this is a background task */ + background?: boolean } export async function taskMetadata({ @@ -38,6 +40,7 @@ export async function taskMetadata({ mode, apiConfigName, initialStatus, + background, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -112,6 +115,7 @@ export async function taskMetadata({ mode, ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), ...(initialStatus && { status: initialStatus }), + ...(background && { background: true }), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 97f07fcc7a..65acb76a08 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -152,7 +152,7 @@ export interface TaskOptions extends CreateTaskOptions { initialTodos?: TodoItem[] workspacePath?: string /** Initial status for the task's history item (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" + initialStatus?: "active" | "delegated" | "completed" | "interrupted" } export class Task extends EventEmitter implements TaskLike { @@ -406,7 +406,7 @@ export class Task extends EventEmitter implements TaskLike { // Cloud Sync Tracking // Initial status for the task's history item (set at creation time to avoid race conditions) - private readonly initialStatus?: "active" | "delegated" | "completed" + private readonly initialStatus?: "active" | "delegated" | "completed" | "interrupted" // MessageManager for high-level message operations (lazy initialized) private _messageManager?: MessageManager diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 1d6de93e64..3672a4dacb 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -41,6 +41,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setLastNonRelevantSort, showAllWorkspaces, setShowAllWorkspaces, + showBackgroundTasks, + setShowBackgroundTasks, } = useTaskSearch() const { t } = useAppTranslation() @@ -223,6 +225,30 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { + {/* Select all control in selection mode */} diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index d0dc367e64..a1d440781e 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -6,7 +6,7 @@ import { ExportButton } from "./ExportButton" import { DeleteButton } from "./DeleteButton" import { StandardTooltip } from "../ui/standard-tooltip" import { useAppTranslation } from "@/i18n/TranslationContext" -import { Split } from "lucide-react" +import { Split, Layers, AlertTriangle } from "lucide-react" export interface TaskItemFooterProps { item: HistoryItem @@ -28,6 +28,20 @@ const TaskItemFooter: React.FC = ({ return (
+ {/* Background task tag */} + {item.background && ( + <> + {item.status === "interrupted" ? ( + + ) : ( + + )} + + {item.status === "interrupted" ? t("history:interruptedTag") : t("history:backgroundTag")} + + · + + )} {/* Subtask tag */} {isSubtask && ( <> diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx index aa334d94c2..337ec2aecd 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -94,4 +94,33 @@ describe("TaskItemFooter", () => { expect(screen.queryByText("history:subtaskTag")).not.toBeInTheDocument() }) + + it("shows background tag when item.background is true", () => { + const backgroundItem = { ...mockItem, background: true } + render() + + expect(screen.getByText("history:backgroundTag")).toBeInTheDocument() + }) + + it("does not show background tag when item.background is falsy", () => { + render() + + expect(screen.queryByText("history:backgroundTag")).not.toBeInTheDocument() + }) + + it("shows interrupted tag when item is a background task with interrupted status", () => { + const interruptedItem = { ...mockItem, background: true, status: "interrupted" as const } + render() + + expect(screen.getByText("history:interruptedTag")).toBeInTheDocument() + expect(screen.queryByText("history:backgroundTag")).not.toBeInTheDocument() + }) + + it("shows background tag instead of interrupted for active background tasks", () => { + const activeBackgroundItem = { ...mockItem, background: true, status: "active" as const } + render() + + expect(screen.getByText("history:backgroundTag")).toBeInTheDocument() + expect(screen.queryByText("history:interruptedTag")).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx index bea79814fa..b99c9ca598 100644 --- a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx +++ b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx @@ -284,4 +284,65 @@ describe("useTaskSearch", () => { // When not searching, it should fall back to newest expect(result.current.sortOption).toBe("mostRelevant") }) + + it("shows background tasks by default", () => { + const taskHistoryWithBackground: HistoryItem[] = [ + ...mockTaskHistory, + { + id: "task-bg", + number: 4, + task: "Background task", + ts: new Date("2022-02-18T12:00:00").getTime(), + tokensIn: 50, + tokensOut: 25, + totalCost: 0.005, + workspace: "/workspace/project1", + background: true, + }, + ] + + mockUseExtensionState.mockReturnValue({ + taskHistory: taskHistoryWithBackground, + cwd: "/workspace/project1", + } as any) + + const { result } = renderHook(() => useTaskSearch()) + + // Background tasks should be included by default + expect(result.current.showBackgroundTasks).toBe(true) + expect(result.current.tasks.some((task) => task.id === "task-bg")).toBe(true) + }) + + it("hides background tasks when showBackgroundTasks is false", () => { + const taskHistoryWithBackground: HistoryItem[] = [ + ...mockTaskHistory, + { + id: "task-bg", + number: 4, + task: "Background task", + ts: new Date("2022-02-18T12:00:00").getTime(), + tokensIn: 50, + tokensOut: 25, + totalCost: 0.005, + workspace: "/workspace/project1", + background: true, + }, + ] + + mockUseExtensionState.mockReturnValue({ + taskHistory: taskHistoryWithBackground, + cwd: "/workspace/project1", + } as any) + + const { result } = renderHook(() => useTaskSearch()) + + act(() => { + result.current.setShowBackgroundTasks(false) + }) + + // Background tasks should be hidden + expect(result.current.tasks.some((task) => task.id === "task-bg")).toBe(false) + // Non-background tasks should still be visible + expect(result.current.tasks.some((task) => task.id === "task-1")).toBe(true) + }) }) diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 3969985b98..e4cd6a98ca 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -12,6 +12,7 @@ export const useTaskSearch = () => { const [sortOption, setSortOption] = useState("newest") const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") const [showAllWorkspaces, setShowAllWorkspaces] = useState(false) + const [showBackgroundTasks, setShowBackgroundTasks] = useState(true) useEffect(() => { if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) { @@ -28,8 +29,11 @@ export const useTaskSearch = () => { if (!showAllWorkspaces) { tasks = tasks.filter((item) => item.workspace === cwd) } + if (!showBackgroundTasks) { + tasks = tasks.filter((item) => !item.background) + } return tasks - }, [taskHistory, showAllWorkspaces, cwd]) + }, [taskHistory, showAllWorkspaces, showBackgroundTasks, cwd]) const fzf = useMemo(() => { return new Fzf(presentableTasks, { @@ -88,5 +92,7 @@ export const useTaskSearch = () => { setLastNonRelevantSort, showAllWorkspaces, setShowAllWorkspaces, + showBackgroundTasks, + setShowBackgroundTasks, } } diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 85174890e1..6d1d54606f 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Subtask", "deleteWithSubtasks": "This will also delete {{count}} subtask(s). Are you sure?", "expandSubtasks": "Expand subtasks", - "collapseSubtasks": "Collapse subtasks" + "collapseSubtasks": "Collapse subtasks", + "backgroundTag": "Background", + "interruptedTag": "Interrupted", + "showBackgroundTasks": "Show background tasks", + "hideBackgroundTasks": "Hide background tasks", + "filter": { + "prefix": "Filter:", + "all": "All Tasks", + "foregroundOnly": "Foreground Only" + } }