Skip to content

Commit fe414eb

Browse files
authored
fix(mobile): expose background work and thread status actions (pingdotgg#21395)
1 parent 6efe9a0 commit fe414eb

8 files changed

Lines changed: 395 additions & 23 deletions

File tree

‎apps/mobile/src/Stack.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
7171
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
7272
import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSheet";
7373
import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
74+
import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet";
7475
import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
7576
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
7677
import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen";
@@ -558,6 +559,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
558559
"SettingsLegal",
559560
"SettingsSheet",
560561
"ThreadAgents",
562+
"ThreadBackgroundWork",
561563
"ThreadPullRequests",
562564
"ThreadQueue",
563565
"ThreadReviewComment",
@@ -869,6 +871,15 @@ const RootStackConfig = createWorkspaceStackNavigator({
869871
sheetGrabberVisible: true,
870872
},
871873
}),
874+
ThreadBackgroundWork: createNativeStackScreen({
875+
screen: ThreadBackgroundWorkSheet,
876+
options: {
877+
...FORM_SHEET_PRESENTATION_OPTIONS,
878+
headerShown: false,
879+
sheetAllowedDetents: [0.5, 0.9],
880+
sheetGrabberVisible: true,
881+
},
882+
}),
872883
ThreadAgents: createNativeStackScreen({
873884
screen: ThreadAgentsSheet,
874885
options: {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3+
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4+
import {
5+
presentPendingBackgroundWork,
6+
type PendingBackgroundWorkItem,
7+
} from "@t3tools/client-runtime/state/thread-execution";
8+
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
9+
import * as Haptics from "expo-haptics";
10+
import { Platform, Pressable, ScrollView, View } from "react-native";
11+
import { ScreenStack } from "react-native-screens";
12+
import { useSafeAreaInsets } from "react-native-safe-area-context";
13+
14+
import { AndroidSheetHeader } from "../../components/ui/AndroidScreenHeader";
15+
import { SymbolView } from "../../components/ui/AppSymbol";
16+
import { AppText as Text } from "../../components/ui/AppText";
17+
import { ScreenContent } from "../../components/ui/ScreenContent";
18+
import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
19+
import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack";
20+
import { environmentThreadShells } from "../../state/threads";
21+
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
22+
23+
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
24+
const KIND_LABELS = {
25+
subagent: "Subagent",
26+
command: "Command",
27+
monitor: "Monitor",
28+
background_task: "Background task",
29+
} as const;
30+
31+
type BackgroundWorkTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
32+
33+
export function ThreadBackgroundWorkSheet({ route }: StaticScreenProps<BackgroundWorkTarget>) {
34+
const { layout } = useAdaptiveWorkspaceLayout();
35+
const navigation = useNavigation();
36+
const insets = useSafeAreaInsets();
37+
const target = route.params;
38+
const thread = useAtomValue(
39+
environmentThreadShells.threadShellAtom(scopeThreadRef(target.environmentId, target.threadId)),
40+
);
41+
const presentation = presentPendingBackgroundWork(thread?.pendingBackgroundTasks ?? []);
42+
const openThread = (threadId: ThreadId) => {
43+
void Haptics.selectionAsync();
44+
// A thread replaces the leaf sheet so workspace back navigation still works.
45+
navigation.dispatch(
46+
StackActions.replace("Thread", { environmentId: target.environmentId, threadId }),
47+
);
48+
};
49+
const content = (
50+
<ScrollView
51+
nestedScrollEnabled
52+
className="flex-1"
53+
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
54+
contentContainerClassName="px-5 pb-6"
55+
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }}
56+
>
57+
{presentation === null ? (
58+
<Text className="pt-6 text-center text-sm text-foreground-muted">
59+
Nothing running in the background.
60+
</Text>
61+
) : (
62+
presentation.items.map((item) => (
63+
<BackgroundWorkRow key={item.taskId} item={item} onOpen={openThread} />
64+
))
65+
)}
66+
</ScrollView>
67+
);
68+
if (Platform.OS === "ios") {
69+
return (
70+
<View collapsable={false} className="flex-1 bg-sheet">
71+
<ScreenStack style={{ flex: 1 }}>
72+
<NativeScreen
73+
activityState={2}
74+
enabled
75+
isNativeStack
76+
screenId="thread-background-work-sheet-native"
77+
scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
78+
className="flex-1 bg-sheet"
79+
>
80+
{content}
81+
<NativeHeader
82+
backgroundColor="rgba(0,0,0,0)"
83+
tintColorClassName="accent-foreground"
84+
hideBackButton
85+
hideShadow={false}
86+
title="Background work"
87+
titleColorClassName="accent-foreground"
88+
titleFontSize={18}
89+
titleFontWeight="800"
90+
translucent
91+
/>
92+
</NativeScreen>
93+
</ScreenStack>
94+
</View>
95+
);
96+
}
97+
return (
98+
<View collapsable={false} className="flex-1 bg-sheet">
99+
<NativeStackScreenOptions options={{ sheetCornerRadius: 28 }} />
100+
<AndroidSheetHeader title="Background work" onBack={() => navigation.goBack()} />
101+
<ScreenContent insetHorizontal={layout.usesSplitView}>{content}</ScreenContent>
102+
</View>
103+
);
104+
}
105+
106+
function BackgroundWorkRow(props: {
107+
readonly item: PendingBackgroundWorkItem;
108+
readonly onOpen: (threadId: ThreadId) => void;
109+
}) {
110+
const { item } = props;
111+
const childThreadId = item.childThreadId;
112+
const row = (
113+
<View className="flex-row items-center gap-3 border-b border-border py-3.5">
114+
<SymbolView
115+
name={
116+
item.kind === "subagent" ? "person.2" : item.kind === "monitor" ? "clock" : "terminal"
117+
}
118+
size={15}
119+
tintColorClassName="accent-foreground-muted"
120+
type="monochrome"
121+
/>
122+
<View className="min-w-0 flex-1">
123+
<Text className="font-t3-medium text-sm text-foreground">{item.label}</Text>
124+
<Text className="text-xs text-foreground-muted">{KIND_LABELS[item.kind]}</Text>
125+
</View>
126+
{childThreadId === undefined ? null : (
127+
<SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
128+
)}
129+
</View>
130+
);
131+
return childThreadId === undefined ? (
132+
<View accessible>{row}</View>
133+
) : (
134+
<Pressable
135+
accessibilityRole="link"
136+
accessibilityLabel={`Open subagent ${item.label}`}
137+
onPress={() => props.onOpen(childThreadId)}
138+
className="active:opacity-70"
139+
>
140+
{row}
141+
</Pressable>
142+
);
143+
}

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
1+
import {
2+
isAtomCommandInterrupted,
3+
settlePromise,
4+
squashAtomCommandFailure,
5+
} from "@t3tools/client-runtime/state/runtime";
26
import { AsyncResult } from "effect/reactivity";
37
import * as Dialog from "../../components/ui/Dialog";
48
import { useAtomValue } from "@effect/atom-react";
@@ -8,7 +12,13 @@ import {
812
useThreadShell,
913
} from "../../state/entities";
1014
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
11-
import { StackActions, useNavigation } from "@react-navigation/native";
15+
import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
16+
import {
17+
effectiveSnoozed,
18+
threadWokeAt,
19+
isThreadWokeUnseen,
20+
snoozeWakeLabel,
21+
} from "@t3tools/client-runtime/state/thread-settled";
1222
import type { WorktreeSetupCardProps } from "./worktree-setup-card";
1323
import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
1424
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
@@ -516,6 +526,96 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
516526
}
517527
})();
518528
const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel);
529+
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
530+
reportFailure: false,
531+
});
532+
const unsnoozeThread = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
533+
const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false });
534+
const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false });
535+
const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
536+
const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null);
537+
const statusPending = statusRequest?.threadKey === selectedThreadKey;
538+
const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString());
539+
useFocusEffect(
540+
useCallback(() => {
541+
setLifecycleNow(new Date().toISOString());
542+
const timer = setInterval(() => setLifecycleNow(new Date().toISOString()), 60_000);
543+
return () => clearInterval(timer);
544+
}, []),
545+
);
546+
const snoozed =
547+
props.serverConfig?.environment.capabilities.threadSnooze === true &&
548+
effectiveSnoozed(props.selectedThread, { now: lifecycleNow });
549+
const snoozedUntil = props.selectedThread.snoozedUntil;
550+
useEffect(() => {
551+
if (!snoozed || snoozedUntil === null) return;
552+
const timer = setTimeout(
553+
() => setLifecycleNow(new Date().toISOString()),
554+
Math.min(Math.max(0, Date.parse(snoozedUntil) - Date.now()) + 50, 2_147_483_647),
555+
);
556+
return () => clearTimeout(timer);
557+
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Re-arm if timer clamping fires before the wake time.
558+
}, [snoozed, snoozedUntil, lifecycleNow]);
559+
const settled =
560+
props.serverConfig?.environment.capabilities.threadSettlement === true &&
561+
props.selectedThread.settledOverride === "settled";
562+
const wokeAt =
563+
props.serverConfig?.environment.capabilities.threadSnooze === true
564+
? threadWokeAt(props.selectedThread, { now: lifecycleNow })
565+
: null;
566+
const woke = isThreadWokeUnseen({
567+
wokeAt,
568+
settled,
569+
lastVisitedAt: props.selectedThread.lastVisitedAt,
570+
latestRunCompletedAt: props.selectedThread.latestRun?.completedAt ?? null,
571+
});
572+
const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => {
573+
if (statusPending || !props.canOperateThread) return;
574+
const request = { threadKey: selectedThreadKey };
575+
setStatusRequest(request);
576+
const target = {
577+
environmentId: props.environmentId,
578+
input: { threadId: props.selectedThread.id },
579+
};
580+
const recovery = props.selectedThread.limitRecovery;
581+
const recoveryOwnsSnooze =
582+
recovery?.snooze === true &&
583+
recovery.runId === props.selectedThread.latestRun?.runId &&
584+
snoozedUntil !== null &&
585+
Date.parse(snoozedUntil) === Date.parse(recovery.resetAt);
586+
const result =
587+
action === "stop"
588+
? await interruptThreadTurn(target)
589+
: action === "wake"
590+
? recoveryOwnsSnooze
591+
? await updateMetadata({
592+
...target,
593+
input: {
594+
...target.input,
595+
limitRecovery: {
596+
runId: recovery.runId,
597+
resetAt: recovery.resetAt,
598+
snooze: false,
599+
},
600+
},
601+
})
602+
: await unsnoozeThread({ ...target, input: { ...target.input, reason: "user" } })
603+
: action === "unsettle"
604+
? await unsettleThread({ ...target, input: { ...target.input, reason: "user" } })
605+
: await visitThread({
606+
...target,
607+
input: { ...target.input, visitedAt: wokeAt ?? lifecycleNow },
608+
});
609+
// An accepted interrupt can leave tasks running. Release the action for retry.
610+
setStatusRequest((current) => (current === request ? null : current));
611+
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
612+
const error = squashAtomCommandFailure(result);
613+
Dialog.alert(
614+
"Could not update thread",
615+
error instanceof Error ? error.message : "Try again.",
616+
);
617+
}
618+
};
519619
const pendingBackgroundWork = presentPendingBackgroundWork(
520620
props.selectedThread.pendingBackgroundTasks,
521621
);
@@ -576,6 +676,46 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
576676
.map((item) => item.label)
577677
.join(", ")}`,
578678
waiting: pendingBackgroundWork.waiting,
679+
onPress: () => {
680+
Keyboard.dismiss();
681+
navigation.navigate("ThreadBackgroundWork", {
682+
environmentId: props.environmentId,
683+
threadId: props.selectedThread.id,
684+
});
685+
},
686+
action: {
687+
label: statusPending ? "Stopping..." : "Stop",
688+
accessibilityLabel: "Stop background work",
689+
disabled: !props.canOperateThread || statusPending,
690+
onPress: () => void runStatusAction("stop"),
691+
},
692+
};
693+
}
694+
if (contentPresentationKind === "ready" && (snoozed || settled || woke)) {
695+
const state = snoozed ? "snoozed" : settled ? "settled" : "woke";
696+
const label =
697+
snoozed && snoozedUntil !== null
698+
? `Snoozed, wakes in ${snoozeWakeLabel(snoozedUntil, { now: lifecycleNow })}`
699+
: settled
700+
? "Settled"
701+
: "Woke from snooze";
702+
const actionLabel = snoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss";
703+
return {
704+
kind: "lifecycle",
705+
state,
706+
label,
707+
action: {
708+
label: statusPending
709+
? snoozed
710+
? "Waking..."
711+
: settled
712+
? "Un-settling..."
713+
: "Dismissing..."
714+
: actionLabel,
715+
accessibilityLabel: actionLabel,
716+
disabled: !props.canOperateThread || statusPending,
717+
onPress: () => void runStatusAction(snoozed ? "wake" : settled ? "unsettle" : "dismiss"),
718+
},
579719
};
580720
}
581721
if (props.selectedThread.goal !== null && contentPresentationKind === "ready") {

0 commit comments

Comments
 (0)