Agent
The identity/run surface: the persona molecule in the header plus a run-state badge and the thumb-reachable Start/Pause/Stop control bar the web config card never had. The run is the caller's — controlled status, reported verbs, dead bar when no handler is wired. The web card's instructions/tools/output body is deferred to its own surfaces.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/agent.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/agent.jsonDependencies
react-native-reanimated
https://reactnativereusables.com/r/nativewind/badge.json |
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/persona.json |
https://rnraielements.com/r/nativewind/status.json |
https://reactnativereusables.com/r/uniwind/badge.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/persona.json |
https://rnraielements.com/r/uniwind/status.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Badge } from '@/registry/nativewind/components/ui/badge';
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 {
CircleCheckIcon,
CircleIcon,
CirclePauseIcon,
CircleXIcon,
LoaderCircleIcon,
PauseIcon,
PlayIcon,
SquareIcon,
type LucideIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import Animated, {
ReduceMotion,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { Persona } from './persona';
import {
agentRunMeta,
enabledRunActions,
AGENT_RUN_ACTIONS,
type AgentRunAction,
type AgentRunIconName,
type AgentRunStatus,
} from './agent.logic';
/**
* Agent — the identity/run surface.
*
* COMPOSITION IS THE POINT: the header is the existing `persona` molecule — avatar,
* name, description — never re-implemented, plus a run-state badge whose tones come from
* lib/status.ts, the ONE status map. The web original's Agent is a configuration card
* (instructions, tools, output schema); the PRD's porting verdict narrows `agent` to an
* identity card, and UC-AGENT-05 AC-4 adds what the web card never had: start / pause /
* stop controls in thumb reach. The config body (instructions markdown, the tools
* accordion, the output schema) is deferred, not dropped — instructions is a markdown
* surface (message.tsx's injected-renderer seam), and the schema belongs to
* schema-display's own wave; this organism composes the identity and the run.
*
* THE RUN IS THE CALLER'S. Like every queue and answer in this registry (speech-input
* precedent), the component holds no run state: `runStatus` is controlled,
* `onRunAction` reports the verb, and the component never advances a run by itself.
* With no `onRunAction` wired the control bar does not render at all — a dead row of
* buttons teaches nothing.
*
* The Running badge's loader pulses on RNR Skeleton's own 1000ms signature and is gated
* on useReducedMotion, exactly as tool.tsx's clock — the one looping animation this
* surface carries. Impossible controls DISABLE rather than vanish (see
* enabledRunActions in agent.logic.ts).
*/
type AgentContextValue = {
runStatus: AgentRunStatus;
/** Present only when the consumer wired onRunAction. */
fire?: (action: AgentRunAction) => void;
};
const AgentContext = React.createContext<AgentContextValue | null>(null);
function useAgent() {
const ctx = React.useContext(AgentContext);
if (!ctx) throw new Error('Agent sub-components must be used within <Agent>');
return ctx;
}
type AgentProps = Omit<ViewProps, 'children'> & {
/** Controlled run state. The caller owns the run — this only mirrors it. */
runStatus?: AgentRunStatus;
/** Fires on Start / Pause / Stop. Omit it and the control bar never renders. */
onRunAction?: (action: AgentRunAction) => void;
children?: React.ReactNode;
};
function Agent({ runStatus = 'idle', onRunAction, className, children, ...props }: AgentProps) {
const fire = React.useCallback(
(action: AgentRunAction) => {
onRunAction?.(action);
},
[onRunAction],
);
return (
<AgentContext.Provider value={{ runStatus, fire: onRunAction ? fire : undefined }}>
<Card className={cn('gap-3 p-3', className)} {...props}>
{children}
</Card>
</AgentContext.Provider>
);
}
/** iconName (from agent.logic.ts) → Lucide component. Static and exhaustive by type. */
const RUN_ICONS: Record<AgentRunIconName, LucideIcon> = {
circle: CircleIcon,
'loader-circle': LoaderCircleIcon,
'circle-pause': CirclePauseIcon,
'circle-x': CircleXIcon,
'circle-check': CircleCheckIcon,
};
/**
* The run badge. For the running state the loader pulses on RNR Skeleton's signature
* (1→0.5, 1000ms, reversed repeat) and stands perfectly still under reduced motion —
* tool.tsx's clock, reused as a pattern, not forked into a second tempo.
*/
function RunBadge({ runStatus }: { runStatus: AgentRunStatus }) {
const meta = agentRunMeta(runStatus);
const reduced = useReducedMotion();
const looping = runStatus === 'running';
const opacity = useSharedValue(1);
React.useEffect(() => {
if (!looping || reduced) {
opacity.value = 1;
return;
}
opacity.value = withRepeat(
withTiming(0.5, { duration: 1000, reduceMotion: ReduceMotion.System }),
-1,
true,
);
}, [looping, reduced, opacity]);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
return (
<Badge
variant="outline"
accessibilityLabel={`Run status: ${meta.label}`}
accessibilityLiveRegion="polite"
className="shrink-0 gap-1"
>
<Animated.View style={style}>
<Icon as={RUN_ICONS[meta.iconName]} size={12} className={cn(meta.className)} />
</Animated.View>
<Text className={cn('text-xs', meta.className)}>{meta.label}</Text>
</Badge>
);
}
type AgentHeaderProps = {
/** Passed straight to the Persona molecule — never re-implemented here. */
name: string;
description?: string;
avatarUri?: string;
className?: string;
};
function AgentHeader({ name, description, avatarUri, className }: AgentHeaderProps) {
const { runStatus } = useAgent();
return (
<View className={cn('flex-row items-center gap-2', className)}>
{/* flex-1 + min-w-0 let the name truncate while the badge keeps its shape. */}
<Persona name={name} description={description} avatarUri={avatarUri} className="min-w-0 flex-1" />
<RunBadge runStatus={runStatus} />
</View>
);
}
/** The identity card's body slot — reasoning, tools, artifacts, whatever the run shows. */
function AgentContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<View className={cn('gap-2', className)} {...props}>
{children}
</View>
);
}
/** The control bar's verb → row entry. Static table; order is the thumb's reading order. */
const ACTION_BUTTONS: Record<
AgentRunAction,
{ label: string; icon: LucideIcon; variant: 'default' | 'outline' | 'ghost' }
> = {
start: { label: 'Start', icon: PlayIcon, variant: 'default' },
pause: { label: 'Pause', icon: PauseIcon, variant: 'outline' },
stop: { label: 'Stop', icon: SquareIcon, variant: 'ghost' },
};
/**
* The thumb-reachable control bar (UC-AGENT-05 AC-4). Renders only when a handler is
* wired; impossible verbs are disabled in place — a control that disappears
* mid-press is worse than one that refuses the press.
*/
function AgentActions({ className }: { className?: string }) {
const { runStatus, fire } = useAgent();
if (!fire) return null;
const enabled = enabledRunActions(runStatus);
return (
<View className={cn('flex-row items-center gap-2', className)}>
{AGENT_RUN_ACTIONS.map((action) => {
const button = ACTION_BUTTONS[action];
// From paused, the verb semantically resumes — same 'start' action the run
// machine speaks, the label the user hears (M8).
const label =
action === 'start' && runStatus === 'paused' ? 'Resume' : button.label;
const isEnabled = enabled.includes(action);
return (
<Button
key={action}
variant={button.variant}
disabled={!isEnabled}
onPress={() => fire(action)}
accessibilityLabel={`${label} the run`}
accessibilityState={{ disabled: !isEnabled }}
className="flex-1"
>
<Icon as={button.icon} size={14} />
<Text>{label}</Text>
</Button>
);
})}
</View>
);
}
export { Agent, AgentActions, AgentContent, AgentHeader, useAgent };
/**
* Agent — pure logic. Zero react-native imports, so the Vitest tier owns the run-state
* map and the control-bar state machine; agent.tsx holds only the static iconName →
* Lucide table, typed exhaustively against `iconName` (see tool.logic.ts for the full
* rationale).
*
* THE WEB ORIGINAL IS A CONFIG CARD (instructions, tools, output schema); it has no run
* state at all. The run vocabulary here is this port's addition, drawn from PRD
* UC-AGENT-05 AC-4 — "start, pause, or stop an agent run from a thumb-reachable control
* bar" — plus the two outcomes a run can end in. The PRD's porting verdict for `agent`
* is an identity card; the run state is what makes the identity card an identity/run
* surface rather than a static header.
*
* Tones resolve exclusively through lib/status.ts — the ONE status-tone map. No second
* color vocabulary.
*/
import { statusColor, type StatusTone } from '@/registry/nativewind/lib/status';
/** The run states the control bar can drive and the transcript can show. */
export type AgentRunStatus = 'idle' | 'running' | 'paused' | 'error' | 'done';
export const AGENT_RUN_STATUS_KEYS = [
'idle',
'running',
'paused',
'error',
'done',
] as const satisfies readonly AgentRunStatus[];
/** The three verbs of UC-AGENT-05 AC-4's control bar. */
export type AgentRunAction = 'start' | 'pause' | 'stop';
export const AGENT_RUN_ACTIONS = [
'start',
'pause',
'stop',
] as const satisfies readonly AgentRunAction[];
/** Lucide icon NAME (kebab-case); the component resolves the component. */
export type AgentRunIconName =
| 'circle'
| 'loader-circle'
| 'circle-pause'
| 'circle-x'
| 'circle-check';
export type AgentRunMeta = {
/** Badge text. */
label: string;
tone: StatusTone;
iconName: AgentRunIconName;
/** Precomposed text class from the shared statusColor map. */
className: string;
};
export const AGENT_RUN_STATUS_META: Record<AgentRunStatus, AgentRunMeta> = {
idle: { label: 'Idle', tone: 'pending', iconName: 'circle', className: statusColor.pending },
running: { label: 'Running', tone: 'running', iconName: 'loader-circle', className: statusColor.running },
paused: { label: 'Paused', tone: 'pending', iconName: 'circle-pause', className: statusColor.pending },
error: { label: 'Error', tone: 'error', iconName: 'circle-x', className: statusColor.error },
done: { label: 'Done', tone: 'success', iconName: 'circle-check', className: statusColor.success },
};
export function agentRunMeta(status: AgentRunStatus): AgentRunMeta {
return AGENT_RUN_STATUS_META[status];
}
/**
* Which controls each run state enables — the whole state machine behind the control
* bar, kept pure so the Vitest tier owns it. A run that has already ended (error, done)
* has nothing left to stop, and an idle run has nothing to pause: the impossible
* controls are disabled, never hidden — a control that vanishes under a thumb mid-press
* is the failure this shape prevents.
*/
export function enabledRunActions(status: AgentRunStatus): AgentRunAction[] {
switch (status) {
case 'idle':
return ['start'];
case 'running':
return ['pause', 'stop'];
case 'paused':
return ['start', 'stop'];
case 'error':
case 'done':
return ['start'];
}
}
import { Avatar, AvatarFallback, AvatarImage } from '@/registry/nativewind/components/ui/avatar';
import { Text } from '@/registry/nativewind/components/ui/text';
import { cn } from '@/registry/nativewind/lib/utils';
import { View, type ViewProps } from 'react-native';
/**
* Persona — who is speaking.
*
* DIVERGES FROM THE WEB ORIGINAL, deliberately. The web `persona` is a RIVE animation on a
* WebGL2 canvas — six variants driven by five state-machine inputs — not the avatar card it
* resembles. `rive-react-native` is a first-party runtime, so an animated port is possible,
* but it would make this the only registry item pulling a native animation runtime.
*
* What ships here is the identity surface: avatar, name, description, status. The animated
* avatar is NOT ported. If it is ever wanted it belongs as a separate opt-in item that
* declares rive-react-native, rather than as a dependency every consumer inherits for a
* component most will use as a header.
*
* That is a real gap, and the porting-verdict table says so rather than implying parity.
*/
type PersonaProps = ViewProps & {
name: string;
description?: string;
avatarUri?: string;
/** Shown as a muted trailing label — "thinking", "idle", a model id. */
status?: string;
};
function Persona({ name, description, avatarUri, status, className, ...props }: PersonaProps) {
const initials = name
.split(' ')
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase();
return (
<View className={cn('flex-row items-center gap-3', className)} {...props}>
{/* Explicit RN style sizing, redundant with `size-10` on purpose. The avatar's
percent-sized fallback (`size-full`) resolves against the nearest DEFINITE
ancestor when the root loses its class-driven size — observed on device as a
full-screen `bg-muted` pill when the runtime stylesheet dropped the size rules
(stale uniwind stylesheet state). A numeric style bypasses the stylesheet, so
the fallback can never exceed 40pt in any engine. */}
<Avatar alt={name} className="size-10" style={{ width: 40, height: 40 }}>
{avatarUri ? <AvatarImage source={{ uri: avatarUri }} /> : null}
<AvatarFallback>
<Text className="text-sm font-medium">{initials}</Text>
</AvatarFallback>
</Avatar>
<View className="min-w-0 flex-1 gap-0.5">
<Text numberOfLines={1} className="text-sm font-medium text-foreground">
{name}
</Text>
{description ? (
<Text numberOfLines={2} className="text-xs text-muted-foreground">
{description}
</Text>
) : null}
</View>
{status ? (
<Text numberOfLines={1} className="shrink-0 text-xs text-muted-foreground">
{status}
</Text>
) : null}
</View>
);
}
export { Persona };
Props
| Name | Type | Default | Description |
|---|---|---|---|
| runStatus | AgentRunStatus | undefined | 'idle' | Controlled run state. The caller owns the run — this only mirrors it. |
| onRunAction | ((action: AgentRunAction) => void) | undefined | — | Fires on Start / Pause / Stop. Omit it and the control bar never renders. |
| children | React.ReactNode | — | — |
Theming
Themed via bg-muted, text-foreground, text-muted-foreground.
Usage
(o) => (
<Agent
runStatus={(o.runStatus as 'idle' | 'running' | 'paused' | 'error' | 'done') ?? 'running'}
onRunAction={() => {}}
>
<AgentHeader
name="Registry Porter"
description="Installs the 56-item set into consumer apps"
avatarUri={AVATAR_URI}
/>
<AgentContent>
<Text className="text-sm text-muted-foreground">
Run 12 · installing conversation.json through the RNR CLI
</Text>
</AgentContent>
<AgentActions />
</Agent>
)