Skip to content
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
Binary file added .github/pr-assets/13-mobile-action-recovery.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
135 changes: 135 additions & 0 deletions apps/mobile/src/features/threads/ActionResumeNotice.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>, 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 (
<View className="rounded-2xl border border-yellow-400/35 bg-yellow-500/10 px-3.5 py-3 dark:border-yellow-300/20">
<View className="flex-row items-center gap-2.5">
<SymbolView name="clock" size={18} tintColor="#eab308" type="monochrome" />
<View className="min-w-0 flex-1">
<Text className="text-sm font-t3-bold text-yellow-800 dark:text-yellow-200">
Waiting for {action.actionName}
</Text>
<Text className="mt-0.5 text-xs text-yellow-800/75 dark:text-yellow-200/70">
The agent will resume once this Action finishes and the thread is idle.
</Text>
</View>
<ControlPill
accessibilityLabel={`Cancel ${action.actionName}`}
disabled={pendingAction !== null}
label={pendingAction === "cancel" ? "Cancelling…" : "Cancel"}
onPress={() => void cancel()}
variant="pill"
/>
</View>
</View>
);
}

if (action?.delivery !== "available") return null;

return (
<View className="rounded-2xl border border-yellow-400/35 bg-yellow-500/10 px-3.5 py-3 dark:border-yellow-300/20">
<View className="flex-row items-start gap-2.5">
<SymbolView
name="exclamationmark.triangle"
size={18}
tintColor="#eab308"
type="monochrome"
/>
<View className="min-w-0 flex-1">
<Text className="text-sm font-t3-bold text-yellow-800 dark:text-yellow-200">
{action.actionName} was interrupted
</Text>
<Text className="mt-0.5 text-xs text-yellow-800/75 dark:text-yellow-200/70">
LastCode did not restart the command or wake the agent. Resume only the agent follow-up
when you are ready.
</Text>
<View className="mt-2.5 flex-row justify-end gap-2">
<ControlPill
disabled={pendingAction !== null}
label={pendingAction === "discard" ? "Discarding…" : "Discard"}
onPress={() => void recover("discard")}
variant="pill"
/>
<ControlPill
disabled={pendingAction !== null}
label={pendingAction === "resume" ? "Resuming…" : "Resume agent"}
onPress={() => void recover("resume")}
variant="primary"
/>
</View>
</View>
</View>
</View>
);
}
14 changes: 14 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -669,6 +670,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
</Animated.View>
) : null}
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.selectedThread.actionResume?.outcome === "running" ||
props.selectedThread.actionResume?.delivery === "available" ? (
<Animated.View
className="shrink-0 px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
>
<ActionResumeNotice
environmentId={props.environmentId}
thread={props.selectedThread}
/>
</Animated.View>
) : null}
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
className="shrink-0 gap-3 px-4 pb-3"
Expand Down
36 changes: 33 additions & 3 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import type {
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import type { MenuAction } from "@react-native-menu/menu";
import * as Cause from "effect/Cause";
import { SymbolView } from "../../components/AppSymbol";
import { memo, useCallback, useMemo, type ComponentProps } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { Alert, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import Svg, { Circle, Path } from "react-native-svg";
Expand All @@ -21,6 +22,8 @@ import { relativeTime } from "../../lib/time";
import { themeColorWithAlpha } from "../../lib/mobileTheme";
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, type ThreadPr } from "../../state/use-thread-pr";
import type { HomeGroupDisplayAction } from "../home/homeListItems";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
Expand Down Expand Up @@ -456,6 +459,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {

const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
props;
const runningAction = thread.actionResume?.outcome === "running" ? thread.actionResume : null;
const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
const status = resolveThreadStatus(thread);
const pr = useThreadPr(thread, props.projectCwd);
const timestamp = relativeTime(
Expand Down Expand Up @@ -485,16 +490,40 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
() => 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<MenuAction[]>(
() => [
THREAD_ROW_MENU_ACTIONS[0]!,
...buildThreadTitleRegenerationMenuItems({
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(
() => ({
Expand All @@ -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 ? (
Expand Down
69 changes: 61 additions & 8 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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" },
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -517,6 +537,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
}),
[props.titleRegenerationSupported, thread.titleRegeneration],
);
const actionMenuItems = useMemo<MenuAction[]>(
() =>
runningAction === null
? []
: [
{
id: "cancel-action",
title: `Cancel ${runningAction.actionName}`,
image: "stop.fill",
attributes: { destructive: true },
},
],
[runningAction],
);
const snoozableCardMenuActions = useMemo<MenuAction[]>(
() => [
{ id: "settle", title: "Settle", image: "checkmark" },
Expand All @@ -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<MenuAction[]>(
() => [
CARD_MENU_ACTIONS[0]!,
...pinMenuItem,
...titleRegenerationMenuItems,
...actionMenuItems,
...CARD_MENU_ACTIONS.slice(1),
],
[pinMenuItem, titleRegenerationMenuItems],
[actionMenuItems, pinMenuItem, titleRegenerationMenuItems],
);
const slimMenuActions = useMemo<MenuAction[]>(
() => [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<MenuAction[]>(
() => [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<MenuAction[]>(
() => [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 } }) => {
Expand All @@ -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,
Expand All @@ -578,6 +630,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
},
[
handleArchive,
handleCancelAction,
handleDelete,
handleRegenerateTitle,
handleMovePinnedDown,
Expand Down
Loading