diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts
index afac5a1a23f..7d17ee086ca 100644
--- a/src/main/claude/claude-background-task-tracker.test.ts
+++ b/src/main/claude/claude-background-task-tracker.test.ts
@@ -55,8 +55,8 @@ describe('ClaudeBackgroundTaskTracker', () => {
expect(tracker.state).toEqual({
state: 'monitoring',
tasks: [
- { id: 'agent-1', kind: 'agent' },
- { id: 'agent-2', kind: 'agent' }
+ { id: 'agent-1', kind: 'agent', stoppable: false },
+ { id: 'agent-2', kind: 'agent', stoppable: false }
]
})
// The turn IS the outcome of work the provider marked foreground.
@@ -66,6 +66,29 @@ describe('ClaudeBackgroundTaskTracker', () => {
expect(tracker.stoppableTaskIds).toEqual([])
})
+ it('marks a foreground row not stoppable and leaves a backgrounded row alone', () => {
+ // `stopTask` has no foreground target, so the row must not offer a Stop that
+ // would silently do nothing. A backgrounded row stays untouched on the wire.
+ const tracker = new ClaudeBackgroundTaskTracker()
+ tracker.observe({ type: 'user' }, true)
+ tracker.observe(
+ system('task_started', {
+ task_id: 'fore-1',
+ task_type: 'local_agent',
+ is_backgrounded: false
+ })
+ )
+ tracker.observe(
+ system('task_started', { task_id: 'back-1', task_type: 'local_agent', is_backgrounded: true })
+ )
+
+ expect(tracker.state?.tasks).toEqual([
+ { id: 'fore-1', kind: 'agent', stoppable: false },
+ { id: 'back-1', kind: 'agent' }
+ ])
+ expect(tracker.stoppableTaskIds).toEqual(['back-1'])
+ })
+
it('uses an explicit background update for a foreground task and ignores progress alone', () => {
const tracker = new ClaudeBackgroundTaskTracker()
tracker.observe({ type: 'user' }, true)
@@ -80,7 +103,7 @@ describe('ClaudeBackgroundTaskTracker', () => {
tracker.observe(system('task_progress', { task_id: 'task-1', description: 'still working' }))
).toBe(false)
// Live while the turn runs, then retired by that turn's `result`.
- expect(tracker.state?.tasks).toEqual([{ id: 'task-1', kind: 'command' }])
+ expect(tracker.state?.tasks).toEqual([{ id: 'task-1', kind: 'command', stoppable: false }])
tracker.observe(result())
expect(tracker.state).toBeNull()
diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts
index 7667f5c27c3..b5bd6e13d26 100644
--- a/src/main/claude/claude-background-task-tracker.ts
+++ b/src/main/claude/claude-background-task-tracker.ts
@@ -265,6 +265,9 @@ export class ClaudeBackgroundTaskTracker {
details.push({
id,
kind: task.kind,
+ // Foreground work is not a target `stopTask` accepts, so the row says so
+ // rather than drawing a Stop that would silently do nothing.
+ ...(task.backgrounded ? {} : { stoppable: false }),
...(task.description ? { description: task.description } : {})
})
}
diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx
index f038bb1fcd9..435012539c5 100644
--- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx
@@ -80,9 +80,7 @@ describe('NativeChatBackgroundTasksStatus row glyphs', () => {
const monitorGlyph = glyphClassFor('monitor')
cleanup()
const { container } = render()
- const dotGlyph = lucideGlyphName(
- container.querySelector('svg')?.getAttribute('class') ?? ''
- )
+ const dotGlyph = lucideGlyphName(container.querySelector('svg')?.getAttribute('class') ?? '')
expect(monitorGlyph).not.toBe('')
expect(monitorGlyph).toBe(dotGlyph)
})
@@ -122,6 +120,30 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => {
expect(screen.getByLabelText('Stop background tasks')).toBeInTheDocument()
})
+ it('withholds a row stop the host reported it cannot act on', () => {
+ // Claude publishes foreground rows with `stoppable: false`: the session
+ // accepts targeted stops, but not for this row.
+ const onStop = vi.fn()
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
+
+ expect(screen.getByText('in-turn subagent')).toBeInTheDocument()
+ expect(screen.queryByLabelText('Stop in-turn subagent')).not.toBeInTheDocument()
+ expect(screen.getByLabelText('Stop backgrounded subagent')).toBeInTheDocument()
+ })
+
it('offers no stop at all when the provider exposes none', () => {
// Codex: a Stop button here would be a control that cannot act.
renderStrip({ supportsTaskStop: false, supportsStopAll: false })
diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
index 9d3e899a359..4a3f968d512 100644
--- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
@@ -13,8 +13,17 @@ function backgroundTaskLabel(task: AgentSessionBackgroundTask): string {
return task.description || backgroundTaskKindLabel(task.kind)
}
+/** Absent means stoppable: a host that predates the field published only rows
+ * its stop could act on. Only an explicit `false` withholds the control. */
+function backgroundTaskStoppable(task: AgentSessionBackgroundTask): boolean {
+ return task.stoppable !== false
+}
+
export function NativeChatBackgroundTasksStatus(props: {
tasks: readonly AgentSessionBackgroundTask[]
+ /** Whether the host accepts a targeted stop at all. A row still withholds its
+ * own button when the row itself reports it cannot be stopped — same rule as
+ * `supportsStopAll`: never a button that cannot act. */
supportsTaskStop: boolean
/** False when the provider exposes no honest stop at all; the fallback
* control is hidden rather than offering a button that cannot act. */
@@ -75,12 +84,9 @@ export function NativeChatBackgroundTasksStatus(props: {
key={task.id}
className="flex min-w-0 items-center gap-2 text-foreground/80"
>
-
+
{label}
- {props.supportsTaskStop ? (
+ {props.supportsTaskStop && backgroundTaskStoppable(task) ? (