Confirmation
Human-in-the-loop approve/deny sized for a thumb. Renders NOTHING without an approval or while arguments are still streaming — the web contract, made total in a Vitest-owned phase machine. Once answered the pair stays: the chosen side resolves with a check, the other disables, and the outcome persists in the transcript (question.tsx's persistence, replacing the web's swap-to-Accepted/Rejected rows).
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/confirmation.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/confirmation.jsonDependencies
No required npm packages — this item installs through the registry dependencies below.
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/card.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/status.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/card.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/status.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Button } from '@/registry/nativewind/components/ui/button';
import { Card } from '@/registry/nativewind/components/ui/card';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Text } from '@/registry/nativewind/components/ui/text';
import { cn } from '@/registry/nativewind/lib/utils';
import { CheckIcon, CircleCheckIcon, CircleSlashIcon, type LucideIcon } from 'lucide-react-native';
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import {
CONFIRMATION_OUTCOME,
confirmationPhase,
resolveDecision,
type ConfirmationApproval,
type ConfirmationPhase,
type ToolStatus,
} from './confirmation.logic';
/**
* Confirmation — human-in-the-loop approve/deny, sized for a thumb (UC-AGENT-04).
*
* THE WEB ORIGINAL'S RENDER CONTRACT, VERBATIM: it renders NOTHING without an approval
* and nothing while the tool part is still streaming its arguments — the phase machine
* in confirmation.logic.ts (Vitest-owned) is that contract made total. While pending,
* the request is legible (AC-2: the caller renders exactly what is being asked, args
* included, in the request slot) and the pair is live.
*
* THE OUTCOME PERSISTS, question.tsx'S WAY. The web swaps the buttons for
* Accepted/Rejected rows; on a phone the pair STAYS — the chosen side flips to a
* filled, check-marked resolved state and the other side disables (a mistap must not
* re-decide what the agent was told). The answer thus remains visible in the transcript
* afterward (AC-3), and the component never decides by itself: `onRespond` hands the
* boolean to the caller, whose job is feeding it back through `approval` — an
* unanswered optimistic press is held only until props speak.
*
* VARIANT MAPPING, DECLARED: Approve carries RNR's `destructive` variant because
* approval is the one destructive direction in the exchange — it EXECUTES the agent's
* requested action; Deny is the safe escape and takes `outline`. A benign approval can
* re-map through the action's own props.
*
* Touch targets: RNR's h-10 control plus hitSlop reaches the 44pt platform minimum
* (AC-1) without a pixel of extra chrome.
*/
type ConfirmationContextValue = {
phase: ConfirmationPhase;
/** The decision, once made — controlled or optimistic. */
decision: boolean | null;
respond: (approved: boolean) => void;
};
const ConfirmationContext = React.createContext<ConfirmationContextValue | null>(null);
function useConfirmation() {
const ctx = React.useContext(ConfirmationContext);
if (!ctx) throw new Error('Confirmation sub-components must be used within <Confirmation>');
return ctx;
}
type ConfirmationProps = Omit<ViewProps, 'children'> & {
/** The tool part's state, streamed straight in. */
state: ToolStatus;
/** The tool part's approval object. Omit it and the card renders nothing. */
approval?: ConfirmationApproval | null;
/** Fires once per decision. Wiring the outcome back is the caller's job. */
onRespond?: (approved: boolean) => void;
children?: React.ReactNode;
};
function Confirmation({
state,
approval,
onRespond,
className,
children,
...props
}: ConfirmationProps) {
const [local, setLocal] = React.useState<boolean | null>(null);
const phase = confirmationPhase(state, approval, local);
const decision = resolveDecision(approval, local);
const respond = React.useCallback(
(approved: boolean) => {
if (confirmationPhase(state, approval, local) !== 'pending') return;
setLocal(approved);
onRespond?.(approved);
},
[state, approval, local, onRespond],
);
// The web original's contract: no approval (or a still-streaming request) renders
// NOTHING — not an empty card, not a spinner.
if (phase === 'hidden') return null;
return (
<ConfirmationContext.Provider value={{ phase, decision, respond }}>
<Card className={cn('gap-2 p-3', className)} {...props}>
{children}
</Card>
</ConfirmationContext.Provider>
);
}
/** What is being asked. One line, styled by the caller's children. */
function ConfirmationTitle({ className, ...props }: React.ComponentProps<typeof Text>) {
return <Text className={cn('text-sm font-semibold text-foreground', className)} {...props} />;
}
/** The request body — exactly which action, arguments included (AC-2). Pending only. */
function ConfirmationRequest({
className,
children,
...props
}: ViewProps & { children?: React.ReactNode }) {
const { phase } = useConfirmation();
if (phase !== 'pending') return null;
return (
<View className={cn('gap-1', className)} {...props}>
{children}
</View>
);
}
/** iconName (from confirmation.logic.ts) → Lucide component. Static and exhaustive. */
const OUTCOME_ICONS: Record<'circle-check' | 'circle-slash', LucideIcon> = {
'circle-check': CircleCheckIcon,
'circle-slash': CircleSlashIcon,
};
/** One thumb-sized control. Generic on purpose — the pair below is the default layout. */
function ConfirmationAction({
children,
onPress,
disabled,
selected = false,
variant = 'outline',
accessibilityLabel,
className,
}: Omit<React.ComponentProps<typeof Button>, 'children' | 'onPress'> & {
children: string;
onPress: () => void;
/** Marks the resolved answer with the check — question.tsx's chosen-option form. */
selected?: boolean;
accessibilityLabel?: string;
}) {
return (
<Button
variant={variant}
disabled={disabled}
onPress={onPress}
accessibilityLabel={accessibilityLabel ?? children}
accessibilityState={{ selected, disabled: Boolean(disabled) }}
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn('flex-1', className)}
>
{selected ? <Icon as={CheckIcon} size={14} /> : null}
<Text>{children}</Text>
</Button>
);
}
/**
* The approve/deny pair. Renders the whole time it is mounted — pending, both live;
* answered, the chosen side resolved (secondary + check) and the other dead. A pair
* that vanished would un-ask the question the transcript already answered.
*/
function ConfirmationActions({ className }: { className?: string }) {
const { phase, decision, respond } = useConfirmation();
if (phase === 'hidden') return null;
const pending = phase === 'pending';
return (
<View className={cn('flex-row items-center gap-2', className)}>
<ConfirmationAction
variant={decision === true ? 'secondary' : 'destructive'}
disabled={!pending && decision !== true}
selected={decision === true}
onPress={() => respond(true)}
accessibilityLabel="Approve this action"
>
Approve
</ConfirmationAction>
<ConfirmationAction
variant={decision === false ? 'secondary' : 'outline'}
disabled={!pending && decision !== false}
selected={decision === false}
onPress={() => respond(false)}
accessibilityLabel="Deny this action"
>
Deny
</ConfirmationAction>
</View>
);
}
/**
* The persisted outcome, as the outcome map renders it — for callers composing their
* own resolved card (or a summary line under the pair). Icons keep color from being
* the sole channel (WCAG 1.4.1).
*/
function ConfirmationOutcome({ outcome }: { outcome: 'approved' | 'denied' }) {
const meta = CONFIRMATION_OUTCOME[outcome];
return (
<View className="flex-row items-center gap-1.5">
<Icon as={OUTCOME_ICONS[meta.iconName]} size={14} className={cn(meta.className)} />
<Text className={cn('text-xs font-medium', meta.className)}>{meta.label}</Text>
</View>
);
}
export { Confirmation, ConfirmationAction, ConfirmationActions, ConfirmationOutcome, ConfirmationTitle, ConfirmationRequest, useConfirmation };
/**
* Confirmation — pure logic. Zero react-native imports, so the Vitest tier owns the
* approval state machine; confirmation.tsx holds only presentation.
*
* THE AI SDK TOOL-PART STATE UNION IS DECLARED LOCALLY — tool.logic.ts's precedent:
* the registry is stateless about the model and takes no runtime dependency on any SDK.
* The union mirrors the tool part's `state` verbatim; a consumer streams `part.state`
* and `part.approval` straight in, no adapter.
*
* THE STATE MACHINE IS THE WEB ORIGINAL'S RENDER CONTRACT, MADE TOTAL. The web
* Confirmation renders NOTHING without an `approval` and nothing while the tool part is
* still streaming its input ('input-streaming' / 'input-available'); Actions render
* only while approval is requested; a decision persists. Those rules are `phase` here,
* covering every input without an unmapped case.
*/
import { statusColor } from '@/registry/nativewind/lib/status';
/**
* The tool approval object, as the AI SDK tool part carries it: present (possibly
* undecided) once the run has asked for permission, carrying the decision afterwards.
*/
export type ConfirmationApproval = { approved?: boolean };
export type ToolStatus =
| 'input-streaming'
| 'input-available'
| 'output-available'
| 'output-error'
| 'output-denied'
| 'approval-requested'
| 'approval-responded';
/** What the card shows. `hidden` renders nothing — the web original's contract. */
export type ConfirmationPhase = 'hidden' | 'pending' | 'approved' | 'denied';
export const CONFIRMATION_PHASE_KEYS = [
'hidden',
'pending',
'approved',
'denied',
] as const satisfies readonly ConfirmationPhase[];
/**
* The decision the UI should honor: a controlled `approval` that already carries a
* decision always wins (props are the transcript's memory — a restored card must show
* what was decided, never a stale optimistic answer); otherwise the caller-side
* optimistic answer stands.
*/
export function resolveDecision(
approval?: ConfirmationApproval | null,
localAnswer: boolean | null = null,
): boolean | null {
if (approval && typeof approval.approved === 'boolean') return approval.approved;
return localAnswer;
}
/**
* The phase machine. Rules, verbatim from the web original's render contract:
* - No `approval` and no local answer → hidden. Nothing was ever requested.
* - input-streaming / input-available → hidden, EVEN with an approval object — the
* tool is still assembling its arguments; asking for a decision now would decide
* about a moving target.
* - A decision (controlled or optimistic) → approved / denied, and it PERSISTS: the
* transcript keeps the outcome visible afterward (UC-AGENT-04 AC-3), whatever the
* tool part's later state.
* - Still undecided on an approval state → pending.
* - Undecided and the part moved on (output-error, output-denied with no recorded
* decision) → hidden. There is no request left to answer.
*/
export function confirmationPhase(
state: ToolStatus,
approval?: ConfirmationApproval | null,
localAnswer: boolean | null = null,
): ConfirmationPhase {
if (state === 'input-streaming' || state === 'input-available') return 'hidden';
const decision = resolveDecision(approval, localAnswer);
if (!approval && decision === null) return 'hidden';
if (decision === true) return 'approved';
if (decision === false) return 'denied';
if (state === 'approval-requested' || state === 'approval-responded') return 'pending';
return 'hidden';
}
/**
* The two terminal outcomes, with the labels a restored transcript reads and the icons
* that keep color from being the sole channel. Tones resolve through lib/status.ts —
* the ONE map. Approve persists as success, deny as denial.
*/
export const CONFIRMATION_OUTCOME: Record<
Exclude<ConfirmationPhase, 'hidden' | 'pending'>,
{ label: string; iconName: 'circle-check' | 'circle-slash'; className: string }
> = {
approved: {
label: 'Approved',
iconName: 'circle-check',
className: statusColor.success,
},
denied: {
label: 'Denied',
iconName: 'circle-slash',
className: statusColor.denied,
},
};
Props
| Name | Type | Default | Description |
|---|---|---|---|
| state | ToolStatus | — | The tool part's state, streamed straight in. |
| approval | ConfirmationApproval | null | undefined | — | The tool part's approval object. Omit it and the card renders nothing. |
| onRespond | ((approved: boolean) => void) | undefined | — | Fires once per decision. Wiring the outcome back is the caller's job. |
| children | React.ReactNode | — | — |
Theming
Themed via text-foreground.
Usage
() => (
<Confirmation state="input-available" approval={{ approved: true }} onRespond={() => {}}>
<ConfirmationRequest>
<ConfirmationTitle>Install 56 items into apps/example?</ConfirmationTitle>
<ConfirmationActions>
<ConfirmationAction action="approve">Install</ConfirmationAction>
<ConfirmationAction action="deny">Skip</ConfirmationAction>
</ConfirmationActions>
</ConfirmationRequest>
</Confirmation>
)