| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { StackActions, useIsFocused, useNavigation } from "@react-navigation/native"; |
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; |
| 5 | import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 6 | import type { |
| 7 | EnvironmentId, |
| 8 | OrchestrationV2Subagent, |
| 9 | OrchestrationV2TurnItem, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { useEffect, useState } from "react"; |
| 12 | import { AppState, Pressable, View, type ColorValue } from "react-native"; |
| 13 | |
| 14 | import { AppText as Text } from "../../components/AppText"; |
| 15 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 17 | import { cn } from "../../lib/cn"; |
| 18 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 19 | import { serverEnvironment } from "../../state/server"; |
| 20 | import { environmentThreadDetails } from "../../state/threads"; |
| 21 | import { subagentCardElapsed } from "./subagent-card-presentation"; |
| 22 | import { SubagentRow } from "./SubagentRow"; |
| 23 | import { WorkLogBlock } from "./work-log-layout"; |
| 24 | |
| 25 | type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>; |
| 26 | type AgentTiming = Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">; |
| 27 | |
| 28 | function 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 | |
| 50 | function 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 | |
| 64 | export 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 | } |