Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

/**
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export interface CreateTaskOptions {
experiments?: Record<string, boolean>
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
Expand Down
21 changes: 21 additions & 0 deletions src/core/task-persistence/TaskHistoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 ──────────────────────────────

/**
Expand Down
54 changes: 54 additions & 0 deletions src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
})
6 changes: 5 additions & 1 deletion src/core/task-persistence/taskMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -38,6 +40,7 @@ export async function taskMetadata({
mode,
apiConfigName,
initialStatus,
background,
}: TaskMetadataOptions) {
const taskDir = await getTaskDirectoryPath(globalStoragePath, id)

Expand Down Expand Up @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskEvents> implements TaskLike {
Expand Down Expand Up @@ -406,7 +406,7 @@ export class Task extends EventEmitter<TaskEvents> 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
Expand Down
26 changes: 26 additions & 0 deletions webview-ui/src/components/history/HistoryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setLastNonRelevantSort,
showAllWorkspaces,
setShowAllWorkspaces,
showBackgroundTasks,
setShowBackgroundTasks,
} = useTaskSearch()
const { t } = useAppTranslation()

Expand Down Expand Up @@ -223,6 +225,30 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</SelectItem>
</SelectContent>
</Select>
<Select
value={showBackgroundTasks ? "all" : "foregroundOnly"}
onValueChange={(value) => setShowBackgroundTasks(value === "all")}>
<SelectTrigger className="flex-1">
<SelectValue>
{t("history:filter.prefix")}{" "}
{t(`history:filter.${showBackgroundTasks ? "all" : "foregroundOnly"}`)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<span className="codicon codicon-list-flat" />
{t("history:filter.all")}
</div>
</SelectItem>
<SelectItem value="foregroundOnly">
<div className="flex items-center gap-2">
<span className="codicon codicon-eye-closed" />
{t("history:filter.foregroundOnly")}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>

{/* Select all control in selection mode */}
Expand Down
16 changes: 15 additions & 1 deletion webview-ui/src/components/history/TaskItemFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +28,20 @@ const TaskItemFooter: React.FC<TaskItemFooterProps> = ({
return (
<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center">
<div className="flex gap-1 items-center text-vscode-descriptionForeground/60">
{/* Background task tag */}
{item.background && (
<>
{item.status === "interrupted" ? (
<AlertTriangle className="size-3 text-vscode-editorWarning-foreground" />
) : (
<Layers className="size-3" />
)}
<span>
{item.status === "interrupted" ? t("history:interruptedTag") : t("history:backgroundTag")}
</span>
<span>&middot;</span>
</>
)}
{/* Subtask tag */}
{isSubtask && (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TaskItemFooter item={backgroundItem} variant="full" />)

expect(screen.getByText("history:backgroundTag")).toBeInTheDocument()
})

it("does not show background tag when item.background is falsy", () => {
render(<TaskItemFooter item={mockItem} variant="full" />)

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(<TaskItemFooter item={interruptedItem} variant="full" />)

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(<TaskItemFooter item={activeBackgroundItem} variant="full" />)

expect(screen.getByText("history:backgroundTag")).toBeInTheDocument()
expect(screen.queryByText("history:interruptedTag")).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
8 changes: 7 additions & 1 deletion webview-ui/src/components/history/useTaskSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const useTaskSearch = () => {
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
const [showAllWorkspaces, setShowAllWorkspaces] = useState(false)
const [showBackgroundTasks, setShowBackgroundTasks] = useState(true)

useEffect(() => {
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
Expand All @@ -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, {
Expand Down Expand Up @@ -88,5 +92,7 @@ export const useTaskSearch = () => {
setLastNonRelevantSort,
showAllWorkspaces,
setShowAllWorkspaces,
showBackgroundTasks,
setShowBackgroundTasks,
}
}
Loading
Loading