diff --git a/.github/pr-assets/13-mobile-action-recovery.png b/.github/pr-assets/13-mobile-action-recovery.png new file mode 100644 index 000000000000..8c0f50d9bb6a Binary files /dev/null and b/.github/pr-assets/13-mobile-action-recovery.png differ diff --git a/apps/mobile/src/features/threads/ActionResumeNotice.tsx b/apps/mobile/src/features/threads/ActionResumeNotice.tsx new file mode 100644 index 000000000000..235bd197abed --- /dev/null +++ b/apps/mobile/src/features/threads/ActionResumeNotice.tsx @@ -0,0 +1,135 @@ +import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { useCallback, useEffect, useState } from "react"; +import { Alert, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { ControlPill } from "../../components/ControlPill"; +import { terminalEnvironment } from "../../state/terminal"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +function failureMessage(cause: Cause.Cause, fallback: string): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; +} + +export function ActionResumeNotice(props: { + readonly environmentId: EnvironmentId; + readonly thread: OrchestrationThreadShell; +}) { + const action = props.thread.actionResume ?? null; + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const resumeAction = useAtomCommand(threadEnvironment.resumeAction, { reportFailure: false }); + const discardAction = useAtomCommand(threadEnvironment.discardAction, { reportFailure: false }); + const [pendingAction, setPendingAction] = useState<"cancel" | "resume" | "discard" | null>(null); + + useEffect(() => setPendingAction(null), [action?.runId, action?.delivery, action?.outcome]); + + const cancel = useCallback(async () => { + if (action?.outcome !== "running" || pendingAction !== null) return; + setPendingAction("cancel"); + const result = await closeTerminal({ + environmentId: props.environmentId, + input: { threadId: props.thread.id, terminalId: action.terminalId }, + }); + if (result._tag === "Failure") { + setPendingAction(null); + Alert.alert( + "Could not cancel Action", + failureMessage(result.cause, "The Project Action could not be cancelled."), + ); + } + }, [action, closeTerminal, pendingAction, props.environmentId, props.thread.id]); + + const recover = useCallback( + async (choice: "resume" | "discard") => { + if (action?.delivery !== "available" || pendingAction !== null) return; + setPendingAction(choice); + const command = choice === "resume" ? resumeAction : discardAction; + const result = await command({ + environmentId: props.environmentId, + input: { threadId: props.thread.id }, + }); + if (result._tag === "Failure") { + setPendingAction(null); + Alert.alert( + choice === "resume" ? "Could not resume agent" : "Could not discard follow-up", + failureMessage(result.cause, "The interrupted Action follow-up could not be updated."), + ); + } + }, + [ + action?.delivery, + discardAction, + pendingAction, + props.environmentId, + props.thread.id, + resumeAction, + ], + ); + + if (action?.outcome === "running") { + return ( + + + + + + Waiting for {action.actionName} + + + The agent will resume once this Action finishes and the thread is idle. + + + void cancel()} + variant="pill" + /> + + + ); + } + + if (action?.delivery !== "available") return null; + + return ( + + + + + + {action.actionName} was interrupted + + + LastCode did not restart the command or wake the agent. Resume only the agent follow-up + when you are ready. + + + void recover("discard")} + variant="pill" + /> + void recover("resume")} + variant="primary" + /> + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e234838394ba..02d0100d7479 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -67,6 +67,7 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { ActionResumeNotice } from "./ActionResumeNotice"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, @@ -669,6 +670,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} + {props.selectedThread.actionResume?.outcome === "running" || + props.selectedThread.actionResume?.delivery === "available" ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], ); + const handleCancelAction = useCallback(async () => { + if (runningAction === null) return; + const result = await closeTerminal({ + environmentId: thread.environmentId, + input: { threadId: thread.id, terminalId: runningAction.terminalId }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not cancel Action", + error instanceof Error ? error.message : "The Project Action could not be cancelled.", + ); + } + }, [closeTerminal, runningAction, thread.environmentId, thread.id]); const menuActions = useMemo( () => [ THREAD_ROW_MENU_ACTIONS[0]!, @@ -492,9 +511,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ...(runningAction === null + ? [] + : [ + { + id: "cancel-action", + title: `Cancel ${runningAction.actionName}`, + image: "stop.fill", + attributes: { destructive: true }, + } satisfies MenuAction, + ]), THREAD_ROW_MENU_ACTIONS[1]!, ], - [props.titleRegenerationSupported, thread.titleRegeneration], + [props.titleRegenerationSupported, runningAction, thread.titleRegeneration], ); const primaryAction = useMemo( () => ({ @@ -509,9 +538,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); + if (nativeEvent.event === "cancel-action") void handleCancelAction(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle], + [handleArchive, handleCancelAction, handleDelete, handleRegenerateTitle], ); const statusPill = effectiveStatus ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 86d442dfed97..21059dc0f2ae 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -5,6 +5,7 @@ import type { import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; +import * as Cause from "effect/Cause"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -18,6 +19,8 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -53,6 +56,7 @@ const STATUS_LABEL_BY_STATUS: Partial< approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, + waiting: { label: "Waiting", className: "text-yellow-700 dark:text-yellow-300" }, failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, }; @@ -395,6 +399,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const runningAction = thread.actionResume?.outcome === "running" ? thread.actionResume : null; + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; @@ -438,6 +444,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [onMovePinnedThread, thread], ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleCancelAction = useCallback(async () => { + if (runningAction === null) return; + const result = await closeTerminal({ + environmentId: thread.environmentId, + input: { threadId: thread.id, terminalId: runningAction.terminalId }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not cancel Action", + error instanceof Error ? error.message : "The Project Action could not be cancelled.", + ); + } + }, [closeTerminal, runningAction, thread.environmentId, thread.id]); // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled @@ -517,6 +537,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }), [props.titleRegenerationSupported, thread.titleRegeneration], ); + const actionMenuItems = useMemo( + () => + runningAction === null + ? [] + : [ + { + id: "cancel-action", + title: `Cancel ${runningAction.actionName}`, + image: "stop.fill", + attributes: { destructive: true }, + }, + ], + [runningAction], + ); const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, @@ -528,30 +562,47 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ...pinMenuItem, ...titleRegenerationMenuItems, + ...actionMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], + [actionMenuItems, pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...titleRegenerationMenuItems, + ...actionMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [pinMenuItem, titleRegenerationMenuItems], + [actionMenuItems, pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( - () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SLIM_MENU_ACTIONS[0]!, + ...titleRegenerationMenuItems, + ...actionMenuItems, + SLIM_MENU_ACTIONS[1]!, + ], + [actionMenuItems, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SNOOZED_MENU_ACTIONS[0]!, + ...titleRegenerationMenuItems, + ...actionMenuItems, + SNOOZED_MENU_ACTIONS[1]!, + ], + [actionMenuItems, titleRegenerationMenuItems], ); const legacyMenuActions = useMemo( - () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + LEGACY_MENU_ACTIONS[0]!, + ...titleRegenerationMenuItems, + ...actionMenuItems, + LEGACY_MENU_ACTIONS[1]!, + ], + [actionMenuItems, titleRegenerationMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -564,6 +615,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); + if (nativeEvent.event === "cancel-action") void handleCancelAction(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ event: nativeEvent.event, @@ -578,6 +630,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [ handleArchive, + handleCancelAction, handleDelete, handleRegenerateTitle, handleMovePinnedDown, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c4a1a844c777..c2ac5a270f1e 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -23,6 +23,7 @@ import { resolveThreadListV2SwipeActions, sortThreadsForListV2, } from "./threadListV2"; +import { resolveThreadStatus } from "./threadPresentation"; const environmentId = EnvironmentId.make("environment-1"); @@ -145,6 +146,22 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status(thread)).toBe("approval"); }); + it("reports Waiting for a running Action once higher-priority work is idle", () => { + const actionResume = { outcome: "running" } as NonNullable< + EnvironmentThreadShell["actionResume"] + >; + const waiting = makeThread({ id: ThreadId.make("waiting"), title: "Waiting", actionResume }); + expect(resolveThreadListV2Status(waiting)).toBe("waiting"); + expect(resolveThreadStatus(waiting)?.kind).toBe("waiting"); + + expect( + resolveThreadListV2Status({ + ...waiting, + hasPendingApprovals: true, + }), + ).toBe("approval"); + }); + it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 53b80e52c4f1..0b222187ce93 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -24,7 +24,7 @@ export { snoozeWakeLabel }; * (approval), "in motion" (working), and "broken" (failed). Ready is the * unlabeled resting state. */ -export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; +export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; export function resolveThreadListV2SnoozeMenuSelection(input: { @@ -123,7 +123,10 @@ export function resolveThreadListV2Enabled(input: { } export function resolveThreadListV2Status( - thread: Pick, + thread: Pick< + EnvironmentThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "actionResume" + >, ): ThreadListV2Status { if (thread.hasPendingApprovals) { return "approval"; @@ -137,6 +140,9 @@ export function resolveThreadListV2Status( if (thread.session?.status === "error") { return "failed"; } + if (thread.actionResume?.outcome === "running") { + return "waiting"; + } return "ready"; } diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 9de3d4d3089f..c556dc65e3ac 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -11,6 +11,7 @@ export type ThreadStatusKind = | "pending-approval" | "awaiting-input" | "working" + | "waiting" | "connecting" | "error" | "plan-ready"; @@ -109,6 +110,18 @@ export function resolveThreadStatus( }; } + if (thread.actionResume?.outcome === "running") { + return { + kind: "waiting", + label: "Waiting", + pillClassName: "bg-yellow-500/12 dark:bg-yellow-500/16", + textClassName: "text-yellow-700 dark:text-yellow-300", + iconColor: "#eab308", + iconBackground: "rgba(234,179,8,0.22)", + pulse: false, + }; + } + const hasPlanReadyPrompt = thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 97f397da3e80..b68ef39d9ebd 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -37,6 +37,7 @@ function makeShell(input: { readonly activityAt: string | null; readonly sessionStatus?: "starting" | "running"; readonly pending?: "approval" | "user-input"; + readonly runningAction?: boolean; }): OrchestrationThreadShell { const threadId = ThreadId.make("thread-1"); return { @@ -80,6 +81,9 @@ function makeShell(input: { hasPendingApprovals: input.pending === "approval", hasPendingUserInput: input.pending === "user-input", hasActionableProposedPlan: false, + actionResume: input.runningAction + ? ({ outcome: "running" } as NonNullable) + : null, }; } @@ -403,6 +407,15 @@ describe("canSettle", () => { expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( false, ); + expect(canSettle(makeShell({ activityAt: FRESH, runningAction: true }), { now: NOW })).toBe( + false, + ); + expect( + effectiveSettled(makeShell({ activityAt: STALE, runningAction: true }), { + now: NOW, + autoSettleAfterDays: 3, + }), + ).toBe(false); }); it("blocks settling a queued turn start, only within the grace window", () => { diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index e2e93f288889..fd4b4c6dd13f 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -87,12 +87,18 @@ export function hasQueuedTurnStart( export function canSettle( shell: Pick< OrchestrationThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "session" + | "latestUserMessageAt" + | "latestTurn" + | "actionResume" >, options: { readonly now: string }, ): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (shell.actionResume?.outcome === "running") return false; // Queued work is as blocked-on-progress as a live session: settling it // (or auto-settling it on a closed PR) would hide a just-requested turn. if (hasQueuedTurnStart(shell, options)) return false; @@ -247,6 +253,7 @@ export function effectiveSettled( // Blocked work must remain visible even when a user explicitly settled it. if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (shell.actionResume?.outcome === "running") return false; if (hasQueuedTurnStart(shell, { now: options.now })) { // The queued-turn blocker alone is forgivable: it is clock-derived, and // list callers pass a coarser `now` than the settle action used. When diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 0aa89be09b94..ee3befcd80f0 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -547,8 +547,8 @@ export const OrchestrationThreadShell = Schema.Struct({ backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))), /** * Latest resume-capable Action state. Optional on the wire so older clients - * ignore it. Web/desktop render `running` as Waiting and `process_lost` as a - * passive recovery affordance; mobile deliberately ignores the field. + * ignore it. Clients render `running` as Waiting and `process_lost` as a + * passive recovery affordance. */ actionResume: Schema.optional(Schema.NullOr(ActionResumeState)), /**