Files
orca/mobile/src/session/MobileNativeChatBackgroundTasks.tsx
T
Brennan Benson e2234a37ef Let a Codex chat stop a command it left running in the background (#26780)
* Let a Codex chat stop a command it left running in the background

A command Codex started and left running (a dev server, a watcher) showed in
the background-tasks strip above the composer with no Stop, because Codex
reported no stop it could honour. Codex 0.140+ has one: the app-server's
experimental thread/backgroundTerminals/terminate names the process by the
processId on the command's item, and thread/backgroundTerminals/list shows
what still runs.

The command tracker now keeps each command's processId. The first time a
running command carries one, the session probes its own app-server once with
a one-row list; a listing marks the app-server's backgrounded commands
stoppable and the session as offering per-row Stop and Stop all, through the
fields the strip already reads. Any refusal (an older Codex answers "unknown
variant"), timeout or malformed answer leaves it as today: no Stop.

A Stop terminates each named process, then confirms against the thread's own
list. A thread Codex says it unloaded counts as stopped; a dropped connection
does not, since lost contact is no proof a process ended. An app-server that
times out is not asked again. The killed process's own item/completed removes
its row, as before.

* Ask Codex again after a probe gets no answer, and say when a background Stop fails

A background-terminal probe that timed out or lost its request used to hide
Stop for the rest of the app-server's life. Only a refusal by name (unknown
variant, method not found) or a malformed listing now settles it as
unsupported; anything else is asked again on the next update.

A background Stop whose agent failed (a process still listed after its stop,
a timeout) used to answer "nothing stopped" and the button simply came back.
The host now refuses it, so both clients show their usual notice: "The
background task wasn't stopped." This covers Claude's background Stop too.

* Say a background Stop wasn't stopped only when the agent shows it still running

A timeout or lost contact with the agent leaves the outcome unknown, so it now answers
with the existing outcome-unknown refusal instead of "The background task wasn't stopped."

* Keep a background task's Stop in its stopping state until the row leaves

A confirmed Stop re-enabled its button for the moment between the host's answer
and the roster dropping the row. The button now holds while the host says it
stopped the task and the strip still lists it, on desktop and phone; a failed,
unconfirmed or nothing-stopped answer, or the row coming back, offers Stop again.

* Keep Grok's own subagent Stop refusal quiet, and a reply about another subagent unconfirmed

* Report Grok's own refusal of a subagent Stop as not stopped

Grok answers an ended or unknown subagent with a normal reply, so a JSON-RPC refusal means it did nothing and the subagent still runs.
2026-10-09 15:44:34 -07:00

398 lines
12 KiB
TypeScript

import { memo, useMemo, useState } from 'react'
import {
Dimensions,
type LayoutChangeEvent,
Pressable,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native'
import {
Activity,
Bot,
ChevronDown,
CircleHelp,
SquareTerminal,
Workflow
} from 'lucide-react-native'
import type { AgentChildRowModel } from '../../../src/shared/agent-child-row-model'
import type { AgentSessionBackgroundTask } from '../../../src/shared/agent-session-wire'
import { sayBackgroundTaskEnglish as say } from '../../../src/shared/background-task-copy'
import {
backgroundTasksHeaderContent,
backgroundTasksHeaderText,
NARROW_BACKGROUND_TASKS_STRIP_REM
} from '../../../src/shared/background-task-header-content'
import {
backgroundTaskGroupLabel,
backgroundTaskRowMeta,
backgroundTaskRowStopId,
backgroundTasksStripTicks,
buildBackgroundTaskGroups,
buildBackgroundTaskGroupsFromViews
} from '../../../src/shared/background-task-roster'
import { AGENT_WORKING_COLOR, AgentStateDot } from '../components/AgentStateDot'
import { useNow } from '../hooks/use-now'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks'
import {
useMobileBackgroundTaskStops,
type MobileBackgroundTaskStopping as Stopping
} from './use-mobile-background-task-stops'
import { mobileAgentChildRowName, mobileAgentChildRowText } from './mobile-agent-child-row-text'
type TaskKind = AgentSessionBackgroundTask['kind']
function KindIcon({ kind, size }: { kind: TaskKind; size: number }): React.JSX.Element {
const props = { size, color: kindIconColor(kind), strokeWidth: 2 }
switch (kind) {
case 'agent':
return <Bot {...props} />
case 'command':
return <SquareTerminal {...props} />
case 'monitor':
return <Activity {...props} />
case 'workflow':
return <Workflow {...props} />
case 'unknown':
return <CircleHelp {...props} />
}
}
// React Native has no root font; its points stand in for desktop's 16 px rem.
const NARROW_STRIP_WIDTH = NARROW_BACKGROUND_TASKS_STRIP_REM * 16
// Monitoring is drawn in the same amber everywhere it shows; the other kinds are plain markers.
function kindIconColor(kind: TaskKind): string {
return kind === 'monitor' ? AGENT_WORKING_COLOR : colors.textMuted
}
function TaskRow({
row,
now,
hostNow,
supportsTaskStop,
stopping,
onStop
}: {
row: AgentChildRowModel
now: number
/** `now` on the host's clock, which stamps every elapsed anchor. */
hostNow: number
supportsTaskStop: boolean
stopping: Stopping
onStop: (taskId: string) => void
}): React.JSX.Element {
const { lead, trail } = mobileAgentChildRowText(row, now)
const meta = backgroundTaskRowMeta(row, hostNow)
const stopId = backgroundTaskRowStopId(row, supportsTaskStop)
const busy = stopId !== null && stopping.taskIds.has(stopId)
return (
<>
<View testID="background-task-row" style={styles.row}>
<KindIcon kind={row.kind} size={14} />
<AgentStateDot state={row.displayState} />
<Text style={styles.rowText} numberOfLines={1} ellipsizeMode="tail">
<Text style={styles.lead}>{lead}</Text>
{trail ? <Text style={styles.trail}>{` · ${trail}`}</Text> : null}
</Text>
{meta ? (
// Reticks every second; screen readers hear the row's state, not its clock.
<Text style={styles.meta} importantForAccessibility="no" accessibilityElementsHidden>
{meta}
</Text>
) : null}
{stopId !== null ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={say('stopTask', { value0: mobileAgentChildRowName(row) })}
accessibilityState={{ disabled: busy }}
disabled={busy}
hitSlop={6}
style={({ pressed }) => [
styles.textAction,
pressed && styles.pressed,
busy && styles.disabled
]}
onPress={() => onStop(stopId)}
>
<Text style={styles.actionLabel}>{say('stop')}</Text>
</Pressable>
) : null}
</View>
{row.owned.length > 0 ? (
// Work a child owns (its shell, a nested agent) reads beneath it, not in its kind's group.
<View style={styles.owned}>
{row.owned.map((owned) => (
<TaskRow
key={owned.id}
row={owned}
now={now}
hostNow={hostNow}
supportsTaskStop={supportsTaskStop}
stopping={stopping}
onStop={onStop}
/>
))}
</View>
) : null}
</>
)
}
/** The session's running child work — agents, shells, monitors, workflows — as one collapsible box
* above the composer. Desktop parity: `NativeChatBackgroundTasksStatus`, from the same host roster
* and the same shared grouping, header and Stop rules. Mounted per conversation, so its open and
* stopping state outlive the moment a sequential fan-out's roster empties between children. */
function MobileNativeChatBackgroundTasksImpl({
tasks
}: {
tasks: MobileStructuredBackgroundTasks
}): React.JSX.Element | null {
const { view, rowContext } = tasks
const [expanded, setExpanded] = useState(false)
// Before the first layout, the window less the strip's own margins, so it opens in its final form.
const [narrow, setNarrow] = useState(
() => Dimensions.get('window').width - 2 * spacing.lg < NARROW_STRIP_WIDTH
)
const { stopping, onStop } = useMobileBackgroundTaskStops(tasks)
const groups = useMemo(
() =>
view.children !== undefined
? buildBackgroundTaskGroupsFromViews(view.children, rowContext)
: buildBackgroundTaskGroups(view.tasks, view.settledTasks, say),
[view.children, view.tasks, view.settledTasks, rowContext]
)
const now = useNow(1_000, view.show && backgroundTasksStripTicks(groups, expanded))
if (!view.show) {
return null
}
// Elapsed anchors are host-stamped; read them on the host's clock, as the turn bar's start is.
const hostNow = now - rowContext.hostClockOffsetMs
const header = backgroundTasksHeaderContent(groups, { narrow, now: hostNow }, say)
const onLayout = (event: LayoutChangeEvent): void => {
setNarrow(event.nativeEvent.layout.width < NARROW_STRIP_WIDTH)
}
return (
<View style={styles.list} onLayout={onLayout} testID="background-tasks-strip">
<View style={styles.box}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
accessibilityLabel={backgroundTasksHeaderText(header)}
hitSlop={spacing.xs}
style={({ pressed }) => [styles.header, pressed && styles.pressed]}
onPress={() => setExpanded(!expanded)}
>
<View style={styles.headerText}>
{header.segments.map((segment, index) => {
return (
<View key={segment.kind ?? 'total'} style={styles.segment}>
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
{segment.kind ? (
// The turn owns the voice: same icons, dimmed until it ends.
<View style={view.isMonitoring ? undefined : styles.dimmed}>
<KindIcon kind={segment.kind} size={12} />
</View>
) : null}
<Text style={styles.segmentText} numberOfLines={1} ellipsizeMode="tail">
{segment.text}
</Text>
</View>
)
})}
{header.detail ? (
<Text style={styles.detail} numberOfLines={1} ellipsizeMode="tail">
{header.segments.length > 0 ? `— ${header.detail}` : header.detail}
</Text>
) : null}
</View>
<View style={expanded ? styles.caretOpen : undefined}>
<ChevronDown size={14} color={colors.textMuted} strokeWidth={2} />
</View>
</Pressable>
{expanded ? (
<ScrollView style={styles.body} nestedScrollEnabled>
{groups.length > 0 ? (
groups.map((group) => (
<View key={group.kind} style={styles.group}>
<Text style={styles.groupLabel}>{backgroundTaskGroupLabel(group.kind, say)}</Text>
{group.tasks.map((entry) => (
<TaskRow
key={entry.row.id}
row={entry.row}
now={now}
hostNow={hostNow}
supportsTaskStop={view.supportsStop}
stopping={stopping}
onStop={onStop}
/>
))}
</View>
))
) : (
<Text style={styles.unavailable}>{say('detailsUnavailable')}</Text>
)}
{!view.supportsStop && view.supportsStopAll ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={say('stopAll')}
accessibilityState={{ disabled: stopping.all }}
disabled={stopping.all}
hitSlop={6}
style={({ pressed }) => [
styles.textAction,
styles.stopAll,
pressed && styles.pressed,
stopping.all && styles.disabled
]}
onPress={() => onStop()}
>
<Text style={styles.actionLabel}>{say('stop')}</Text>
</Pressable>
) : null}
</ScrollView>
) : null}
</View>
</View>
)
}
export const MobileNativeChatBackgroundTasks = memo(MobileNativeChatBackgroundTasksImpl)
// hitSlop requests 44pt hit rects; compact native parent bounds still limit their reach.
const MIN_TOUCH_TARGET = 44
const styles = StyleSheet.create({
list: {
marginHorizontal: spacing.lg,
marginVertical: spacing.xs
},
box: {
backgroundColor: colors.bgPanel,
borderRadius: radii.row,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
overflow: 'hidden'
},
header: {
minHeight: 36,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md
},
headerText: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
overflow: 'hidden'
},
// Every piece may shrink, so large Dynamic Type ellipsizes the header instead of clipping it.
segment: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
flexShrink: 1,
minWidth: 0
},
separator: {
color: colors.textMuted,
fontSize: typography.metaSize
},
segmentText: {
flexShrink: 1,
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '500'
},
detail: {
flexShrink: 1,
color: colors.textMuted,
fontSize: typography.metaSize
},
dimmed: {
opacity: 0.4
},
caretOpen: {
transform: [{ rotate: '180deg' }]
},
body: {
maxHeight: 220,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
group: {
paddingHorizontal: spacing.md,
paddingTop: spacing.xs,
paddingBottom: spacing.xs
},
groupLabel: {
color: colors.textMuted,
fontSize: 10,
fontFamily: typography.monoFamily,
letterSpacing: 0.8,
textTransform: 'uppercase',
paddingBottom: spacing.xs / 2
},
row: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
owned: {
marginLeft: spacing.lg
},
rowText: {
flex: 1,
minWidth: 0,
fontSize: typography.metaSize
},
lead: {
color: colors.textPrimary,
fontWeight: '500'
},
trail: {
color: colors.textMuted
},
meta: {
color: colors.textMuted,
fontSize: 10,
fontFamily: typography.monoFamily
},
unavailable: {
color: colors.textMuted,
fontSize: typography.metaSize,
padding: spacing.md
},
textAction: {
minHeight: 32,
minWidth: MIN_TOUCH_TARGET,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
borderRadius: radii.button
},
stopAll: {
alignSelf: 'flex-start',
marginHorizontal: spacing.sm
},
actionLabel: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '500'
},
pressed: {
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.5
}
})