From 65baa106bd986964c94fff7ba06298348658743d Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 23 Jul 2026 11:45:29 +0100 Subject: [PATCH] fix(mobile): show stopped cloud runs as stopped, not failed (port #3697) Ports the desktop fix (#3697) to the mobile app. A cloud run the user deliberately stops (backend status `cancelled`) was being collapsed into `failed`, so the end-of-run banner read "Run failed" with a Retry button. Map `cancelled` to a new terminal outcome `stopped`: the banner now reads "Run stopped" with neutral/success styling and offers Continue. Introduces a shared `TerminalStatus` type and extracts the banner into `TerminalStatusBanner` for isolated testing. Generated-By: PostHog Code Task-Id: 880d3028-60e8-4893-b5f6-2b31f4a44456 --- .../tasks/components/TaskSessionView.tsx | 49 +++------------ .../components/TerminalStatusBanner.test.tsx | 63 +++++++++++++++++++ .../tasks/components/TerminalStatusBanner.tsx | 57 +++++++++++++++++ .../tasks/stores/taskSessionStore.test.ts | 20 +++++- .../features/tasks/stores/taskSessionStore.ts | 12 ++-- apps/mobile/src/features/tasks/types.ts | 4 ++ .../features/tasks/utils/sessionActivity.ts | 8 ++- 7 files changed, 164 insertions(+), 49 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/TerminalStatusBanner.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/TerminalStatusBanner.tsx diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index fb4c23ed13..cbd66159a6 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -28,10 +28,12 @@ import type { SessionEvent, SessionNotification, SessionNotificationAttachment, + TerminalStatus, } from "../types"; import { PlanApprovalCard } from "./PlanApprovalCard"; import { PlanStatusBar } from "./PlanStatusBar"; import { QuestionCard } from "./QuestionCard"; +import { TerminalStatusBanner } from "./TerminalStatusBanner"; interface PermissionResponseArgs { toolCallId: string; @@ -55,7 +57,7 @@ interface TaskSessionViewProps { pendingPermissions?: Record; isConnecting?: boolean; isThinking?: boolean; - terminalStatus?: "failed" | "completed"; + terminalStatus?: TerminalStatus; lastError?: string | null; onRetry?: () => void; onOpenTask?: (taskId: string) => void; @@ -1017,46 +1019,11 @@ export function TaskSessionView({ initialNumToRender={30} ListHeaderComponent={ terminalStatus ? ( - - - {terminalStatus === "failed" ? "Run failed" : "Run completed"} - - {lastError && ( - {lastError} - )} - {onRetry && ( - - - {terminalStatus === "failed" ? "Retry" : "Continue"} - - - )} - + ) : null } /> diff --git a/apps/mobile/src/features/tasks/components/TerminalStatusBanner.test.tsx b/apps/mobile/src/features/tasks/components/TerminalStatusBanner.test.tsx new file mode 100644 index 0000000000..13af28ea89 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TerminalStatusBanner.test.tsx @@ -0,0 +1,63 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { + TerminalStatusBanner, + type TerminalStatusBannerProps, +} from "./TerminalStatusBanner"; + +function render(props: TerminalStatusBannerProps) { + let renderer!: ReturnType; + act(() => { + renderer = create(createElement(TerminalStatusBanner, props)); + }); + return renderer; +} + +function renderedText(renderer: ReturnType): string { + const acc: string[] = []; + const walk = (node: unknown) => { + if (typeof node === "string") { + acc.push(node); + } else if (Array.isArray(node)) { + node.forEach(walk); + } else if (node && typeof node === "object" && "children" in node) { + walk((node as { children: unknown }).children); + } + }; + walk(renderer.toJSON()); + return acc.join(" "); +} + +describe("TerminalStatusBanner", () => { + it.each([ + { terminalStatus: "completed", label: "Run completed", button: "Continue" }, + { terminalStatus: "failed", label: "Run failed", button: "Retry" }, + { terminalStatus: "stopped", label: "Run stopped", button: "Continue" }, + ] as const)( + "shows $label with a $button action for a $terminalStatus run", + ({ terminalStatus, label, button }) => { + const text = renderedText(render({ terminalStatus, onRetry: vi.fn() })); + expect(text).toContain(label); + expect(text).toContain(button); + }, + ); + + it("does not label a stopped run as failed", () => { + const text = renderedText(render({ terminalStatus: "stopped" })); + expect(text).not.toContain("Run failed"); + expect(text).not.toContain("Retry"); + }); + + it("fires onRetry when the action is pressed", () => { + const onRetry = vi.fn(); + const renderer = render({ terminalStatus: "stopped", onRetry }); + const pressable = renderer.root.findAll( + (node) => node.props.onPress === onRetry, + )[0]; + act(() => { + pressable.props.onPress(); + }); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/TerminalStatusBanner.tsx b/apps/mobile/src/features/tasks/components/TerminalStatusBanner.tsx new file mode 100644 index 0000000000..aff1b0a166 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TerminalStatusBanner.tsx @@ -0,0 +1,57 @@ +import { Pressable, Text, View } from "react-native"; +import type { TerminalStatus } from "../types"; + +export interface TerminalStatusBannerProps { + terminalStatus: TerminalStatus; + lastError?: string | null; + onRetry?: () => void; +} + +export function TerminalStatusBanner({ + terminalStatus, + lastError, + onRetry, +}: TerminalStatusBannerProps) { + const isFailed = terminalStatus === "failed"; + const label = + terminalStatus === "failed" + ? "Run failed" + : terminalStatus === "stopped" + ? "Run stopped" + : "Run completed"; + + return ( + + + {label} + + {lastError && ( + {lastError} + )} + {onRetry && ( + + + {isFailed ? "Retry" : "Continue"} + + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index ddc5da1459..b0b49e1ef1 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -32,7 +32,11 @@ import type { TaskRun, } from "../types"; import { useMessageQueueStore } from "./messageQueueStore"; -import { type TaskSession, useTaskSessionStore } from "./taskSessionStore"; +import { + mapTerminalStatus, + type TaskSession, + useTaskSessionStore, +} from "./taskSessionStore"; import { useTaskStore } from "./taskStore"; function seedSession(overrides: Partial = {}): void { @@ -47,6 +51,20 @@ function seedSession(overrides: Partial = {}): void { useTaskSessionStore.setState({ sessions: { "run-1": session } }); } +describe("mapTerminalStatus", () => { + it.each([ + { status: "completed", expected: "completed" }, + { status: "failed", expected: "failed" }, + { status: "cancelled", expected: "stopped" }, + { status: "in_progress", expected: undefined }, + { status: "queued", expected: undefined }, + { status: undefined, expected: undefined }, + { status: null, expected: undefined }, + ] as const)("maps $status to $expected", ({ status, expected }) => { + expect(mapTerminalStatus(status)).toBe(expected); + }); +}); + describe("steerQueuedMessage", () => { beforeEach(() => { useMessageQueueStore.setState({ queuesByTaskId: {} }, false); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index f7455157e5..e17d75d16b 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -27,6 +27,7 @@ import { type SessionNotificationAttachment, type StoredLogEntry, type Task, + type TerminalStatus, } from "../types"; import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playbackRateForTaskDuration } from "../utils/playbackRate"; @@ -294,8 +295,8 @@ export interface TaskSession { // the log). Used to dedup the canonical copy against the echo. localUserEchoes?: Set; // Terminal backend status for this run, populated by status updates so the - // UI can surface "Run failed" / "Run completed". - terminalStatus?: "failed" | "completed"; + // UI can surface "Run failed" / "Run completed" / "Run stopped". + terminalStatus?: TerminalStatus; lastError?: string | null; // True when the user initiated work (new task, sendPrompt, resume) and // we should play a sound when control returns. False when reconnecting @@ -392,11 +393,12 @@ const connectAttempts = new Set(); // queue twice. const flushingTasks = new Set(); -function mapTerminalStatus( +export function mapTerminalStatus( status: string | undefined | null, -): "completed" | "failed" | undefined { +): TerminalStatus | undefined { if (status === "completed") return "completed"; - if (status === "failed" || status === "cancelled") return "failed"; + if (status === "failed") return "failed"; + if (status === "cancelled") return "stopped"; return undefined; } diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 18c31142ea..5047c37731 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -45,6 +45,10 @@ export type TaskRunStatus = export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; +// UI-facing terminal outcome for a run. `cancelled` maps to `stopped` (the user +// deliberately halted it) so the UI can distinguish it from a real failure. +export type TerminalStatus = "completed" | "failed" | "stopped"; + export function isTerminalStatus( status: TaskRunStatus | string | null | undefined, ): boolean { diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.ts index f980e66a52..982d4db8b6 100644 --- a/apps/mobile/src/features/tasks/utils/sessionActivity.ts +++ b/apps/mobile/src/features/tasks/utils/sessionActivity.ts @@ -1,11 +1,15 @@ -import type { SessionEvent, SessionNotification } from "../types"; +import type { + SessionEvent, + SessionNotification, + TerminalStatus, +} from "../types"; export type SessionActivityPhase = "idle" | "connecting" | "working"; interface SessionActivityState { isPromptPending?: boolean; awaitingAgentOutput?: boolean; - terminalStatus?: "failed" | "completed"; + terminalStatus?: TerminalStatus; events?: SessionEvent[]; }