Skip to content

Commit 2e31bf9

Browse files
committed
fix(mobile): stop background work after the turn settles
1 parent 8bc40b4 commit 2e31bf9

4 files changed

Lines changed: 95 additions & 23 deletions

File tree

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ export interface ThreadDetailScreenProps {
200200
readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
201201
readonly onRemoveDraftImage: (imageId: string) => void;
202202
readonly onStopThread: () => void;
203+
readonly stoppingBackgroundWork: boolean;
204+
readonly onStopBackgroundWork: () => void;
203205
readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise<MessageId | null>;
204206
readonly onReconnectEnvironment: () => void;
205207
/** Whether the model picker may offer providers other than this thread's. */
@@ -470,6 +472,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
470472
if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
471473
return {
472474
kind: "waiting",
475+
stopping: props.stoppingBackgroundWork,
476+
onStop: props.onStopBackgroundWork,
473477
label: pendingBackgroundWork.title,
474478
accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items
475479
.map((item) => item.label)

‎apps/mobile/src/features/threads/ThreadRouteScreen.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,15 @@ function ThreadRouteContent(
342342
const gitActions = useSelectedThreadGitActions();
343343
const requests = useSelectedThreadRequests();
344344
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
345+
const [backgroundStopRequest, setBackgroundStopRequest] = useState<{
346+
threadKey: string;
347+
} | null>(null);
348+
const backgroundThreadKey = selectedThread
349+
? scopedThreadKey(selectedThread.environmentId, selectedThread.id)
350+
: null;
351+
if (backgroundStopRequest !== null && backgroundStopRequest.threadKey !== backgroundThreadKey) {
352+
setBackgroundStopRequest(null);
353+
}
345354
const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
346355
label: "load earlier thread history",
347356
reportFailure: false,
@@ -686,6 +695,23 @@ function ThreadRouteContent(
686695
});
687696
}, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]);
688697

