1import {
2 SECRET_REQUEST_DEFAULT_PLACEHOLDER,
3 SECRET_REQUEST_PRIVACY_NOTE,
4 secretRequestAnswerInput,
5 secretRequestDisplay,
6 secretRequestFailureMessage,
7 type SecretRequestItem,
8} from "@t3tools/client-runtime/secret-request";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
13import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
14import { useRef, useState } from "react";
15import { Pressable, View, type ColorValue } from "react-native";
16
17import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
18import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
19import { serverEnvironment } from "../../state/server";
20import { useAtomCommand } from "../../state/use-atom-command";
21import { RequestActionButton } from "./RequestActionButton";
22
23/**
24 * Feed card for a secret an agent asked the user for. The typed value lives
25 * only in this component's state and the RPC payload: it is never logged,
26 * alerted, or persisted, and the field clears once the answer is sent.
27 */
28const LOCK_SYMBOL: AppSymbolName = { ios: "lock", android: "lock" };
29const PRIVATE_SYMBOL: AppSymbolName = { ios: "checkmark.shield", android: "lock" };
30
31export function SecretRequestCard(props: {
32 readonly environmentId: EnvironmentId;
33 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
34 readonly iconColor: ColorValue;
35}) {
36 const { item, visibility } = props.projectedItem;
37 if (item.type !== "secret_request") return null;
38 const display = secretRequestDisplay(item, visibility);
39 if (display.kind === "pending") {
40 return (
41 <PendingSecretRequestForm
42 environmentId={props.environmentId}
43 item={item}
44 iconColor={props.iconColor}
45 />
46 );
47 }
48 const icon: AppSymbolName =
49 display.kind === "pending-elsewhere"
50 ? LOCK_SYMBOL
51 : display.outcome === "saved"
52 ? "checkmark"
53 : "minus";
54 return (
55 <View className="mb-3 min-h-9 flex-row items-center gap-2 px-1">
56 <SymbolView name={icon} size={13} tintColor={props.iconColor} type="monochrome" />
57 <Text className="flex-1 font-sans text-sm text-foreground-muted" numberOfLines={2}>
58 {item.label} · {display.label}
59 </Text>
60 </View>
61 );
62}
63
64function PendingSecretRequestForm(props: {
65 readonly environmentId: EnvironmentId;
66 readonly item: SecretRequestItem;
67 readonly iconColor: ColorValue;
68}) {
69 const { item } = props;
70 const answer = useAtomCommand(serverEnvironment.answerSecretRequest, {
71 label: "answer secret request",
72 // The failure cause holds the request; keep it out of the console.
73 reportFailure: false,
74 reportDefect: false,
75 });
76 const [secret, setSecret] = useState("");
77 const [submitting, setSubmitting] = useState(false);
78 const [error, setError] = useState<string | null>(null);
79 // Submit then a tap can both run before a re-render; this guard is synchronous.
80 const inFlight = useRef(false);
81
82 const send = async (
83 reply: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
84 ) => {
85 const input = secretRequestAnswerInput(item, reply);
86 if (input === null || inFlight.current) return;
87 inFlight.current = true;
88 setSubmitting(true);
89 setError(null);
90 const result = await answer({ environmentId: props.environmentId, input }).finally(() => {
91 inFlight.current = false;
92 setSubmitting(false);
93 });
94 if (result._tag === "Success") {
95 // The card switches to its answered row once the item updates.
96 setSecret("");
97 return;
98 }
99 if (!isAtomCommandInterrupted(result)) {
100 setError(secretRequestFailureMessage(squashAtomCommandFailure(result)));
101 }
102 };
103
104 // Same hierarchy as web: what is asked, why, the field, then the promise
105 // about where the value goes.
106 return (
107 <View className="mb-3 gap-3 rounded-[20px] border border-border bg-card-alt p-4">
108 <View className="gap-1">
109 <Text className="font-t3-bold text-base text-foreground">{item.label}</Text>
110 {item.reason.trim() ? (
111 <Text className="font-sans text-sm leading-5 text-foreground-muted">{item.reason}</Text>
112 ) : null}
113 </View>
114 <TextInput
115 accessibilityLabel={item.label}
116 placeholder={item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
117 value={secret}
118 onChangeText={setSecret}
119 editable={!submitting}
120 secureTextEntry
121 autoCorrect={false}
122 autoCapitalize="none"
123 autoComplete="off"
124 textContentType="none"
125 importantForAutofill="no"
126 spellCheck={false}
127 returnKeyType="done"
128 onSubmitEditing={() => void send({ type: "save", secret })}
129 />
130 {error !== null ? (
131 <Text
132 accessibilityRole="alert"
133 accessibilityLiveRegion="polite"
134 className="font-sans text-sm text-danger-foreground"
135 >
136 {error}
137 </Text>
138 ) : null}
139 <RequestActionButton
140 label="Save securely"
141 disabled={submitting || secret.trim().length === 0}
142 onPress={() => void send({ type: "save", secret })}
143 />
144 <View className="flex-row items-center justify-between gap-2">
145 <View className="flex-1 flex-row items-center gap-1.5">
146 <SymbolView
147 name={PRIVATE_SYMBOL}
148 size={13}
149 tintColor={props.iconColor}
150 type="monochrome"
151 />
152 <Text className="flex-1 font-sans text-xs text-foreground-muted">
153 {SECRET_REQUEST_PRIVACY_NOTE}
154 </Text>
155 </View>
156 {/* Quiet like the web card's: the field and Save are the action. */}
157 <Pressable
158 accessibilityRole="button"
159 accessibilityState={{ disabled: submitting }}
160 disabled={submitting}
161 hitSlop={8}
162 className="px-1 py-1 active:opacity-60 disabled:opacity-50"
163 onPress={() => void send({ type: "decline" })}
164 >
165 <Text className="font-sans text-xs text-foreground-muted">Decline</Text>
166 </Pressable>
167 </View>
168 </View>
169 );
170}