| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { ThreadTurnSubagents } from "@t3tools/client-runtime/state/thread-subagents"; |
| 3 | import { |
| 4 | isOrchestrationV2WorkActive, |
| 5 | type EnvironmentId, |
| 6 | type OrchestrationV2Subagent, |
| 7 | type ThreadId, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { deriveSubagentElapsedMs, formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 10 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 11 | import * as DateTime from "effect/DateTime"; |
| 12 | import * as Haptics from "expo-haptics"; |
| 13 | import { useEffect, useState } from "react"; |
| 14 | import { Platform, Pressable, ScrollView, View } from "react-native"; |
| 15 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; |
| 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | |
| 18 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; |
| 19 | import { AppText as Text } from "../../components/AppText"; |
| 20 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 21 | import { environmentThreadDetails } from "../../state/threads"; |
| 22 | import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 23 | import { SubagentRow } from "./SubagentRow"; |
| 24 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| 25 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 26 | |
| 27 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 28 | |
| 29 | type AgentsTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; |
| 30 | |
| 31 | export function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagents | null { |
| 32 | return useAtomValue(environmentThreadDetails.turnSubagentsAtom(target)); |
| 33 | } |
| 34 | |
| 35 | export 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 | |
| 128 | function 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 | |
| 170 | function 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 | */ |
| 184 | function 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 | } |