698+
const handleStopBackgroundWork = useCallback(async () => {
699+
if (!selectedThread) {
700+
return;
701+
}
702+
const request = {
703+
threadKey: scopedThreadKey(selectedThread.environmentId, selectedThread.id),
704+
};
705+
setBackgroundStopRequest(request);
706+
await interruptThreadTurn({
707+
environmentId: selectedThread.environmentId,
708+
input: { threadId: selectedThread.id },
709+
});
710+
// Acceptance does not confirm termination. Allow retry while tasks remain,
711+
// including when the interrupt fails, matching the web client's behavior.
712+
setBackgroundStopRequest((current) => (current === request ? null : current));
713+
}, [interruptThreadTurn, selectedThread]);
714+
689715
const handleOpenTerminal = useCallback(
690716
(nextTerminalId?: string | null) => {
691717
terminalDebugLog("terminal-menu:open-existing", {
@@ -1066,6 +1092,8 @@ function ThreadRouteContent(
10661092
onRemoveDraftImage={composer.onRemoveDraftImage}
10671093
serverConfig={serverConfig}
10681094
onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
1095+
stoppingBackgroundWork={backgroundStopRequest !== null}
1096+
onStopBackgroundWork={handleStopBackgroundWork}
10691097
onSendMessage={composer.onSendMessage}
10701098
onReconnectEnvironment={handleReconnectEnvironment}
10711099
canSwitchThreadProvider={composer.canSwitchThreadProvider}

‎apps/mobile/src/features/threads/floating-working-control.tsx‎

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -151,18 +151,18 @@ export function FloatingWorkingControl(props: {
151151
return null;
152152
}
153153

154-
// The queue, agents, and reconnect labels have separate tap targets.
155-
const statusInteractive = props.status?.kind === "connection";
154+
// Each interactive segment has its own tap target.
155+
const statusInteractive = props.status?.kind === "connection" || props.status?.kind === "waiting";
156156
const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview;
157157
// The host stays centered on the capsule, but its measurement constraint
158158
// comes from the overlay, independent of the capsule's current width.
159159
const statusContent =
160160
props.status !== null ? (
161161
<View
162-
pointerEvents={props.status.kind === "connection" ? "box-none" : "none"}
162+
pointerEvents={statusInteractive ? "box-none" : "none"}
163163
className="h-11 items-center justify-center"
164164
>
165-
<Animated.View className="h-11" style={capsuleSizerStyle} />
165+
<Animated.View pointerEvents="none" className="h-11" style={capsuleSizerStyle} />
166166
<View
167167
pointerEvents={statusInteractive ? "box-none" : "none"}
168168
className="absolute h-11 items-center justify-center"
@@ -386,25 +386,9 @@ function FloatingStatusLabel(props: {
386386
);
387387
}
388388
if (props.status.kind === "waiting") {
389-
return (
390-
<StatusLabelRow
391-
key="waiting"
392-
accessibilityLabel={props.status.accessibilityLabel}
393-
className="gap-2"
394-
onLayout={props.onLayout}
395-
>
396-
<SymbolView
397-
name={{ ios: "bolt", android: "bolt" }}
398-
size={13}
399-
tintColorClassName="foreground"
400-
type="monochrome"
401-
/>
402-
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
403-
{props.status.label}
404-
</Text>
405-
</StatusLabelRow>
406-
);
389+
return <BackgroundWorkLabel status={props.status} onLayout={props.onLayout} />;
407390
}
391+
408392
if (props.status.kind === "preparing") {
409393
return (
410394
<StatusLabelRow
@@ -471,6 +455,56 @@ function StatusLabelRow(props: {
471455
);
472456
}
473457

458+
// Stop is its own button inside the row rather than the whole pill, so a
459+
// stray tap on the label cannot end background work. Everything but Stop
460+
// passes touches through to the feed, like the non-interactive labels.
461+
function BackgroundWorkLabel(props: {
462+
readonly status: Extract<FloatingWorkingStatus, { kind: "waiting" }>;
463+
readonly onLayout: (event: LayoutChangeEvent) => void;
464+
}) {
465+
return (
466+
<Animated.View
467+
pointerEvents="box-none"
468+
className="absolute max-w-full"
469+
entering={LABEL_ENTERING}
470+
exiting={LABEL_EXITING}
471+
onLayout={props.onLayout}
472+
>
473+
<View pointerEvents="box-none" className="h-11 flex-row items-center pl-4">
474+
<View
475+
pointerEvents="none"
476+
accessible
477+
accessibilityLabel={props.status.accessibilityLabel}
478+
className="min-w-0 shrink flex-row items-center gap-2"
479+
>
480+
<SymbolView
481+
name={{ ios: "bolt", android: "bolt" }}
482+
size={13}
483+
tintColorClassName="foreground"
484+
type="monochrome"
485+
/>
486+
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
487+
{props.status.label}
488+
</Text>
489+
</View>
490+
<View pointerEvents="none" className="ml-3 h-4 w-px bg-border" />
491+
<Pressable
492+
accessibilityLabel={props.status.stopping ? "Stopping" : "Stop background work"}
493+
accessibilityRole="button"
494+
accessibilityState={{ disabled: props.status.stopping }}
495+
className="h-11 justify-center pl-3 pr-4 active:opacity-70"
496+
disabled={props.status.stopping}
497+
onPress={props.status.onStop}
498+
>
499+
<Text className="font-t3-medium text-xs text-danger-foreground">
500+
{props.status.stopping ? "Stopping…" : "Stop"}
501+
</Text>
502+
</Pressable>
503+
</View>
504+
</Animated.View>
505+
);
506+
}
507+
474508
function WorkingDuration(props: {
475509
readonly startedAt: string;
476510
readonly onLayout: (event: LayoutChangeEvent) => void;

‎apps/mobile/src/features/threads/floating-working-status.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ export type FloatingWorkingStatus =
1010
| { readonly kind: "syncing"; readonly label: string }
1111
| { readonly kind: "compacting" }
1212
// The turn settled while background work it started still runs.
13-
| { readonly kind: "waiting"; readonly label: string; readonly accessibilityLabel: string }
13+
| {
14+
readonly kind: "waiting";
15+
readonly label: string;
16+
readonly accessibilityLabel: string;
17+
readonly stopping: boolean;
18+
readonly onStop: () => void;
19+
}
1420
// A task whose thread the server has not created yet: the worktree may
1521
// still be checking out, so there is no turn to time.
1622
| { readonly kind: "preparing"; readonly label: string }

0 commit comments

Comments
 (0)