feat: add breakpoints to todo lists for auto-approve pause

This PR implements breakpoints for to-do list items that pause auto-approve
mode when a todo with a breakpoint becomes in_progress.

Changes:
- Add breakpoint field to TodoItem type in packages/types/src/todo.ts
- Add breakpoint toggle UI in TodoListDisplay.tsx with visual indicator
- Add toggleTodoBreakpoint message handler in webviewMessageHandler.ts
- Update checkAutoApproval to pause when in-progress todo has breakpoint
- Add breakpointHit message to trigger celebration sound on pause
- Play celebration sound (same as task complete) when breakpoint is hit
- Breakpoints are session-only (disposable, not persisted across sessions)
- Breakpoints clear automatically after being hit

Closes #10399
This commit is contained in:
Roo Code 2026-01-01 08:12:55 +00:00
parent 2068531801
commit 98d2fbe981
9 changed files with 119 additions and 31 deletions

View file

@ -14,6 +14,7 @@ export const todoItemSchema = z.object({
id: z.string(),
content: z.string(),
status: todoStatusSchema,
breakpoint: z.boolean().optional(), // When true, auto-approve pauses when this item becomes in_progress
})
export type TodoItem = z.infer<typeof todoItemSchema>

View file

@ -1,4 +1,4 @@
import { type ClineAsk, type McpServerUse, type FollowUpData, isNonBlockingAsk } from "@roo-code/types"
import { type ClineAsk, type McpServerUse, type FollowUpData, type TodoItem, isNonBlockingAsk } from "@roo-code/types"
import type { ClineSayTool, ExtensionState } from "../../shared/ExtensionMessage"
import { ClineAskResponse } from "../../shared/WebviewMessage"
@ -32,7 +32,7 @@ export type AutoApprovalStateOptions =
export type CheckAutoApprovalResult =
| { decision: "approve" }
| { decision: "deny" }
| { decision: "ask" }
| { decision: "ask"; breakpointHit?: boolean }
| {
decision: "timeout"
timeout: number
@ -44,11 +44,13 @@ export async function checkAutoApproval({
ask,
text,
isProtected,
todoList,
}: {
state?: Pick<ExtensionState, AutoApprovalState | AutoApprovalStateOptions>
ask: ClineAsk
text?: string
isProtected?: boolean
todoList?: TodoItem[]
}): Promise<CheckAutoApprovalResult> {
if (isNonBlockingAsk(ask)) {
return { decision: "approve" }
@ -58,6 +60,15 @@ export async function checkAutoApproval({
return { decision: "ask" }
}
// Check for breakpoint on current in-progress todo item
// If there's an in-progress todo with a breakpoint, pause auto-approval
if (todoList && todoList.length > 0) {
const inProgressTodo = todoList.find((t) => t.status === "in_progress")
if (inProgressTodo?.breakpoint) {
return { decision: "ask", breakpointHit: true }
}
}
if (ask === "followup") {
if (state.alwaysAllowFollowupQuestions === true) {
try {

View file

@ -1150,12 +1150,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Automatically approve if the ask according to the user's settings.
const provider = this.providerRef.deref()
const state = provider ? await provider.getState() : undefined
const approval = await checkAutoApproval({ state, ask: type, text, isProtected })
const approval = await checkAutoApproval({ state, ask: type, text, isProtected, todoList: this.todoList })
if (approval.decision === "approve") {
this.approveAsk()
} else if (approval.decision === "deny") {
this.denyAsk()
} else if (approval.decision === "ask" && "breakpointHit" in approval && approval.breakpointHit) {
// Breakpoint hit: send notification to play celebration sound and clear the breakpoint
provider?.postMessageToWebview({ type: "breakpointHit" })
// Clear the breakpoint after it's hit (disposable, as per user request)
const inProgressTodo = this.todoList?.find((t) => t.status === "in_progress")
if (inProgressTodo) {
inProgressTodo.breakpoint = false
}
} else if (approval.decision === "timeout") {
// Store the auto-approval timeout so it can be cancelled if user interacts
this.autoApprovalTimeoutRef = setTimeout(() => {

View file

@ -1735,6 +1735,17 @@ export const webviewMessageHandler = async (
}
break
}
case "toggleTodoBreakpoint": {
const { todoId, breakpoint } = (message.values ?? {}) as { todoId?: string; breakpoint?: boolean }
const currentTask = provider.getCurrentTask()
if (todoId && currentTask?.todoList) {
const todo = currentTask.todoList.find((t) => t.id === todoId)
if (todo) {
todo.breakpoint = breakpoint
}
}
break
}
case "refreshCustomTools": {
try {
const toolDirs = getRooDirectoriesForCwd(getCurrentCwd()).map((dir) => path.join(dir, "tools"))

View file

@ -128,6 +128,7 @@ export interface ExtensionMessage {
| "dismissedUpsells"
| "organizationSwitchResult"
| "interactionRequired"
| "breakpointHit"
| "browserSessionUpdate"
| "browserSessionNavigate"
| "claudeCodeRateLimits"

View file

@ -29,6 +29,7 @@ export type EditQueuedMessagePayload = Pick<QueuedMessage, "id" | "text" | "imag
export interface WebviewMessage {
type:
| "updateTodoList"
| "toggleTodoBreakpoint"
| "deleteMultipleTasksWithIds"
| "currentApiConfigName"
| "saveApiConfiguration"

View file

@ -867,6 +867,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "interactionRequired":
playSound("notification")
break
case "breakpointHit":
// Play celebration sound when a todo breakpoint is hit (same as task complete)
playSound("celebration")
break
}
// textAreaRef.current is not explicitly required here since React
// guarantees that ref will be stable across re-renders, and we're

View file

@ -1,10 +1,18 @@
import { cn } from "@/lib/utils"
import { vscode } from "@/utils/vscode"
import { t } from "i18next"
import { ArrowRight, Check, ListChecks, SquareDashed } from "lucide-react"
import { useState, useRef, useMemo, useEffect } from "react"
import { ArrowRight, Check, Circle, ListChecks, SquareDashed } from "lucide-react"
import { useState, useRef, useMemo, useEffect, useCallback } from "react"
type TodoStatus = "completed" | "in_progress" | "pending"
interface TodoItem {
id: string
content: string
status: TodoStatus
breakpoint?: boolean
}
function getTodoIcon(status: TodoStatus | null) {
switch (status) {
case "completed":
@ -16,22 +24,42 @@ function getTodoIcon(status: TodoStatus | null) {
}
}
export function TodoListDisplay({ todos }: { todos: any[] }) {
export function TodoListDisplay({ todos }: { todos: TodoItem[] }) {
const [isCollapsed, setIsCollapsed] = useState(true)
// Session-only breakpoint state (doesn't persist across sessions)
const [breakpoints, setBreakpoints] = useState<Record<string, boolean>>({})
const ulRef = useRef<HTMLUListElement>(null)
const itemRefs = useRef<(HTMLLIElement | null)[]>([])
const scrollIndex = useMemo(() => {
const inProgressIdx = todos.findIndex((todo: any) => todo.status === "in_progress")
const inProgressIdx = todos.findIndex((todo: TodoItem) => todo.status === "in_progress")
if (inProgressIdx !== -1) return inProgressIdx
return todos.findIndex((todo: any) => todo.status !== "completed")
return todos.findIndex((todo: TodoItem) => todo.status !== "completed")
}, [todos])
// Find the most important todo to display when collapsed
const mostImportantTodo = useMemo(() => {
const inProgress = todos.find((todo: any) => todo.status === "in_progress")
const inProgress = todos.find((todo: TodoItem) => todo.status === "in_progress")
if (inProgress) return inProgress
return todos.find((todo: any) => todo.status !== "completed")
return todos.find((todo: TodoItem) => todo.status !== "completed")
}, [todos])
// Toggle breakpoint on a todo item
const toggleBreakpoint = useCallback(
(todoId: string, e: React.MouseEvent) => {
e.stopPropagation() // Prevent collapsing the list
const newBreakpointState = !breakpoints[todoId]
setBreakpoints((prev) => ({
...prev,
[todoId]: newBreakpointState,
}))
// Notify extension about the breakpoint change
vscode.postMessage({
type: "toggleTodoBreakpoint",
values: { todoId, breakpoint: newBreakpointState },
})
},
[breakpoints],
)
useEffect(() => {
if (isCollapsed) return
if (!ulRef.current) return
@ -78,26 +106,47 @@ export function TodoListDisplay({ todos }: { todos: any[] }) {
)}
</div>
{/* Inline expanded list */}
{!isCollapsed && (
<ul ref={ulRef} className="list-none max-h-[300px] overflow-y-auto mt-2 -mb-1 pb-0 px-2 cursor-default">
{todos.map((todo: any, idx: number) => {
const icon = getTodoIcon(todo.status as TodoStatus)
return (
<li
key={todo.id || todo.content}
ref={(el) => (itemRefs.current[idx] = el)}
className={cn(
"font-light flex flex-row gap-2 items-start min-h-[20px] leading-normal mb-2",
todo.status === "in_progress" && "text-vscode-charts-yellow",
todo.status !== "in_progress" && todo.status !== "completed" && "opacity-60",
)}>
{icon}
<span>{todo.content}</span>
</li>
)
})}
</ul>
)}
{!isCollapsed && (
<ul ref={ulRef} className="list-none max-h-[300px] overflow-y-auto mt-2 -mb-1 pb-0 px-2 cursor-default">
{todos.map((todo: TodoItem, idx: number) => {
const icon = getTodoIcon(todo.status as TodoStatus)
const hasBreakpoint = breakpoints[todo.id] || false
const canHaveBreakpoint = todo.status === "pending" // Only pending items can have breakpoints
return (
<li
key={todo.id || todo.content}
ref={(el) => (itemRefs.current[idx] = el)}
className={cn(
"font-light flex flex-row gap-2 items-start min-h-[20px] leading-normal mb-2 group",
todo.status === "in_progress" && "text-vscode-charts-yellow",
todo.status !== "in_progress" && todo.status !== "completed" && "opacity-60",
)}>
{icon}
<span className="flex-1">{todo.content}</span>
{/* Breakpoint toggle button - only show for pending items */}
{canHaveBreakpoint && (
<button
onClick={(e) => toggleBreakpoint(todo.id, e)}
className={cn(
"shrink-0 p-0.5 rounded hover:bg-vscode-toolbar-hoverBackground transition-colors",
hasBreakpoint
? "text-vscode-charts-red opacity-100"
: "text-vscode-descriptionForeground opacity-0 group-hover:opacity-60",
)}
title={
hasBreakpoint ? t("chat:todo.removeBreakpoint") : t("chat:todo.addBreakpoint")
}>
<Circle
className={cn("size-2.5", hasBreakpoint && "fill-current")}
strokeWidth={hasBreakpoint ? 0 : 2}
/>
</button>
)}
</li>
)
})}
</ul>
)}
</div>
)
}

View file

@ -470,6 +470,8 @@
"complete": "{{total}} to-dos done",
"updated": "Updated the to-do list",
"completed": "Completed",
"started": "Started"
"started": "Started",
"addBreakpoint": "Add breakpoint (pauses auto-approve)",
"removeBreakpoint": "Remove breakpoint"
}
}