Skip to content

Commit 0d728bc

Browse files
authored
fix(mobile): show stopped cloud runs as stopped, not failed (port #3697) (#3750)
1 parent 49dbaf6 commit 0d728bc

7 files changed

Lines changed: 164 additions & 49 deletions

File tree

apps/mobile/src/features/tasks/components/TaskSessionView.tsx

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ import type {
2828
SessionEvent,
2929
SessionNotification,
3030
SessionNotificationAttachment,
31+
TerminalStatus,
3132
} from "../types";
3233
import { CloudMessageAttachment } from "./CloudMessageAttachment";
3334
import { PlanApprovalCard } from "./PlanApprovalCard";
3435
import { PlanStatusBar } from "./PlanStatusBar";
3536
import { QuestionCard } from "./QuestionCard";
37+
import { TerminalStatusBanner } from "./TerminalStatusBanner";
3638

3739
interface PermissionResponseArgs {
3840
toolCallId: string;
@@ -57,7 +59,7 @@ interface TaskSessionViewProps {
5759
pendingPermissions?: Record<string, CloudPendingPermissionRequest>;
5860
isConnecting?: boolean;
5961
isThinking?: boolean;
60-
terminalStatus?: "failed" | "completed";
62+
terminalStatus?: TerminalStatus;
6163
lastError?: string | null;
6264
onRetry?: () => void;
6365
onOpenTask?: (taskId: string) => void;
@@ -1033,46 +1035,11 @@ export function TaskSessionView({
10331035
initialNumToRender={30}
10341036
ListHeaderComponent={
10351037
terminalStatus ? (
1036-
<View
1037-
className={`mx-4 mt-2 mb-4 rounded-lg px-4 py-3 ${
1038-
terminalStatus === "failed"
1039-
? "bg-status-error/10"
1040-
: "bg-status-success/10"
1041-
}`}
1042-
>
1043-
<Text
1044-
className={`font-semibold text-sm ${
1045-
terminalStatus === "failed"
1046-
? "text-status-error"
1047-
: "text-status-success"
1048-
}`}
1049-
>
1050-
{terminalStatus === "failed" ? "Run failed" : "Run completed"}
1051-
</Text>
1052-
{lastError && (
1053-
<Text className="mt-1 text-gray-11 text-xs">{lastError}</Text>
1054-
)}
1055-
{onRetry && (
1056-
<Pressable
1057-
onPress={onRetry}
1058-
className={`mt-2 self-start rounded-md px-3 py-1.5 ${
1059-
terminalStatus === "failed"
1060-
? "bg-status-error/20"
1061-
: "bg-status-success/20"
1062-
}`}
1063-
>
1064-
<Text
1065-
className={`font-medium text-xs ${
1066-
terminalStatus === "failed"
1067-
? "text-status-error"
1068-
: "text-status-success"
1069-
}`}
1070-
>
1071-
{terminalStatus === "failed" ? "Retry" : "Continue"}
1072-
</Text>
1073-
</Pressable>
1074-
)}
1075-
</View>
1038+
<TerminalStatusBanner
1039+
terminalStatus={terminalStatus}
1040+
lastError={lastError}
1041+
onRetry={onRetry}
1042+
/>
10761043
) : null
10771044
}
10781045
/>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createElement } from "react";
2+
import { act, create } from "react-test-renderer";
3+
import { describe, expect, it, vi } from "vitest";
4+
import {
5+
TerminalStatusBanner,
6+
type TerminalStatusBannerProps,
7+
} from "./TerminalStatusBanner";
8+
9+
function render(props: TerminalStatusBannerProps) {
10+
let renderer!: ReturnType<typeof create>;
11+
act(() => {
12+
renderer = create(createElement(TerminalStatusBanner, props));
13+
});
14+
return renderer;
15+
}
16+
17+
function renderedText(renderer: ReturnType<typeof create>): string {
18+
const acc: string[] = [];
19+
const walk = (node: unknown) => {
20+
if (typeof node === "string") {
21+
acc.push(node);
22+
} else if (Array.isArray(node)) {
23+
node.forEach(walk);
24+
} else if (node && typeof node === "object" && "children" in node) {
25+
walk((node as { children: unknown }).children);
26+
}
27+
};
28+
walk(renderer.toJSON());
29+
return acc.join(" ");
30+
}
31+
32+
describe("TerminalStatusBanner", () => {
33+
it.each([
34+
{ terminalStatus: "completed", label: "Run completed", button: "Continue" },
35+
{ terminalStatus: "failed", label: "Run failed", button: "Retry" },
36+
{ terminalStatus: "stopped", label: "Run stopped", button: "Continue" },
37+
] as const)(
38+
"shows $label with a $button action for a $terminalStatus run",
39+
({ terminalStatus, label, button }) => {
40+
const text = renderedText(render({ terminalStatus, onRetry: vi.fn() }));
41+
expect(text).toContain(label);
42+
expect(text).toContain(button);
43+
},
44+
);
45+
46+
it("does not label a stopped run as failed", () => {
47+
const text = renderedText(render({ terminalStatus: "stopped" }));
48+
expect(text).not.toContain("Run failed");
49+
expect(text).not.toContain("Retry");
50+
});
51+
52+
it("fires onRetry when the action is pressed", () => {
53+
const onRetry = vi.fn();
54+
const renderer = render({ terminalStatus: "stopped", onRetry });
55+
const pressable = renderer.root.findAll(
56+
(node) => node.props.onPress === onRetry,
57+
)[0];
58+
act(() => {
59+
pressable.props.onPress();
60+
});
61+
expect(onRetry).toHaveBeenCalledTimes(1);
62+
});
63+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Pressable, Text, View } from "react-native";
2+
import type { TerminalStatus } from "../types";
3+
4+
export interface TerminalStatusBannerProps {
5+
terminalStatus: TerminalStatus;
6+
lastError?: string | null;
7+
onRetry?: () => void;
8+
}
9+
10+
export function TerminalStatusBanner({
11+
terminalStatus,
12+
lastError,
13+
onRetry,
14+
}: TerminalStatusBannerProps) {
15+
const isFailed = terminalStatus === "failed";
16+
const label =
17+
terminalStatus === "failed"
18+
? "Run failed"
19+
: terminalStatus === "stopped"
20+
? "Run stopped"
21+
: "Run completed";
22+
23+
return (
24+
<View
25+
className={`mx-4 mt-2 mb-4 rounded-lg px-4 py-3 ${
26+
isFailed ? "bg-status-error/10" : "bg-status-success/10"
27+
}`}
28+
>
29+
<Text
30+
className={`font-semibold text-sm ${
31+
isFailed ? "text-status-error" : "text-status-success"
32+
}`}
33+
>
34+
{label}
35+
</Text>
36+
{lastError && (
37+
<Text className="mt-1 text-gray-11 text-xs">{lastError}</Text>
38+
)}
39+
{onRetry && (
40+
<Pressable
41+
onPress={onRetry}
42+
className={`mt-2 self-start rounded-md px-3 py-1.5 ${
43+
isFailed ? "bg-status-error/20" : "bg-status-success/20"
44+
}`}
45+
>
46+
<Text
47+
className={`font-medium text-xs ${
48+
isFailed ? "text-status-error" : "text-status-success"
49+
}`}
50+
>
51+
{isFailed ? "Retry" : "Continue"}
52+
</Text>
53+
</Pressable>
54+
)}
55+
</View>
56+
);
57+
}

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ import type {
3232
TaskRun,
3333
} from "../types";
3434
import { useMessageQueueStore } from "./messageQueueStore";
35-
import { type TaskSession, useTaskSessionStore } from "./taskSessionStore";
35+
import {
36+
mapTerminalStatus,
37+
type TaskSession,
38+
useTaskSessionStore,
39+
} from "./taskSessionStore";
3640
import { useTaskStore } from "./taskStore";
3741

3842
function seedSession(overrides: Partial<TaskSession> = {}): void {
@@ -47,6 +51,20 @@ function seedSession(overrides: Partial<TaskSession> = {}): void {
4751
useTaskSessionStore.setState({ sessions: { "run-1": session } });
4852
}
4953

54+
describe("mapTerminalStatus", () => {
55+
it.each([
56+
{ status: "completed", expected: "completed" },
57+
{ status: "failed", expected: "failed" },
58+
{ status: "cancelled", expected: "stopped" },
59+
{ status: "in_progress", expected: undefined },
60+
{ status: "queued", expected: undefined },
61+
{ status: undefined, expected: undefined },
62+
{ status: null, expected: undefined },
63+
] as const)("maps $status to $expected", ({ status, expected }) => {
64+
expect(mapTerminalStatus(status)).toBe(expected);
65+
});
66+
});
67+
5068
describe("steerQueuedMessage", () => {
5169
beforeEach(() => {
5270
useMessageQueueStore.setState({ queuesByTaskId: {} }, false);

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type SessionNotificationAttachment,
2828
type StoredLogEntry,
2929
type Task,
30+
type TerminalStatus,
3031
} from "../types";
3132
import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs";
3233
import { playbackRateForTaskDuration } from "../utils/playbackRate";
@@ -264,8 +265,8 @@ export interface TaskSession {
264265
// the log). Used to dedup the canonical copy against the echo.
265266
localUserEchoes?: Set<string>;
266267
// Terminal backend status for this run, populated by status updates so the
267-
// UI can surface "Run failed" / "Run completed".
268-
terminalStatus?: "failed" | "completed";
268+
// UI can surface "Run failed" / "Run completed" / "Run stopped".
269+
terminalStatus?: TerminalStatus;
269270
lastError?: string | null;
270271
// True when the user initiated work (new task, sendPrompt, resume) and
271272
// we should play a sound when control returns. False when reconnecting
@@ -362,11 +363,12 @@ const connectAttempts = new Set<string>();
362363
// queue twice.
363364
const flushingTasks = new Set<string>();
364365

365-
function mapTerminalStatus(
366+
export function mapTerminalStatus(
366367
status: string | undefined | null,
367-
): "completed" | "failed" | undefined {
368+
): TerminalStatus | undefined {
368369
if (status === "completed") return "completed";
369-
if (status === "failed" || status === "cancelled") return "failed";
370+
if (status === "failed") return "failed";
371+
if (status === "cancelled") return "stopped";
370372
return undefined;
371373
}
372374

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export type TaskRunStatus =
4545

4646
export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const;
4747

48+
// UI-facing terminal outcome for a run. `cancelled` maps to `stopped` (the user
49+
// deliberately halted it) so the UI can distinguish it from a real failure.
50+
export type TerminalStatus = "completed" | "failed" | "stopped";
51+
4852
export function isTerminalStatus(
4953
status: TaskRunStatus | string | null | undefined,
5054
): boolean {

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import type { SessionEvent, SessionNotification } from "../types";
1+
import type {
2+
SessionEvent,
3+
SessionNotification,
4+
TerminalStatus,
5+
} from "../types";
26

37
export type SessionActivityPhase = "idle" | "connecting" | "working";
48

59
interface SessionActivityState {
610
isPromptPending?: boolean;
711
awaitingAgentOutput?: boolean;
8-
terminalStatus?: "failed" | "completed";
12+
terminalStatus?: TerminalStatus;
913
events?: SessionEvent[];
1014
}
1115

0 commit comments

Comments
 (0)