1import { useAtomValue } from "@effect/atom-react";
2import type { ThreadTurnSubagents } from "@t3tools/client-runtime/state/thread-subagents";
3import {
4 isOrchestrationV2WorkActive,
5 type EnvironmentId,
6 type OrchestrationV2Subagent,
7 type ThreadId,
8} from "@t3tools/contracts";
9import { deriveSubagentElapsedMs, formatDuration } from "@t3tools/shared/orchestrationTiming";
10import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
11import * as DateTime from "effect/DateTime";
12import * as Haptics from "expo-haptics";
13import { useEffect, useState } from "react";
14import { Platform, Pressable, ScrollView, View } from "react-native";
15import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
16import { useSafeAreaInsets } from "react-native-safe-area-context";
17
18import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
19import { AppText as Text } from "../../components/AppText";
20import { useUniwindTheme } from "../../lib/useUniwindTheme";
21import { environmentThreadDetails } from "../../state/threads";
22import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
23import { SubagentRow } from "./SubagentRow";
24import { MaterialScreenContent } from "../../components/MaterialScreenContent";
25import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
26
27const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
28
29type AgentsTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
30
31export function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagents | null {
32 return useAtomValue(environmentThreadDetails.turnSubagentsAtom(target));
33}
34
35export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
36 const { layout } = useAdaptiveWorkspaceLayout();
37 const target = route.params;
38 const navigation = useNavigation();
39 const insets = useSafeAreaInsets();
40 const theme = useUniwindTheme();
41 const turn = useThreadTurnSubagents(target);
42 const subagents = turn?.subagents ?? [];
43 const hasLiveAgent = (turn?.liveCount ?? 0) > 0;
44
45 const openChildThread = (childThreadId: ThreadId) => {
46 void Haptics.selectionAsync();
47 // Replace rather than push: the sheet is a leaf, and the child thread
48 // belongs in the workspace stack where Home's back button expects it.
49 navigation.dispatch(
50 StackActions.replace("Thread", {
51 environmentId: target.environmentId,
52 threadId: childThreadId,
53 }),
54 );
55 };
56
57 const content = (
58 <ScrollView
59 nestedScrollEnabled
60 className="flex-1"
61 // The iOS header is translucent and floats over this view; UIKit has to
62 // inset the content or the first row sits underneath the title.
63 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
64 contentContainerClassName="px-5 pb-6"
65 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }}
66 >
67 {subagents.length === 0 ? (
68 <Text className="pt-6 text-center text-sm text-foreground-muted">
69 No agents in this turn.
70 </Text>
71 ) : (
72 subagents.map((subagent) => (
73 <AgentRow
74 key={subagent.id}
75 subagent={subagent}
76 environmentId={target.environmentId}
77 tickSeconds={hasLiveAgent}
78 onOpen={openChildThread}
79 />
80 ))
81 )}
82 </ScrollView>
83 );
84
85 if (Platform.OS === "ios") {
86 // A plain formSheet screen never renders a stack header, so it comes from
87 // a nested native stack inside the sheet (same shape as the git sheet).
88 return (
89 <View collapsable={false} className="flex-1 bg-sheet">
90 <ScreenStack style={{ flex: 1 }}>
91 <Screen
92 activityState={2}
93 enabled
94 isNativeStack
95 screenId="thread-agents-sheet-native"
96 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
97 style={{ backgroundColor: theme["--color-sheet"], flex: 1 }}
98 >
99 {content}
100 <ScreenStackHeaderConfig
101 backgroundColor="rgba(0,0,0,0)"
102 color={theme["--color-foreground"]}
103 hideBackButton
104 hideShadow={false}
105 title="Agents"
106 titleColor={theme["--color-foreground"]}
107 titleFontSize={18}
108 titleFontWeight="800"
109 translucent
110 />
111 </Screen>
112 </ScreenStack>
113 </View>
114 );
115 }
116
117 return (
118 <View collapsable={false} className="flex-1 bg-sheet">
119 <NativeStackScreenOptions options={{ sheetCornerRadius: 28 }} />
120 <AndroidSheetHeader title="Agents" onBack={() => navigation.goBack()} />
121 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
122 {content}
123 </MaterialScreenContent>
124 </View>
125 );
126}
127
128function AgentRow(props: {
129 readonly environmentId: EnvironmentId;
130 readonly subagent: OrchestrationV2Subagent;
131 readonly tickSeconds: boolean;
132 readonly onOpen: (childThreadId: ThreadId) => void;
133}) {
134 const { subagent } = props;
135 const childThreadId = subagent.childThreadId;
136
137 const row = (
138 <View className="border-b border-border py-3.5">
139 <SubagentRow
140 environmentId={props.environmentId}
141 subagent={subagent}
142 elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
143 />
144 </View>
145 );
146
147 if (childThreadId === null) {
148 return (
149 <View
150 accessible
151 accessibilityHint="Provider-managed agent. Its work appears in the transcript."
152 >
153 {row}
154 </View>
155 );
156 }
157
158 return (
159 <Pressable
160 accessibilityRole="link"
161 accessibilityHint="Opens this agent's thread"
162 onPress={() => props.onOpen(childThreadId)}
163 className="active:opacity-70"
164 >
165 {row}
166 </Pressable>
167 );
168}
169
170function AgentElapsed(props: {
171 readonly subagent: OrchestrationV2Subagent;
172 readonly tickSeconds: boolean;
173}) {
174 const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds);
175 return elapsed === null ? null : (
176 <Text className="shrink-0 text-xs tabular-nums text-foreground-muted">{elapsed}</Text>
177 );
178}
179
180/**
181 * Elapsed time for one agent. Only live work ticks, inside AgentElapsed,
182 * so the timer never repaints the metadata or a settled sheet.
183 */
184function useSubagentElapsed(
185 subagent: Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">,
186 tickSeconds: boolean,
187): string | null {
188 const [nowMs, setNowMs] = useState(() => Date.now());
189 const running = isOrchestrationV2WorkActive(subagent.status);
190 useEffect(() => {
191 if (!tickSeconds || !running) return;
192 const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
193 return () => clearInterval(intervalId);
194 }, [running, tickSeconds]);
195 const elapsedMs = deriveSubagentElapsedMs(
196 {
197 status: subagent.status,
198 startedAt: subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt),
199 completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt),
200 },
201 nowMs,
202 );
203 return elapsedMs === null || elapsedMs === 0 ? null : formatDuration(elapsedMs);
204}