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 { CheckIcon, LockIcon, MinusIcon, ShieldCheckIcon } from "lucide-react";
15import { useId, useRef, useState, type FormEvent } from "react";
16
17import { serverEnvironment } from "../../state/server";
18import { useAtomCommand } from "../../state/use-atom-command";
19import { Button } from "../ui/button";
20import { Input } from "../ui/input";
21import { WorkLogRow } from "./WorkLog";
22
23/**
24 * Inline 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 * toasted, or persisted, and the field clears once the answer is sent.
27 */
28export function SecretRequestCard(props: {
29 readonly environmentId: EnvironmentId;
30 readonly item: SecretRequestItem;
31 readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
32}) {
33 const { item } = props;
34 const display = secretRequestDisplay(item, props.visibility);
35 if (display.kind === "answered" || display.kind === "pending-elsewhere") {
36 const Icon =
37 display.kind === "pending-elsewhere"
38 ? LockIcon
39 : display.outcome === "saved"
40 ? CheckIcon
41 : MinusIcon;
42 return (
43 <WorkLogRow
44 data-v2-item-type={item.type}
45 icon={<Icon className="size-3.5 text-icon-muted" aria-hidden />}
46 label={
47 <>
48 {item.label} · {display.label}
49 </>
50 }
51 />
52 );
53 }
54 return <PendingSecretRequestForm environmentId={props.environmentId} item={item} />;
55}
56
57function PendingSecretRequestForm(props: {
58 readonly environmentId: EnvironmentId;
59 readonly item: SecretRequestItem;
60}) {
61 const { item } = props;
62 const inputId = useId();
63 const errorId = useId();
64 const privacyId = useId();
65 const answer = useAtomCommand(serverEnvironment.answerSecretRequest, {
66 label: "answer secret request",
67 // The failure cause holds the request; keep it out of the console.
68 reportFailure: false,
69 reportDefect: false,
70 });
71 const [secret, setSecret] = useState("");
72 const [submitting, setSubmitting] = useState(false);
73 const [error, setError] = useState<string | null>(null);
74 // Enter then a click can both run before a re-render; this guard is synchronous.
75 const inFlight = useRef(false);
76
77 const send = async (
78 reply: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
79 ) => {
80 const input = secretRequestAnswerInput(item, reply);
81 if (input === null || inFlight.current) return;
82 inFlight.current = true;
83 setSubmitting(true);
84 setError(null);
85 const result = await answer({ environmentId: props.environmentId, input }).finally(() => {
86 inFlight.current = false;
87 setSubmitting(false);
88 });
89 if (result._tag === "Success") {
90 // The card switches to its answered row once the item updates.
91 setSecret("");
92 return;
93 }
94 if (!isAtomCommandInterrupted(result)) {
95 setError(secretRequestFailureMessage(squashAtomCommandFailure(result)));
96 }
97 };
98
99 const onSubmit = (event: FormEvent<HTMLFormElement>) => {
100 event.preventDefault();
101 void send({ type: "save", secret });
102 };
103
104 // Same hierarchy as a chat card: what is asked, why, the field, then the
105 // promise about where the value goes.
106 return (
107 <form
108 data-v2-item-type={item.type}
109 className="flex min-w-0 flex-col gap-3 rounded-xl border border-border/60 bg-card p-4"
110 onSubmit={onSubmit}
111 autoComplete="off"
112 // A click anywhere on the card targets its field, so a paste that
113 // follows lands here rather than with the composer.
114 onPointerDown={(event) => {
115 if (!(event.target instanceof HTMLElement)) return;
116 if (event.target.closest("input, button, a")) return;
117 document.getElementById(inputId)?.focus();
118 }}
119 >
120 <div className="flex min-w-0 flex-col gap-1">
121 <label htmlFor={inputId} className="text-sm font-medium text-foreground">
122 {item.label}
123 </label>
124 {item.reason.trim() ? <p className="text-sm text-muted-foreground">{item.reason}</p> : null}
125 </div>
126 <div className="flex min-w-0 items-center gap-2">
127 <div className="min-w-0 flex-1">
128 <Input
129 id={inputId}
130 // Masked text rather than a password field: browsers offer to
131 // save any submitted password, and this is not a login.
132 type="text"
133 className="[-webkit-text-security:disc]"
134 autoComplete="off"
135 autoCorrect="off"
136 autoCapitalize="off"
137 spellCheck={false}
138 // Password managers otherwise offer to save or fill this field.
139 data-1p-ignore
140 data-lpignore="true"
141 data-bwignore
142 placeholder={item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
143 value={secret}
144 disabled={submitting}
145 aria-invalid={error !== null || undefined}
146 aria-describedby={error !== null ? `${privacyId} ${errorId}` : privacyId}
147 onChange={(event) => setSecret(event.currentTarget.value)}
148 />
149 </div>
150 <Button type="submit" disabled={submitting || secret.trim().length === 0}>
151 Save securely
152 </Button>
153 </div>
154 {error !== null ? (
155 <p id={errorId} role="alert" className="text-xs text-destructive">
156 {error}
157 </p>
158 ) : null}
159 <div className="flex min-w-0 items-center justify-between gap-2">
160 <p
161 id={privacyId}
162 className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
163 >
164 <ShieldCheckIcon className="size-3.5 shrink-0" aria-hidden />
165 {SECRET_REQUEST_PRIVACY_NOTE}
166 </p>
167 <Button
168 type="button"
169 size="xs"
170 variant="ghost-muted"
171 disabled={submitting}
172 onClick={() => void send({ type: "decline" })}
173 >
174 Decline
175 </Button>
176 </div>
177 </form>
178 );
179}