1import { useAtomValue } from "@effect/atom-react";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff";
4import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display";
5import type {
6 EnvironmentId,
7 OrchestrationV2ProjectedTurnItem,
8 ProviderInstanceId,
9 ServerProvider,
10} from "@t3tools/contracts";
11import { Atom } from "effect/reactivity";
12import { useMemo } from "react";
13import { Alert, Pressable, View, type ColorValue } from "react-native";
14import { AppText as Text } from "../../components/AppText";
15import { SymbolView } from "../../components/AppSymbol";
16import { ProviderIcon } from "../../components/ProviderIcon";
17import { environmentThreadDetails } from "../../state/threads";
18import { serverEnvironment } from "../../state/server";
19import { ThreadContextDivider } from "./thread-context-divider";
20
21function 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
37export 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
61export 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
100function 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}