Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 3d163bd

Browse files
authored
feat(mobile): stop a running cloud run (port #3382) (#3418)
1 parent dccfb47 commit 3d163bd

9 files changed

Lines changed: 254 additions & 3 deletions

File tree

apps/mobile/src/app/task/[id].tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { getTask, runTaskInCloud } from "@/features/tasks/api";
1818
import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader";
1919
import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge";
2020
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
21+
import { StopRunButton } from "@/features/tasks/components/StopRunButton";
2122
import { TaskSessionView } from "@/features/tasks/components/TaskSessionView";
2223
import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt";
2324
import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt";
@@ -49,7 +50,14 @@ import {
4950
import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore";
5051
import { useTaskStore } from "@/features/tasks/stores/taskStore";
5152
import type { Task } from "@/features/tasks/types";
52-
import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity";
53+
import {
54+
confirmStopRun,
55+
isTaskRunning,
56+
} from "@/features/tasks/utils/archiveGuard";
57+
import {
58+
countUserMessages,
59+
getSessionActivityPhase,
60+
} from "@/features/tasks/utils/sessionActivity";
5361
import { useScreenInsets } from "@/hooks/useScreenInsets";
5462
import {
5563
ANALYTICS_EVENTS,
@@ -101,6 +109,7 @@ export default function TaskDetailScreen() {
101109
getSessionForTask,
102110
setFocusedTaskId,
103111
steerQueuedMessage,
112+
stopRun,
104113
} = useTaskSessionStore();
105114

106115
useEffect(() => {
@@ -471,6 +480,37 @@ export default function TaskDetailScreen() {
471480
cancelPrompt(taskId).catch(() => {});
472481
}, [taskId, cancelPrompt]);
473482

483+
const handleStopRun = useCallback(() => {
484+
if (!taskId) return;
485+
confirmStopRun(() => {
486+
const promptsSent = countUserMessages(getSessionForTask(taskId)?.events);
487+
stopRun(taskId)
488+
.then((ok) => {
489+
if (ok) {
490+
analytics.track(ANALYTICS_EVENTS.TASK_RUN_STOPPED, {
491+
task_id: taskId,
492+
execution_type: "cloud",
493+
prompts_sent: promptsSent,
494+
});
495+
} else {
496+
Alert.alert(
497+
"Couldn't stop",
498+
"The run could not be stopped. Please try again.",
499+
);
500+
}
501+
})
502+
.catch(() => {});
503+
});
504+
}, [taskId, stopRun, analytics, getSessionForTask]);
505+
506+
const canStopRun =
507+
!!task &&
508+
!!session &&
509+
!session.terminalStatus &&
510+
!session.stopRequested &&
511+
task.latest_run?.environment !== "local" &&
512+
isTaskRunning(task);
513+
474514
const handleRetry = useCallback(async () => {
475515
if (!taskId || !task) return;
476516
try {
@@ -587,7 +627,9 @@ export default function TaskDetailScreen() {
587627
title={showLoading ? "Loading..." : task?.title || "Task"}
588628
subtitle={task?.repository ?? undefined}
589629
rightSlot={
590-
prUrl ? (
630+
canStopRun ? (
631+
<StopRunButton onPress={handleStopRun} />
632+
) : prUrl ? (
591633
<>
592634
<PrDiffStatsBadge prUrl={prUrl} />
593635
<PrStatusBadge prUrl={prUrl} />

apps/mobile/src/features/tasks/api.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ vi.mock("@/lib/api", () => ({
2424
}),
2525
}));
2626

27-
import { runTaskInCloud } from "./api";
27+
import { cancelRun, HttpError, runTaskInCloud } from "./api";
2828

2929
function bodyOf(call: unknown): Record<string, unknown> {
3030
const [, init] = call as [string, RequestInit];
@@ -78,3 +78,56 @@ describe("runTaskInCloud", () => {
7878
expect(init.body).toBeUndefined();
7979
});
8080
});
81+
82+
describe("cancelRun", () => {
83+
beforeEach(() => {
84+
mockFetch.mockReset();
85+
});
86+
87+
it("POSTs to the run cancel endpoint with an empty body", async () => {
88+
mockFetch.mockResolvedValue(
89+
new Response(JSON.stringify({ status: "cancelled" }), { status: 200 }),
90+
);
91+
92+
const result = await cancelRun("task-1", "run-1");
93+
94+
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
95+
expect(url).toBe(
96+
"https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/cancel/",
97+
);
98+
expect(init.method).toBe("POST");
99+
expect(bodyOf(mockFetch.mock.calls[0])).toEqual({});
100+
expect(result).toEqual({ status: "cancelled" });
101+
});
102+
103+
it("forwards a reason when provided", async () => {
104+
mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
105+
106+
await cancelRun("task-1", "run-1", "user requested");
107+
108+
expect(bodyOf(mockFetch.mock.calls[0])).toEqual({
109+
reason: "user requested",
110+
});
111+
});
112+
113+
it("throws with the backend error message on failure", async () => {
114+
mockFetch.mockResolvedValue(
115+
new Response(JSON.stringify({ error: "Run already finished" }), {
116+
status: 409,
117+
}),
118+
);
119+
120+
await expect(cancelRun("task-1", "run-1")).rejects.toMatchObject({
121+
status: 409,
122+
message: expect.stringContaining("Run already finished"),
123+
});
124+
});
125+
126+
it("falls back to a generic message when the body has no error", async () => {
127+
mockFetch.mockResolvedValue(new Response("boom", { status: 500 }));
128+
129+
await expect(cancelRun("task-1", "run-1")).rejects.toBeInstanceOf(
130+
HttpError,
131+
);
132+
});
133+
});

apps/mobile/src/features/tasks/api.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,36 @@ export async function getTaskRun(
540540
return await response.json();
541541
}
542542

543+
export async function cancelRun(
544+
taskId: string,
545+
runId: string,
546+
reason?: string,
547+
): Promise<{ status?: string }> {
548+
const baseUrl = getBaseUrl();
549+
const projectId = getProjectId();
550+
551+
const response = await authedFetch(
552+
`${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/cancel/`,
553+
{
554+
method: "POST",
555+
body: JSON.stringify(reason ? { reason } : {}),
556+
},
557+
);
558+
559+
if (!response.ok) {
560+
const payload = (await response.json().catch(() => null)) as {
561+
error?: unknown;
562+
} | null;
563+
const message =
564+
typeof payload?.error === "string" && payload.error
565+
? payload.error
566+
: "Failed to stop run";
567+
throw new HttpError(response.status, response.statusText, message);
568+
}
569+
570+
return (await response.json().catch(() => ({}))) as { status?: string };
571+
}
572+
543573
export async function appendTaskRunLog(
544574
taskId: string,
545575
runId: string,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Text } from "@components/text";
2+
import { Stop } from "phosphor-react-native";
3+
import { Pressable } from "react-native";
4+
import { useThemeColors } from "@/lib/theme";
5+
6+
interface StopRunButtonProps {
7+
onPress: () => void;
8+
}
9+
10+
export function StopRunButton({ onPress }: StopRunButtonProps) {
11+
const themeColors = useThemeColors();
12+
return (
13+
<Pressable
14+
onPress={onPress}
15+
hitSlop={8}
16+
className="h-8 flex-row items-center gap-1 rounded-full border border-status-error/40 bg-status-error/10 px-2.5 active:opacity-60"
17+
>
18+
<Stop size={14} color={themeColors.status.error} weight="fill" />
19+
<Text className="font-medium text-[13px] text-status-error">Stop</Text>
20+
</Pressable>
21+
);
22+
}

apps/mobile/src/features/tasks/stores/taskSessionStore.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePreferencesStore } from "@/features/preferences/stores/preferencesSt
66
import { logger } from "@/lib/logger";
77
import {
88
CloudCommandError,
9+
cancelRun,
910
getTask,
1011
runTaskInCloud,
1112
sendCloudCommand,
@@ -319,6 +320,10 @@ export interface TaskSession {
319320
// the running turn, which would abort an in-flight compaction, so queued
320321
// messages are held until compaction ends.
321322
isCompacting?: boolean;
323+
// True once the user has requested the whole run be stopped, until the run
324+
// reaches a terminal status. Hides the Stop control so it can't be tapped
325+
// twice while the cancel is in flight.
326+
stopRequested?: boolean;
322327
}
323328

324329
interface TaskSessionStore {
@@ -345,6 +350,9 @@ interface TaskSessionStore {
345350
},
346351
) => Promise<void>;
347352
cancelPrompt: (taskId: string) => Promise<boolean>;
353+
/** Cancel the whole cloud run. Optimistically marks the session stop-requested
354+
* and reverts on failure. Returns false if there is no session or the API fails. */
355+
stopRun: (taskId: string) => Promise<boolean>;
348356
/** Send a prompt now, interrupting the running turn first if one is live. */
349357
sendInterrupting: (
350358
taskId: string,
@@ -804,6 +812,45 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
804812
}
805813
},
806814

815+
stopRun: async (taskId: string) => {
816+
const session = get().getSessionForTask(taskId);
817+
if (!session) return false;
818+
const runId = session.taskRunId;
819+
820+
const previous = {
821+
stopRequested: session.stopRequested,
822+
isPromptPending: session.isPromptPending,
823+
};
824+
set((state) => ({
825+
sessions: {
826+
...state.sessions,
827+
[runId]: {
828+
...state.sessions[runId],
829+
stopRequested: true,
830+
isPromptPending: false,
831+
},
832+
},
833+
}));
834+
835+
try {
836+
await cancelRun(taskId, runId);
837+
return true;
838+
} catch (error) {
839+
log.error("Failed to stop cloud run", error);
840+
set((state) => {
841+
const current = state.sessions[runId];
842+
if (!current) return state;
843+
return {
844+
sessions: {
845+
...state.sessions,
846+
[runId]: { ...current, ...previous },
847+
},
848+
};
849+
});
850+
return false;
851+
}
852+
},
853+
807854
sendInterrupting: async (taskId, prompt, attachments) => {
808855
// The cloud has no mid-turn inject, so steering interrupts the running
809856
// turn and resends as a fresh prompt.

apps/mobile/src/features/tasks/utils/archiveGuard.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,14 @@ export function confirmArchiveRunningTask(
1919
],
2020
);
2121
}
22+
23+
export function confirmStopRun(onConfirm: () => void): void {
24+
Alert.alert(
25+
"Stop this run?",
26+
"This cancels the running agent. You can start a new run afterwards.",
27+
[
28+
{ text: "Cancel", style: "cancel" },
29+
{ text: "Stop", style: "destructive", onPress: onConfirm },
30+
],
31+
);
32+
}

apps/mobile/src/features/tasks/utils/sessionActivity.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
11
import { describe, expect, it } from "vitest";
22
import type { SessionEvent } from "../types";
33
import {
4+
countUserMessages,
45
getSessionActivityPhase,
56
isSessionAwaitingUserInput,
67
} from "./sessionActivity";
78

9+
function buildUserMessage(text: string): SessionEvent {
10+
return {
11+
type: "session_update",
12+
ts: 1,
13+
notification: {
14+
update: {
15+
sessionUpdate: "user_message_chunk",
16+
content: { type: "text", text },
17+
},
18+
},
19+
} satisfies SessionEvent;
20+
}
21+
22+
describe("countUserMessages", () => {
23+
it("counts only user_message_chunk events", () => {
24+
expect(
25+
countUserMessages([
26+
buildUserMessage("hello"),
27+
buildQuestionToolCall("pending"),
28+
buildUserMessage("again"),
29+
]),
30+
).toBe(2);
31+
});
32+
33+
it("returns 0 for no events", () => {
34+
expect(countUserMessages()).toBe(0);
35+
});
36+
});
37+
838
function buildQuestionToolCall(
939
status: "pending" | "in_progress" | "completed",
1040
) {

apps/mobile/src/features/tasks/utils/sessionActivity.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ export function isSessionAwaitingUserInput(
9999
return awaitingUserInput;
100100
}
101101

102+
export function countUserMessages(events: SessionEvent[] = []): number {
103+
return events.filter(
104+
(e) =>
105+
e.type === "session_update" &&
106+
e.notification.update?.sessionUpdate === "user_message_chunk",
107+
).length;
108+
}
109+
102110
export function getSessionActivityPhase(args: {
103111
retrying: boolean;
104112
session?: SessionActivityState | null;

apps/mobile/src/lib/analytics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const ANALYTICS_EVENTS = {
1616
SIGN_IN_COMPLETED: "Sign in completed",
1717
SIGN_IN_FAILED: "Sign in failed",
1818
PROMPT_SENT: "Prompt sent",
19+
TASK_RUN_STOPPED: "Task run stopped",
1920
} as const;
2021

2122
export type SignInMethod = "oauth" | "dev_api_key" | "qr_scan";
@@ -172,6 +173,12 @@ export interface PromptSentProperties {
172173
is_steer: boolean;
173174
}
174175

176+
export interface TaskRunStoppedProperties {
177+
task_id: string;
178+
execution_type: "cloud";
179+
prompts_sent?: number;
180+
}
181+
175182
export type EventPropertyMap = {
176183
[ANALYTICS_EVENTS.INBOX_VIEWED]: InboxViewedProperties;
177184
[ANALYTICS_EVENTS.INBOX_REPORT_OPENED]: InboxReportOpenedProperties;
@@ -182,6 +189,7 @@ export type EventPropertyMap = {
182189
[ANALYTICS_EVENTS.SIGN_IN_COMPLETED]: SignInCompletedProperties;
183190
[ANALYTICS_EVENTS.SIGN_IN_FAILED]: SignInFailedProperties;
184191
[ANALYTICS_EVENTS.PROMPT_SENT]: PromptSentProperties;
192+
[ANALYTICS_EVENTS.TASK_RUN_STOPPED]: TaskRunStoppedProperties;
185193
};
186194

187195
export interface Analytics {

0 commit comments

Comments
 (0)