1import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts";
3import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests";
4import {
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import { useState } from "react";
9import { useEnvironmentScope } from "../../state/session";
10import { ScrollView, View } from "react-native";
11import Animated, { useAnimatedStyle, type DerivedValue } from "react-native-reanimated";
12import { AppText } from "../../components/ui/AppText";
13import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads";
14import { useAtomCommand } from "../../state/use-atom-command";
15import { PendingApprovalCard } from "./PendingApprovalCard";
16
17function ChildApproval({ entry }: { entry: ChildThreadApproval }) {
18 const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope);
19 const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false });
20 const [responding, setResponding] = useState(false);
21 const [error, setError] = useState<string | null>(null);
22 return (
23 <View className="mx-3.5 my-2 gap-2">
24 <AppText className="font-t3-bold text-sm text-foreground">
25 Approval from {entry.title}
26 </AppText>
27 <PendingApprovalCard
28 canOperateThread={canRespond}
29 approval={entry.approval}
30 respondingApprovalId={responding ? entry.approval.requestId : null}
31 onRespond={async (requestId, decision) => {
32 if (!canRespond || responding) return;
33 setResponding(true);
34 setError(null);
35 const result = await respond({
36 environmentId: entry.thread.environmentId,
37 input: { threadId: entry.thread.threadId, requestId, decision },
38 });
39 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
40 const failure = squashAtomCommandFailure(result);
41 setError(
42 failure instanceof Error ? failure.message : "Failed to submit approval decision.",
43 );
44 }
45 setResponding(false);
46 return result;
47 }}
48 />
49 {error !== null ? (
50 <AppText
51 accessibilityRole="alert"
52 accessibilityLiveRegion="polite"
53 className="font-sans text-sm text-danger-foreground"
54 >
55 {error}
56 </AppText>
57 ) : null}
58 </View>
59 );
60}
61
62export function ChildThreadApprovals({
63 threadRef,
64 maxHeight,
65}: {
66 threadRef: ScopedThreadRef | null;
67 maxHeight: DerivedValue<number>;
68}) {
69 const approvals = useAtomValue(childThreadApprovalsAtom(threadRef));
70 const heightStyle = useAnimatedStyle(() => ({ maxHeight: Math.min(320, maxHeight.value / 2) }));
71 if (approvals.length === 0) return null;
72 return (
73 <Animated.View style={heightStyle} className="overflow-hidden">
74 <ScrollView keyboardShouldPersistTaps="handled" nestedScrollEnabled style={{ flexShrink: 1 }}>
75 {approvals.map((entry) => (
76 <ChildApproval
77 key={JSON.stringify([
78 entry.thread.environmentId,
79 entry.thread.threadId,
80 entry.approval.requestId,
81 ])}
82 entry={entry}
83 />
84 ))}
85 </ScrollView>
86 </Animated.View>
87 );
88}