import type { HumanInputAnswer, HumanInputQuestion, HumanInputSecretAnswer, HumanInputSecretSaveTo, HumanInputSecretSpec, SkillRecord, SessionHumanInputRequest, SubmitHumanInputResponseRequest, } from "@opengeni/sdk"; import { ChevronDownIcon, ChevronUpIcon, MessageCircleQuestionIcon } from "lucide-react"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent, type ReactNode, } from "react"; import { cn } from "../lib/cn"; import { useErrorMessage } from "../lib/error-message"; import { answersFromDrafts, defaultHumanInputFormMessages, emptyDraft, initialDrafts, secretAnswerFromReading, type HumanInputAnswerDraft, type HumanInputFormMessages, } from "../human-input-model"; import { forgetHumanInputDraft, readHumanInputDraft, writeHumanInputDraft, type HumanInputDraftScope, } from "../human-input-draft-store"; import { SecretQuestionField, type SecretFieldHandle } from "./secret-question-field"; import { linkifyText } from "./linkified-text"; import { useOpenGeniText } from "../messages/runtime"; export { answersFromDrafts, defaultHumanInputFormMessages, type HumanInputAnswerDraft, type HumanInputFormMessages, }; class SkillReviewVerificationError extends Error {} export type HumanInputFormProps = { /** Scoped immutable revision reader. Saving a Skill fails closed without its full preview. */ loadSkillReview?: | ((reference: NonNullable) => Promise) | undefined; /** * With `workspaceId` and `sessionId` (as on a full pending request), unsent * non-secret answers are kept per browser tab and restored when the card * mounts again, e.g. after visiting Settings and coming back. */ request: Pick & Partial>; /** * Resolve only once the response was accepted: the form then forgets the * unsent draft and clears secret fields. Reject when sending failed so the * card keeps what was typed for a retry. */ onSubmit: (response: SubmitHumanInputResponseRequest) => void | Promise; submitting?: boolean | undefined; error?: string | null | undefined; title?: ReactNode; description?: ReactNode; /** e.g. "1 of 2" when a host is stepping through parallel requests. */ progressLabel?: ReactNode; submitLabel?: string | undefined; skipLabel?: string | undefined; messages?: Partial | undefined; autoFocus?: boolean | undefined; /** Start collapsed to a compact bar (drafts still retained). */ defaultCollapsed?: boolean | undefined; /** * Answer a yes/no decision (one required choice between an accept and a * decline option, without Other) with two buttons instead of a radio list. * Off by default; embedded conversations turn it on. */ decisionButtons?: boolean | undefined; /** * Where this person may save an answer to a secret question. A request for * a target outside this list falls back to the most private allowed one, * with a note. Defaults to Only me, This task and This workspace. */ allowedSaveTo?: readonly HumanInputSecretSaveTo[] | undefined; className?: string | undefined; }; /** * Styled but host-neutral renderer for one structured request. Matches the * waiting-tone decision language of ApprovalSurface: question-first, compact * options, sticky ask/submit chrome. Hosts can replace title/description or * use `useHumanInputRequests` headlessly. */ export function HumanInputForm(props: HumanInputFormProps) { // The pending-request read model is refreshed after session events and // returns newly allocated question arrays for the same durable request. // Key the state owner by that request's lifecycle identity so reconciliation // cannot erase an answer that the operator is still typing. return ; } function HumanInputRequestForm({ request, onSubmit, loadSkillReview, submitting = false, error, title, description, progressLabel, submitLabel, skipLabel, messages: messageOverrides, autoFocus = true, defaultCollapsed = false, decisionButtons = false, allowedSaveTo, className, }: HumanInputFormProps) { const t = useOpenGeniText(); const formatError = useErrorMessage(); const messages = { ...defaultHumanInputFormMessages(t), ...messageOverrides }; const singleQuestion = request.questions.length === 1 ? request.questions[0]! : null; const decision = decisionButtons ? binaryDecision(request.questions) : null; const [decisionChoice, setDecisionChoice] = useState(null); const resolvedTitle = title === undefined ? singleQuestion ? (singleQuestion.label ?? singleQuestion.prompt) : messages.title : title; const resolvedDescription = description === undefined ? singleQuestion ? singleQuestion.label ? singleQuestion.prompt : (singleQuestion.helpText ?? null) : messages.description || null : description; const resolvedSubmitLabel = submitLabel ?? messages.submit; const resolvedSkipLabel = skipLabel ?? messages.skip; const formId = useId(); const titleId = useId(); const scrollRef = useRef(null); const draftScope = useMemo( () => request.workspaceId && request.sessionId ? { workspaceId: request.workspaceId, sessionId: request.sessionId, requestId: request.id } : null, [request.id, request.sessionId, request.workspaceId], ); const requestSettled = request.status !== undefined && request.status !== "pending"; const [drafts, setDrafts] = useState>(() => ({ ...initialDrafts(request.questions), ...(draftScope && !requestSettled ? readHumanInputDraft(draftScope, request) : null), })); // Once a response is accepted, nothing typed afterwards becomes a draft again. const draftClosed = useRef(false); // Mounting (or StrictMode's second mount) is not an edit: rewriting the // restored draft would refresh its age and let it outlive a pending-list // read that already settled it. const mountedDrafts = useRef(drafts); const questionsRef = useRef(request.questions); questionsRef.current = request.questions; useEffect(() => { if (!draftScope) return; if (requestSettled) { draftClosed.current = true; forgetHumanInputDraft(draftScope); return; } if (draftClosed.current || drafts === mountedDrafts.current) return; writeHumanInputDraft(draftScope, questionsRef.current, drafts); }, [draftScope, drafts, requestSettled]); const [validationErrors, setValidationErrors] = useState>({}); const [submissionError, setSubmissionError] = useState(null); const [submittingInternally, setSubmittingInternally] = useState(false); const [overflowBelow, setOverflowBelow] = useState(false); const [collapsed, setCollapsed] = useState(defaultCollapsed); const submissionInFlight = useRef(false); const submissionGeneration = useRef(0); // Secret answers stay in their uncontrolled inputs until submit. const secretHandles = useRef(new Map()); const secretRegistrars = useMemo( () => new Map void>(), [], ); const registerSecret = useCallback( (questionId: string) => { let registrar = secretRegistrars.get(questionId); if (!registrar) { registrar = (handle) => { if (handle) secretHandles.current.set(questionId, handle); else secretHandles.current.delete(questionId); }; secretRegistrars.set(questionId, registrar); } return registrar; }, [secretRegistrars], ); const busy = submitting || submittingInternally; const reviewIdentity = JSON.stringify( request.questions .filter((question) => question.skillReview) .map((question) => ({ id: question.id, reference: { sourceOperationId: question.skillReview!.sourceOperationId, skillId: question.skillReview!.skillId, revisionId: question.skillReview!.revisionId, expectedRevisionId: question.skillReview!.expectedRevisionId, expectedScopeVersion: question.skillReview!.expectedScopeVersion, ...(question.skillReview!.removalOperationId ? { removalOperationId: question.skillReview!.removalOperationId } : {}), }, })), ); const [reviewReload, setReviewReload] = useState(0); const [reviews, setReviews] = useState<{ loader: typeof loadSkillReview; identity: string; records: Record; error: string | null; } | null>(null); useEffect(() => { let current = true; const questions = JSON.parse(reviewIdentity) as Array<{ id: string; reference: NonNullable; }>; if (!questions.length) return; setReviews(null); void Promise.all( questions.map(async (question) => { if (!loadSkillReview) throw new Error(t("humanInputForm.thisClientCannotPreviewSkill")); const reference = question.reference; const record = await loadSkillReview(reference); if ( record.id !== reference.skillId || record.revisionId !== reference.revisionId || (record.removalOperationId ?? undefined) !== reference.removalOperationId || !record.files.some((file) => file.path === "SKILL.md") ) { throw new SkillReviewVerificationError( t("humanInputForm.theRequestedSkillRevisionCould"), ); } return [question.id, record] as const; }), ) .then((records) => { if (current) setReviews({ loader: loadSkillReview, identity: reviewIdentity, records: Object.fromEntries(records), error: null, }); }) .catch((cause) => { if (current) setReviews({ loader: loadSkillReview, identity: reviewIdentity, records: {}, error: formatError( cause, cause instanceof SkillReviewVerificationError ? cause.message : !loadSkillReview ? t("humanInputForm.thisClientCannotPreviewSkill") : undefined, ), }); }); return () => { current = false; }; }, [loadSkillReview, reviewIdentity, reviewReload, formatError, t]); const visibleReviews = reviews?.loader === loadSkillReview && reviews?.identity === reviewIdentity ? reviews : null; const preview = (question: HumanInputQuestion) => { if (!question.skillReview) return null; const record = visibleReviews?.records[question.id]; return (
{record ? ( <>

{record.title ?? t("humanInputForm.skill")}{" "} {t("humanInputForm.scopeLengthFiles", { scope: record.scope, length: record.files.length, })}

{question.skillReview.removalOperationId ? t("humanInputForm.permanentlyDeletesThisSkillAnd") : t("humanInputForm.savingActivatesTheseExactFiles")}

{record.files.map((file) => (
{file.path}
                  {file.content}
                
))} ) : visibleReviews?.error ? ( <>

{visibleReviews.error}

) : (

{t("humanInputForm.loadingTheExactSkillFiles")}

)}
); }; useEffect(() => { if (collapsed) { setOverflowBelow(false); return; } const node = scrollRef.current; if (!node) return; const sync = () => { const { scrollTop, scrollHeight, clientHeight } = node; setOverflowBelow( scrollHeight > clientHeight + 2 && scrollTop + clientHeight < scrollHeight - 4, ); }; // Layout after paint: first sync can run before the max-height flex // constraint resolves, which falsely reports no overflow. const frame = requestAnimationFrame(sync); node.addEventListener("scroll", sync, { passive: true }); const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(sync) : null; observer?.observe(node); const content = node.firstElementChild; if (content) observer?.observe(content); return () => { cancelAnimationFrame(frame); node.removeEventListener("scroll", sync); observer?.disconnect(); }; }, [request.id, request.questions, collapsed]); const update = ( questionId: string, apply: (draft: HumanInputAnswerDraft) => HumanInputAnswerDraft, ): void => { setDrafts((current) => ({ ...current, [questionId]: apply(current[questionId] ?? emptyDraft()), })); setValidationErrors((current) => { if (!(questionId in current)) return current; const next = { ...current }; delete next[questionId]; return next; }); }; const clearValidationError = (questionId: string): void => setValidationErrors((current) => { if (!(questionId in current)) return current; const next = { ...current }; delete next[questionId]; return next; }); /** Resolves true when the host accepted the response. */ const submitResponse = async (response: SubmitHumanInputResponseRequest): Promise => { if (busy || submissionInFlight.current) return false; const generation = submissionGeneration.current; submissionInFlight.current = true; setSubmissionError(null); setSubmittingInternally(true); try { await onSubmit(response); if (draftScope) { draftClosed.current = true; forgetHumanInputDraft(draftScope); } return true; } catch (cause) { if (generation === submissionGeneration.current) { setSubmissionError(formatError(cause)); } return false; } finally { if (generation === submissionGeneration.current) { submissionInFlight.current = false; setSubmittingInternally(false); } } }; const focusQuestion = (questionId: string): void => { const root = scrollRef.current; if (!root) return; const block = Array.from( root.querySelectorAll("[data-human-input-question]"), ).find((node) => node.getAttribute("data-human-input-question") === questionId); block?.scrollIntoView({ block: "nearest", behavior: "smooth" }); const focusable = block?.querySelector( "input:not([type='hidden']):not([disabled]), textarea:not([disabled])", ); focusable?.focus({ preventScroll: true }); }; const submit = async (event: FormEvent): Promise => { event.preventDefault(); const result = answersFromDrafts(t, request.questions, drafts, messages); const secretAnswers: HumanInputSecretAnswer[] = []; const secretPublicAnswers = new Map(); for (const question of request.questions) { if (question.kind !== "secret") continue; const handle = secretHandles.current.get(question.id); if (!handle) continue; const secret = secretAnswerFromReading(t, question, handle.read(), messages); if (!secret) continue; if ("error" in secret) { result.errors[question.id] = secret.error; continue; } secretAnswers.push(secret.secretAnswer); secretPublicAnswers.set(question.id, secret.answer); } if (Object.keys(result.errors).length > 0) { setValidationErrors(result.errors); const firstInvalid = request.questions.find((question) => question.id in result.errors); if (firstInvalid) { // After paint so aria-invalid / error text exist under the question. requestAnimationFrame(() => focusQuestion(firstInvalid.id)); } return; } const unreviewedSave = request.questions.find( (question) => question.skillReview && !visibleReviews?.records[question.id] && result.answers.some( (answer) => answer.questionId === question.id && answer.values.includes("save"), ), ); if (unreviewedSave) { setValidationErrors({ [unreviewedSave.id]: unreviewedSave.skillReview?.removalOperationId ? t("humanInputForm.loadTheExactSkillProposal") : t("humanInputForm.loadTheExactSkillFiles"), }); return; } // Keep the request's question order across drafted and secret answers. const drafted = new Map(result.answers.map((answer) => [answer.questionId, answer])); const answers = request.questions.flatMap((question) => { const answer = drafted.get(question.id) ?? secretPublicAnswers.get(question.id); return answer ? [answer] : []; }); const accepted = await submitResponse({ outcome: "answered", answers, ...(secretAnswers.length > 0 ? { secretAnswers } : {}), }); if (accepted) for (const handle of secretHandles.current.values()) handle.clear(); }; const metaBits = [ progressLabel, !singleQuestion ? messages.questionCount(request.questions.length) : null, request.expiresAt ? ( <> {messages.deadlineLabel}{" "} ) : null, ].filter(Boolean); if (collapsed) { return (

{resolvedTitle}

{metaBits.length > 0 ? (

{metaBits.join(" · ")}

) : null}
); } return (
void submit(event)} aria-labelledby={titleId} className={cn( // Multi-question: pin height at the cap so the flex body gets a real // box and overflow-y engages. max-height alone + percentage/`h-full` // children often sizes to content and clips with no scroll. "og-root flex min-h-0 w-full flex-col overflow-hidden rounded-og-lg border border-og-status-waiting/35 bg-og-status-waiting/5 shadow-og-sm", singleQuestion ? "max-h-[min(28rem,50dvh)]" : "h-[min(28rem,50dvh)] max-h-[min(28rem,50dvh)]", className, )} >

{linkifyText(resolvedTitle)} {singleQuestion?.required && !request.allowSkip && !decision ? ( * ) : null}

{progressLabel ? ( {progressLabel} ) : null} {!singleQuestion ? ( {messages.questionCount(request.questions.length)} ) : null}
{resolvedDescription ? (
{linkifyText(resolvedDescription)}
) : null} {request.expiresAt ? (

{messages.deadlineLabel}{" "}

) : null} {request.allowSkip && singleQuestion ? (

{t("humanInputForm.orSkipAndLetThe")}

) : null}
{decision ? ( <> {decision.question.helpText || decision.accept.description || decision.decline.description ? (
{decision.question.label && decision.question.helpText ? (

{linkifyText(decision.question.helpText)}

) : null} {[decision.accept, decision.decline].map((option) => option.description ? (

{option.label} {" · "} {linkifyText(option.description)}

) : null, )}
) : null} {(error ?? submissionError) ? (

{error ?? submissionError}

) : null}
{request.allowSkip ? ( ) : null} {[decision.decline, decision.accept].map((option) => { const primary = option === decision.accept; return ( ); })}
) : ( <>
{request.questions.map((question, index) => { if (singleQuestion) { // Title already carries the question; only render the control + help extras. return (
{preview(question)} clearValidationError(question.id)} onUpdate={(apply) => update(question.id, apply)} />
); } return (
{preview(question)} clearValidationError(question.id)} onUpdate={(apply) => update(question.id, apply)} />
); })}
{overflowBelow ? ( {(error ?? submissionError) ? (

{error ?? submissionError}

) : null}
{request.allowSkip ? ( ) : null}
)} ); } // Unambiguous approve/decline verbs only: "Continue with A" vs "Keep B" is a // real choice between two options, not a yes/no decision. const ACCEPT_WORDS = new Set(["approve", "approved", "yes", "confirm", "allow", "accept"]); const DECLINE_WORDS = new Set(["cancel", "decline", "reject", "deny", "no", "don't", "dont"]); function firstWord(label: string): string { return ( label .trim() .toLowerCase() .replace(/[’]/g, "'") .split(/[\s,.:;!?()]+/)[0] ?? "" ); } /** * A yes/no decision (an approval asked as a question): one required * single-choice question with exactly an accept and a decline option. It is * answered with two buttons; an "Other" free-text answer has no meaning for it, * and the composer still accepts a different instruction. */ export function binaryDecision(questions: readonly HumanInputQuestion[]): { question: HumanInputQuestion; accept: HumanInputQuestion["options"][number]; decline: HumanInputQuestion["options"][number]; } | null { if (questions.length !== 1) return null; const question = questions[0]!; if ( question.kind !== "single_select" || question.skillReview || // The runtime marks every choice question `allowOther`, so it is not a // signal here; a different answer goes through the composer instead. !question.required || question.options.length !== 2 ) return null; const [first, second] = question.options as [ HumanInputQuestion["options"][number], HumanInputQuestion["options"][number], ]; const kind = (option: HumanInputQuestion["options"][number]) => { const word = firstWord(option.label); return ACCEPT_WORDS.has(word) ? "accept" : DECLINE_WORDS.has(word) ? "decline" : null; }; const a = kind(first); const b = kind(second); if (a === "accept" && b === "decline") return { question, accept: first, decline: second }; if (a === "decline" && b === "accept") return { question, accept: second, decline: first }; return null; } function QuestionControls({ question, questionNumber, labelledBy, draft, fieldId, error, messages, autoFocus, firstOption, showPromptChrome, allowSkip, busy, allowedSaveTo, registerSecret, onSecretEdited, onUpdate, }: { question: HumanInputQuestion; questionNumber: number | null; labelledBy: string | undefined; draft: HumanInputAnswerDraft; fieldId: string; error: string | undefined; messages: HumanInputFormMessages; autoFocus: boolean; firstOption: boolean; showPromptChrome: boolean; allowSkip: boolean; busy: boolean; allowedSaveTo: readonly HumanInputSecretSaveTo[] | undefined; registerSecret: (handle: SecretFieldHandle | null) => void; onSecretEdited: () => void; onUpdate: (apply: (draft: HumanInputAnswerDraft) => HumanInputAnswerDraft) => void; }) { const t = useOpenGeniText(); const errorId = `${fieldId}-error`; const helpId = `${fieldId}-help`; const labelId = `${fieldId}-label`; const promptId = `${fieldId}-prompt`; const otherChoiceId = `${fieldId}-other-choice`; const otherLabelId = `${fieldId}-other-label`; const otherTextId = `${fieldId}-other-text`; const visibleLabel = question.label ?? question.prompt; const controlLabelId = showPromptChrome ? labelId : labelledBy; const hint = question.kind === "multi_select" ? messages.selectionHint( question.validation?.minSelections, question.validation?.maxSelections, ) : null; const describedBy = [ question.label && showPromptChrome ? promptId : null, question.helpText && showPromptChrome ? helpId : null, hint ? `${fieldId}-hint` : null, error ? errorId : null, ] .filter(Boolean) .join(" ") || undefined; const selectOtherDraft = (current: HumanInputAnswerDraft): HumanInputAnswerDraft => ({ ...current, otherSelected: true, ...(question.kind === "single_select" ? { values: [] } : {}), }); return ( <> {showPromptChrome ? ( <>
{question.label ? (

{linkifyText(question.prompt)}

) : null} {question.helpText ? (

{linkifyText(question.helpText)}

) : null} {hint ? (

{hint}

) : null} ) : ( <> {!question.required && allowSkip === false ? (

{messages.optional}

) : null} {question.helpText && question.label ? (

{linkifyText(question.helpText)}

) : null} {hint ? (

{hint}

) : null} )} {question.kind === "secret" ? ( question.secret ? ( ) : (

{t("humanInputForm.thisQuestionCanTBe")}

) ) : question.kind === "text" ? (