1import { useAtomValue } from "@effect/atom-react";
2import {
3 deriveWorkflowGroups,
4 projectedSubagentsToRuntime,
5} from "@t3tools/client-runtime/state/subagentRuntime";
6import type { ThreadTurnSubagents } from "@t3tools/client-runtime/state/thread-subagents";
7import {
8 isOrchestrationV2WorkActive,
9 type EnvironmentId,
10 type OrchestrationV2Subagent,
11 type ThreadId,
12} from "@t3tools/contracts";
13import { deriveSubagentElapsedMs, formatDuration } from "@t3tools/shared/orchestrationTiming";
14import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
15import * as DateTime from "effect/DateTime";
16import { Atom } from "effect/reactivity";
17import * as Haptics from "expo-haptics";
18import { useMemo } from "react";
19import { Pressable, ScrollView, View } from "react-native";
20import { useSafeAreaInsets } from "react-native-safe-area-context";
21
22import { SheetHeader } from "../../components/ui/SheetHeader";
23import { AppText as Text } from "../../components/ui/AppText";
24import { environmentThreadDetails } from "../../state/threads";
25import { useEnvironments } from "../../state/environments";
26import { SubagentRow } from "./SubagentRow";
27import { useVisibleSecondClock } from "./use-visible-second-clock";
28import { SubagentStopButton } from "./SubagentStopButton";
29import { ThreadWorkflowGroup } from "./ThreadWorkflowGroup";
30import { ScreenContent } from "../../components/ui/ScreenContent";
31import { subagentThreadTarget } from "./threadAgentsPresentation";
32
33import { useOpenThreadInspector } from "./thread-inspector-navigation";
34
35type AgentsTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
36
37const EMPTY_TURN_SUBAGENTS_ATOM = Atom.make<ThreadTurnSubagents | null>(null).pipe(
38 Atom.withLabel("mobile-thread-turn-subagents:empty"),
39);
40
41export function useThreadTurnSubagents(target: AgentsTarget | null): ThreadTurnSubagents | null {
42 return useAtomValue(
43 target === null
44 ? EMPTY_TURN_SUBAGENTS_ATOM
45 : environmentThreadDetails.turnSubagentsAtom(target),
46 );
47}
48
49export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
50 const target = route.params;
51 const navigation = useNavigation();
52 const openInspector = useOpenThreadInspector();
53 const insets = useSafeAreaInsets();
54 const turn = useThreadTurnSubagents(target);
55 const subagents = turn?.subagents ?? [];
56 const hasLiveAgent = (turn?.liveCount ?? 0) > 0;
57 const workflowGroups = useMemo(
58 () =>
59 new Map(
60 deriveWorkflowGroups(projectedSubagentsToRuntime(subagents)).map((group) => [
61 group.workflow.id,
62 group,
63 ]),
64 ),
65 [subagents],
66 );
67
68 const openChildThread = (child: { environmentId: EnvironmentId; threadId: ThreadId }) => {
69 void Haptics.selectionAsync();
70 if (
71 openInspector(target, {
72 type: "thread",
73 environmentId: child.environmentId,
74 threadId: child.threadId,
75 })
76 )
77 return;
78 // Replace this leaf sheet so the child enters the workspace stack expected by Home's Back button.
79 navigation.dispatch(
80 StackActions.replace("Thread", {
81 environmentId: child.environmentId,
82 threadId: child.threadId,
83 }),
84 );
85 };
86
87 const content = (
88 <ScrollView
89 nestedScrollEnabled
90 className="flex-1"
91 // UIKit must inset content beneath the floating translucent iOS header.
92 contentInsetAdjustmentBehavior="automatic"
93 contentContainerClassName="px-5 pb-6"
94 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }}
95 >
96 {subagents.length === 0 ? (
97 <Text className="pt-6 text-center text-sm text-foreground-muted">
98 No agents in this turn.
99 </Text>
100 ) : (
101 subagents.map((subagent) => {
102 const group = workflowGroups.get(subagent.id);
103 return group === undefined ? (
104 <View key={subagent.id} className="border-b border-border">
105 <AgentRow
106 subagent={subagent}
107 environmentId={target.environmentId}
108 tickSeconds={hasLiveAgent}
109 onOpen={openChildThread}
110 />
111 </View>
112 ) : (
113 <ThreadWorkflowGroup
114 key={subagent.id}
115 environmentId={target.environmentId}
116 subagent={subagent}
117 group={group}
118 onOpen={(threadId) =>
119 openChildThread({ environmentId: target.environmentId, threadId })
120 }
121 />
122 );
123 })
124 )}
125 </ScrollView>
126 );
127
128 return (
129 <View collapsable={false} className="flex-1 bg-sheet">
130 <SheetHeader title="Agents" onClose={() => navigation.goBack()} />
131 <ScreenContent>{content}</ScreenContent>
132 </View>
133 );
134}
135
136function AgentRow(props: {
137 readonly environmentId: EnvironmentId;
138 readonly subagent: OrchestrationV2Subagent;
139 readonly tickSeconds: boolean;
140 readonly onOpen: (child: { environmentId: EnvironmentId; threadId: ThreadId }) => void;
141}) {
142 const { subagent } = props;
143 const { presentationById } = useEnvironments();
144 const target = subagentThreadTarget(subagent, props.environmentId, (id) =>
145 presentationById.has(id),
146 );
147
148 const row = (
149 <View className="py-3.5">
150 <SubagentRow
151 environmentId={props.environmentId}
152 subagent={subagent}
153 canOpenRemote={target !== null}
154 elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
155 />
156 </View>
157 );
158
159 const content =
160 target === null ? (
161 <View
162 accessible
163 className="min-w-0 flex-1"
164 accessibilityHint={
165 subagent.remoteChild === undefined
166 ? "Provider-managed agent. Its work appears in the transcript."
167 : `Runs on ${subagent.remoteChild.label}. Connect to it to open its thread.`
168 }
169 >
170 {row}
171 </View>
172 ) : (
173 <Pressable
174 accessibilityRole="link"
175 accessibilityHint="Opens this agent's thread"
176 onPress={() => props.onOpen(target)}
177 className="min-w-0 flex-1 active:opacity-70"
178 >
179 {row}
180 </Pressable>
181 );
182
183 return (
184 <View className="flex-row items-center">
185 {content}
186 <SubagentStopButton environmentId={props.environmentId} subagent={subagent} />
187 </View>
188 );
189}
190
191function AgentElapsed(props: {
192 readonly subagent: OrchestrationV2Subagent;
193 readonly tickSeconds: boolean;
194}) {
195 const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds);
196 return elapsed === null ? null : (
197 <Text className="shrink-0 text-xs tabular-nums text-foreground-muted">{elapsed}</Text>
198 );
199}
200
201function useSubagentElapsed(
202 subagent: Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">,
203 tickSeconds: boolean,
204): string | null {
205 const running = isOrchestrationV2WorkActive(subagent.status);
206 const nowMs = useVisibleSecondClock(tickSeconds && running);
207 const elapsedMs = deriveSubagentElapsedMs(
208 {
209 status: subagent.status,
210 startedAt: subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt),
211 completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt),
212 },
213 nowMs,
214 );
215 return elapsedMs === null || elapsedMs === 0 ? null : formatDuration(elapsedMs);
216}