1import { useAtomValue } from "@effect/atom-react";
2import { StackActions, useIsFocused, useNavigation } from "@react-navigation/native";
3import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display";
5import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
6import type {
7 EnvironmentId,
8 OrchestrationV2Subagent,
9 OrchestrationV2TurnItem,
10} from "@t3tools/contracts";
11import { useEffect, useState } from "react";
12import { AppState, Pressable, View, type ColorValue } from "react-native";
13
14import { AppText as Text } from "../../components/AppText";
15import { SymbolView } from "../../components/AppSymbol";
16import { ProviderIcon } from "../../components/ProviderIcon";
17import { cn } from "../../lib/cn";
18import type { ThreadFeedActivity } from "../../lib/threadActivity";
19import { serverEnvironment } from "../../state/server";
20import { environmentThreadDetails } from "../../state/threads";
21import { subagentCardElapsed } from "./subagent-card-presentation";
22import { SubagentRow } from "./SubagentRow";
23import { WorkLogBlock } from "./work-log-layout";
24
25type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>;
26type AgentTiming = Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">;
27
28function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTiming> }) {
29 const focused = useIsFocused();
30 const live = agents.some((agent) => isActiveSubagentStatus(agent.status));
31 const [nowMs, setNowMs] = useState(() => Date.now());
32 const [appActive, setAppActive] = useState(() => AppState.currentState === "active");
33 useEffect(() => {
34 const subscription = AppState.addEventListener("change", (state) =>
35 setAppActive(state === "active"),
36 );
37 return () => subscription.remove();
38 }, []);
39 useEffect(() => {
40 if (!live || !focused || !appActive) return;
41 const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
42 return () => clearInterval(intervalId);
43 }, [appActive, focused, live]);
44 const elapsed = subagentCardElapsed(agents, nowMs);
45 return elapsed ? (
46 <Text className="shrink-0 text-xs tabular-nums text-foreground-muted">{elapsed}</Text>
47 ) : null;
48}
49
50function SubagentAvatar(props: {
51 readonly item: SubagentItem;
52 readonly iconUrl?: string | null | undefined;
53}) {
54 return (
55 <View
56 accessible={false}
57 className="h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card"
58 >
59 <ProviderIcon provider={props.item.driver} iconUrl={props.iconUrl} size={15} />
60 </View>
61 );
62}
63
64export function ThreadSubagentGroup(props: {
65 readonly activities: ReadonlyArray<ThreadFeedActivity>;
66 readonly environmentId: EnvironmentId;
67 readonly anchorKey: string;
68 readonly expandedRows: Readonly<Record<string, boolean>>;
69 readonly iconSubtleColor: ColorValue;
70 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
71}) {
72 const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
73 const navigation = useNavigation();
74 const members = props.activities.flatMap(({ projectedItem }) =>
75 projectedItem.item.type === "subagent" ? [projectedItem.item] : [],
76 );
77 const liveAgents = useAtomValue(
78 environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)),
79 (thread) => thread?.projection.subagents,
80 );
81 const agents = members.map((item) => {
82 const live = liveAgents?.find((agent) => agent.id === item.subagentId);
83 return {
84 ...item,
85 item,
86 status: live?.status ?? item.status,
87 startedAt: live?.startedAt ?? item.startedAt,
88 completedAt: live?.completedAt ?? item.completedAt,
89 result: live?.result ?? item.result,
90 progress: live?.progress ?? item.progress,
91 model: live?.model ?? null,
92 };
93 });
94 const grouped = agents.length > 1;
95 const label = `${agents.length} subagents`;
96 const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status));
97 const expanded = props.expandedRows[props.anchorKey] ?? false;
98 const iconUrl = (item: SubagentItem) =>
99 config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl;
100 return (
101 <WorkLogBlock>
102 {grouped ? (
103 <Pressable
104 accessibilityRole="button"
105 accessibilityLabel={`${label}, ${summary}`}
106 accessibilityState={{ expanded }}
107 onPress={() => props.onToggleRow(props.anchorKey, props.anchorKey)}
108 className="min-h-14 flex-row items-center gap-3 rounded-lg py-2 active:bg-subtle"
109 >
110 <View className="flex-row items-center">
111 {agents.slice(0, 3).map((agent, index) => (
112 <View key={agent.item.id} style={{ marginLeft: index === 0 ? 0 : -7 }}>
113 <SubagentAvatar item={agent.item} iconUrl={iconUrl(agent.item)} />
114 </View>
115 ))}
116 {agents.length > 3 ? (
117 <View className="-ml-2 h-7 w-7 items-center justify-center rounded-full border border-border bg-card">
118 <Text className="text-2xs text-foreground-muted">+{agents.length - 3}</Text>
119 </View>
120 ) : null}
121 </View>
122 <View className="min-w-0 flex-1 gap-0.5">
123 <Text numberOfLines={1} className="font-t3-medium text-sm text-foreground">
124 {label}
125 </Text>
126 <Text
127 numberOfLines={1}
128 className={cn(
129 "text-2xs text-foreground-muted",
130 agents.some((agent) => isActiveSubagentStatus(agent.status))
131 ? "text-adaptive-sky-600-400"
132 : agents.some((agent) => agent.status === "failed") &&
133 "text-adaptive-rose-600-400",
134 )}
135 >
136 {summary}
137 </Text>
138 </View>
139 <SubagentElapsed agents={agents} />
140 <SymbolView
141 name={expanded ? "chevron.up" : "chevron.down"}
142 size={11}
143 tintColor={props.iconSubtleColor}
144 />
145 </Pressable>
146 ) : null}
147 {!grouped || expanded ? (
148 <View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">
149 {agents.map((agent) => {
150 const threadId = agent.childThreadId;
151 return (
152 <Pressable
153 key={agent.item.id}
154 accessible
155 accessibilityRole={threadId === null ? undefined : "link"}
156 accessibilityHint={
157 threadId === null ? "Provider-managed agent" : "Opens this agent's thread"
158 }
159 disabled={threadId === null}
160 onPress={() => {
161 // Push, not navigate: navigate reuses this Thread route, so back
162 // would skip the parent thread.
163 if (threadId !== null)
164 navigation.dispatch(
165 StackActions.push("Thread", {
166 environmentId: String(props.environmentId),
167 threadId: String(threadId),
168 }),
169 );
170 }}
171 className="rounded-lg px-3 py-3 active:bg-subtle"
172 >
173 <SubagentRow
174 environmentId={props.environmentId}
175 subagent={agent}
176 elapsed={<SubagentElapsed agents={[agent]} />}
177 />
178 </Pressable>
179 );
180 })}
181 </View>
182 ) : null}
183 </WorkLogBlock>
184 );
185}