Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions apps/mobile/src/features/tasks/components/TaskSessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,7 +57,7 @@ interface TaskSessionViewProps {
pendingPermissions?: Record<string, CloudPendingPermissionRequest>;
isConnecting?: boolean;
isThinking?: boolean;
terminalStatus?: "failed" | "completed";
terminalStatus?: TerminalStatus;
lastError?: string | null;
onRetry?: () => void;
onOpenTask?: (taskId: string) => void;
Expand Down Expand Up @@ -1017,46 +1019,11 @@ export function TaskSessionView({
initialNumToRender={30}
ListHeaderComponent={
terminalStatus ? (
<View
className={`mx-4 mt-2 mb-4 rounded-lg px-4 py-3 ${
terminalStatus === "failed"
? "bg-status-error/10"
: "bg-status-success/10"
}`}
>
<Text
className={`font-semibold text-sm ${
terminalStatus === "failed"
? "text-status-error"
: "text-status-success"
}`}
>
{terminalStatus === "failed" ? "Run failed" : "Run completed"}
</Text>
{lastError && (
<Text className="mt-1 text-gray-11 text-xs">{lastError}</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
className={`mt-2 self-start rounded-md px-3 py-1.5 ${
terminalStatus === "failed"
? "bg-status-error/20"
: "bg-status-success/20"
}`}
>
<Text
className={`font-medium text-xs ${
terminalStatus === "failed"
? "text-status-error"
: "text-status-success"
}`}
>
{terminalStatus === "failed" ? "Retry" : "Continue"}
</Text>
</Pressable>
)}
</View>
<TerminalStatusBanner
terminalStatus={terminalStatus}
lastError={lastError}
onRetry={onRetry}
/>
) : null
}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof create>;
act(() => {
renderer = create(createElement(TerminalStatusBanner, props));
});
return renderer;
}

function renderedText(renderer: ReturnType<typeof create>): 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);
});
});
57 changes: 57 additions & 0 deletions apps/mobile/src/features/tasks/components/TerminalStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
className={`mx-4 mt-2 mb-4 rounded-lg px-4 py-3 ${
isFailed ? "bg-status-error/10" : "bg-status-success/10"
}`}
>
<Text
className={`font-semibold text-sm ${
isFailed ? "text-status-error" : "text-status-success"
}`}
>
{label}
</Text>
{lastError && (
<Text className="mt-1 text-gray-11 text-xs">{lastError}</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
className={`mt-2 self-start rounded-md px-3 py-1.5 ${
isFailed ? "bg-status-error/20" : "bg-status-success/20"
}`}
>
<Text
className={`font-medium text-xs ${
isFailed ? "text-status-error" : "text-status-success"
}`}
>
{isFailed ? "Retry" : "Continue"}
</Text>
</Pressable>
)}
</View>
);
}
20 changes: 19 additions & 1 deletion apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskSession> = {}): void {
Expand All @@ -47,6 +51,20 @@ function seedSession(overrides: Partial<TaskSession> = {}): 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);
Expand Down
12 changes: 7 additions & 5 deletions apps/mobile/src/features/tasks/stores/taskSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -294,8 +295,8 @@ export interface TaskSession {
// the log). Used to dedup the canonical copy against the echo.
localUserEchoes?: Set<string>;
// 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
Expand Down Expand Up @@ -392,11 +393,12 @@ const connectAttempts = new Set<string>();
// queue twice.
const flushingTasks = new Set<string>();

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;
}

Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/tasks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/features/tasks/utils/sessionActivity.ts
Original file line number Diff line number Diff line change
@@ -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[];
}

Expand Down
Loading