| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff"; |
| 4 | import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; |
| 5 | import type { |
| 6 | EnvironmentId, |
| 7 | OrchestrationV2ProjectedTurnItem, |
| 8 | ProviderInstanceId, |
| 9 | ServerProvider, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { Atom } from "effect/reactivity"; |
| 12 | import { useMemo } from "react"; |
| 13 | import { Alert, Pressable, View, type ColorValue } from "react-native"; |
| 14 | import { AppText as Text } from "../../components/AppText"; |
| 15 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 17 | import { environmentThreadDetails } from "../../state/threads"; |
| 18 | import { serverEnvironment } from "../../state/server"; |
| 19 | import { ThreadContextDivider } from "./thread-context-divider"; |
| 20 | |
| 21 | function handoffEndpointsAtom( |
| 22 | environmentId: EnvironmentId, |
| 23 | { item, sourceThreadId }: OrchestrationV2ProjectedTurnItem, |
| 24 | ) { |
| 25 | let previous: ReturnType<typeof resolveHandoffEndpoints> | null = null; |
| 26 | const ref = scopeThreadRef(environmentId, sourceThreadId); |
| 27 | return Atom.make((get) => { |
| 28 | if (item.type !== "handoff") return null; |
| 29 | const projection = get(environmentThreadDetails.threadAtom(ref))?.projection; |
| 30 | const next = resolveHandoffEndpoints(item, projection?.runs ?? []); |
| 31 | // Run activity changes frequently; only endpoint changes affect this row. |
| 32 | if (JSON.stringify(next) !== JSON.stringify(previous)) previous = next; |
| 33 | return previous; |
| 34 | }); |
| 35 | } |
| 36 | |
| 37 | export function ThreadHandoffRow(props: { |
| 38 | environmentId: EnvironmentId; |
| 39 | projectedItem: OrchestrationV2ProjectedTurnItem; |
| 40 | iconColor: ColorValue; |
| 41 | }) { |
| 42 | const { item } = props.projectedItem; |
| 43 | const endpointsAtom = useMemo( |
| 44 | () => handoffEndpointsAtom(props.environmentId, props.projectedItem), |
| 45 | [props.environmentId, props.projectedItem], |
| 46 | ); |
| 47 | const endpoints = useAtomValue(endpointsAtom); |
| 48 | if (item.type !== "handoff" || endpoints === null) return null; |
| 49 | const color = item.status === "failed" ? "#e11d48" : props.iconColor; |
| 50 | return ( |
| 51 | <ThreadHandoffDivider |
| 52 | environmentId={props.environmentId} |
| 53 | endpoints={endpoints} |
| 54 | iconColor={color} |
| 55 | label="Context handoff" |
| 56 | failed={item.status === "failed"} |
| 57 | /> |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | export function ThreadHandoffDivider(props: { |
| 62 | environmentId: EnvironmentId; |
| 63 | endpoints: HandoffEndpoints; |
| 64 | iconColor: ColorValue; |
| 65 | label: string; |
| 66 | failed?: boolean; |
| 67 | }) { |
| 68 | const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); |
| 69 | const { endpoints } = props; |
| 70 | return ( |
| 71 | <ThreadContextDivider |
| 72 | label={props.label} |
| 73 | icon="arrow.left.arrow.right" |
| 74 | iconColor={props.iconColor} |
| 75 | failed={props.failed} |
| 76 | > |
| 77 | <View className="flex-row flex-wrap items-center justify-center gap-1.5"> |
| 78 | {endpoints.from.map((endpoint, index) => ( |
| 79 | <View |
| 80 | key={`${endpoint.instanceId}:${endpoint.model ?? ""}`} |
| 81 | className="max-w-full flex-row items-center" |
| 82 | > |
| 83 | <HandoffEndpoint {...endpoint} providers={config?.providers ?? []} /> |
| 84 | {index < endpoints.from.length - 1 ? ( |
| 85 | <Text accessible={false} className="text-xs text-foreground-muted"> |
| 86 | , |
| 87 | </Text> |
| 88 | ) : null} |
| 89 | </View> |
| 90 | ))} |
| 91 | {endpoints.from.length > 0 ? ( |
| 92 | <SymbolView name="arrow.right" size={12} tintColor={props.iconColor} /> |
| 93 | ) : null} |
| 94 | <HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} /> |
| 95 | </View> |
| 96 | </ThreadContextDivider> |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | function HandoffEndpoint(props: { |
| 101 | instanceId: ProviderInstanceId; |
| 102 | model?: string | undefined; |
| 103 | providers: ReadonlyArray<ServerProvider>; |
| 104 | }) { |
| 105 | const provider = props.providers.find((candidate) => candidate.instanceId === props.instanceId); |
| 106 | const model = provider?.models.find((candidate) => candidate.slug === props.model); |
| 107 | const label = |
| 108 | model?.shortName ?? |
| 109 | model?.name ?? |
| 110 | props.model ?? |
| 111 | (provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId); |
| 112 | return ( |
| 113 | <Pressable |
| 114 | accessibilityRole="button" |
| 115 | accessibilityLabel={label} |
| 116 | accessibilityHint="Show provider account" |
| 117 | className="min-h-6 max-w-full flex-row items-center justify-center gap-1" |
| 118 | hitSlop={{ top: 8, bottom: 8 }} |
| 119 | onPress={() => |
| 120 | Alert.alert( |
| 121 | label, |
| 122 | provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId, |
| 123 | ) |
| 124 | } |
| 125 | > |
| 126 | <ProviderIcon |
| 127 | provider={provider?.driver ?? props.instanceId} |
| 128 | iconUrl={provider?.iconUrl} |
| 129 | size={12} |
| 130 | /> |
| 131 | <Text numberOfLines={1} className="shrink text-xs text-foreground-muted"> |
| 132 | {label} |
| 133 | </Text> |
| 134 | </Pressable> |
| 135 | ); |
| 136 | } |