| 1 | import { |
| 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"; |
| 9 | import { |
| 10 | isAtomCommandInterrupted, |
| 11 | squashAtomCommandFailure, |
| 12 | } from "@t3tools/client-runtime/state/runtime"; |
| 13 | import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; |
| 14 | import { CheckIcon, LockIcon, MinusIcon, ShieldCheckIcon } from "lucide-react"; |
| 15 | import { useId, useRef, useState, type FormEvent } from "react"; |
| 16 | |
| 17 | import { serverEnvironment } from "../../state/server"; |
| 18 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 19 | import { Button } from "../ui/button"; |
| 20 | import { Input } from "../ui/input"; |
| 21 | import { 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 | */ |
| 28 | export 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 | |
| 57 | function 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 | } |