Install

npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/task.json

Dependencies

https://reactnativereusables.com/r/nativewind/badge.json
https://reactnativereusables.com/r/nativewind/collapsible.json
https://reactnativereusables.com/r/nativewind/icon.json
https://reactnativereusables.com/r/nativewind/native-only-animated-view.json
https://reactnativereusables.com/r/nativewind/text.json
https://rnraielements.com/r/nativewind/status.json

AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92

Source

task.tsx
import { Badge } from '@/registry/nativewind/components/ui/badge'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible'; import { Icon } from '@/registry/nativewind/components/ui/icon'; import { NativeOnlyAnimatedView } from '@/registry/nativewind/components/ui/native-only-animated-view'; import { Text } from '@/registry/nativewind/components/ui/text'; import { cn } from '@/registry/nativewind/lib/utils'; import { ChevronDownIcon, CircleCheckIcon, CircleIcon, CircleXIcon, FileIcon, LoaderCircleIcon, type LucideIcon, } from 'lucide-react-native'; import * as React from 'react'; import { Platform, View, type ViewProps } from 'react-native'; import Animated, { FadeIn, ReduceMotion, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { taskStatusMeta, type TaskStatus, type TaskStatusIconName } from './task.logic'; /** * Task — what the agent did or is doing, with a status per row and the files it * touched. The web original is a collapsible checklist driven by a streamed object * (title before items, so the trigger tolerates a missing title with "Loading..."). * * ONE DELIBERATE DIVERGENCE FROM THE WEB: it does NOT defaultOpen. The web's open-by- * default rule loses to the design contract's phone-density rule — a transcript that * auto-unrolls every task list stops being scannable — because status is still * glanceable while collapsed: the trigger row carries a status badge, and each row's * icon carries its own after that. Details are one tap away, which is the disclosure's * whole job. Like the web original, the trigger tolerates a missing title ("Loading…") * because titles arrive before items mid-stream. * * THE STATUS MAP IS PURE (task.logic.ts, Vitest-owned); this file holds only the static * iconName → Lucide table, exhaustive by type. Status swaps animate through * NativeOnlyAnimatedView's FadeIn (200ms — the house layout-transition duration, * ReduceMotion.System chained, bare children on web), so a task completing reads as the * check arriving rather than a flicker. No looping animation here: a spinning loader is * motion that never ends, and per-second ticks chatter to screen readers — the loader * mark is static by design. * * FILE CHIPS. The web flows a chip inline INSIDE the sentence via inline-flex, which * React Native cannot do (no View inside a Text run). The port advice is followed: a * Text run for the sentence plus chips that flow and wrap beside it * (flex-row + flex-wrap), never a nested View-in-Text. */ type TaskContextValue = { open: boolean }; const TaskContext = React.createContext<TaskContextValue | null>(null); function useTask() { const ctx = React.useContext(TaskContext); if (!ctx) throw new Error('Task sub-components must be used within <Task>'); return ctx; } type TaskProps = Omit<ViewProps, 'children'> & { /** Collapsed by default on a phone — see the divergence note above. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; children?: React.ReactNode; }; function Task({ defaultOpen = false, onOpenChange, className, children, ...props }: TaskProps) { const [open, setOpen] = React.useState(defaultOpen); function handleOpenChange(next: boolean) { setOpen(next); onOpenChange?.(next); } return ( <TaskContext.Provider value={{ open }}> <Collapsible open={open} onOpenChange={handleOpenChange} className={cn('rounded-md border border-border bg-muted/50', className)} {...props} > {children} </Collapsible> </TaskContext.Provider> ); } /** iconName (from task.logic.ts) → Lucide component. Static and exhaustive by type. */ const STATUS_ICONS: Record<TaskStatusIconName, LucideIcon> = { circle: CircleIcon, 'loader-circle': LoaderCircleIcon, 'circle-check': CircleCheckIcon, 'circle-x': CircleXIcon, }; /** * The status icon, keyed by status so a change remounts and the entering animation * plays — the "animated completion" beat. Static under reduced motion (FadeIn chains * ReduceMotion.System) and bare on web (NativeOnlyAnimatedView). */ function TaskStatusIcon({ status }: { status: TaskStatus }) { const meta = taskStatusMeta(status); return ( <NativeOnlyAnimatedView key={status} entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)} className="pt-0.5" > <Icon as={STATUS_ICONS[meta.iconName]} size={14} className={cn(meta.className)} /> </NativeOnlyAnimatedView> ); } /** House motion scale for the chevron: 250ms open, 200ms close, system-reduced. */ function Chevron({ open }: { open: boolean }) { const rotation = useSharedValue(open ? 1 : 0); React.useEffect(() => { rotation.value = withTiming(open ? 1 : 0, { duration: open ? 250 : 200, reduceMotion: ReduceMotion.System, }); }, [open, rotation]); const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value * 180}deg` }], })); return ( <Animated.View style={style}> <Icon as={ChevronDownIcon} size={14} className="text-muted-foreground" /> </Animated.View> ); } type TaskTriggerProps = { /** The collapsed summary line. Tolerates a missing title mid-stream. */ title?: string; /** The group's current status, glanceable while collapsed. */ status?: TaskStatus; className?: string; }; function TaskTrigger({ title, status, className }: TaskTriggerProps) { const { open } = useTask(); const meta = status ? taskStatusMeta(status) : null; return ( <CollapsibleTrigger className={cn( 'flex-row items-center gap-2 rounded-md p-3 active:bg-accent', Platform.select({ web: 'transition-colors hover:bg-accent/50' }), className, )} > <Text numberOfLines={1} className="flex-1 text-sm font-medium text-foreground"> {title || 'Loading…'} </Text> {meta ? ( <Badge variant="outline" accessibilityLabel={`Status: ${meta.label}`} className="gap-1"> <Icon as={STATUS_ICONS[meta.iconName]} size={12} className={cn(meta.className)} /> <Text className={cn('text-xs', meta.className)}>{meta.label}</Text> </Badge> ) : null} <Chevron open={open} /> </CollapsibleTrigger> ); } function TaskContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) { return ( <CollapsibleContent className={cn('px-3 pb-3', className)} {...props}> <View className="gap-1.5">{children}</View> </CollapsibleContent> ); } type TaskItemProps = ViewProps & { /** This row's status — icon and color from the pure map, animated on change. */ status?: TaskStatus; children?: React.ReactNode; }; function TaskItem({ status, className, children, ...props }: TaskItemProps) { return ( <View className={cn('flex-row items-start gap-2 py-0.5', className)} {...props}> {status ? <TaskStatusIcon status={status} /> : null} {/* Chips flow and wrap beside the sentence; they never nest inside a Text run. */} <View className="flex-1 flex-row flex-wrap items-center gap-x-1.5 gap-y-1">{children}</View> </View> ); } /** A file the task touched, as a chip. Sits in the item's wrap row, never inside a Text run. */ function TaskItemFile({ children, className }: { children: string; className?: string }) { return ( <View className={cn( 'flex-row items-center gap-1 self-start rounded-sm border border-border bg-background px-1.5 py-0.5', className, )} > <Icon as={FileIcon} size={12} className="text-muted-foreground" /> <Text numberOfLines={1} className="text-xs text-muted-foreground"> {children} </Text> </View> ); } export { Task, TaskContent, TaskItem, TaskItemFile, TaskTrigger, useTask };
task.logic.ts
/** * Task — pure logic. Zero react-native imports, so the Vitest tier owns the * status→label/icon/color map; task.tsx holds only the static iconName → Lucide table, * typed exhaustively against `iconName` (see tool.logic.ts for the full rationale). * * The status union is the web original's gen-ui task schema (`pending` | `in_progress` | * `completed`) widened by this port's two additions: `running` (the PRD's own word for * the in-flight state — UC-AGENT-03: "pending to running to complete") and `rejected` * (an agent plan step the user declined). `in_progress` is kept as an accepted alias so * a consumer feeding the web schema verbatim still typechecks; it renders identically * to `running`. * * Color is never the sole channel: every status carries a distinct icon and a text * label, and the label is what a screen reader announces. */ import { statusColor, type StatusTone } from '@/registry/nativewind/lib/status'; export type TaskStatus = 'pending' | 'in_progress' | 'running' | 'completed' | 'rejected'; export const TASK_STATUS_KEYS = [ 'pending', 'in_progress', 'running', 'completed', 'rejected', ] as const satisfies readonly TaskStatus[]; /** Lucide icon NAME (kebab-case); the component resolves the component. */ export type TaskStatusIconName = 'circle' | 'loader-circle' | 'circle-check' | 'circle-x'; export type TaskStatusMeta = { /** Badge text. `in_progress` borrows the PRD's word, "Running". */ label: string; tone: StatusTone; iconName: TaskStatusIconName; /** Precomposed text class from the shared statusColor map. */ className: string; }; export const TASK_STATUS_META: Record<TaskStatus, TaskStatusMeta> = { pending: { label: 'Pending', tone: 'pending', iconName: 'circle', className: statusColor.pending }, in_progress: { label: 'Running', tone: 'running', iconName: 'loader-circle', className: statusColor.running }, running: { label: 'Running', tone: 'running', iconName: 'loader-circle', className: statusColor.running }, completed: { label: 'Completed', tone: 'success', iconName: 'circle-check', className: statusColor.success }, rejected: { label: 'Rejected', tone: 'denied', iconName: 'circle-x', className: statusColor.denied }, }; export function taskStatusMeta(status: TaskStatus): TaskStatusMeta { return TASK_STATUS_META[status]; }

Props

NameTypeDefaultDescription
defaultOpenboolean | undefinedfalseCollapsed by default on a phone — see the divergence note above.
onOpenChange((open: boolean) => void) | undefined——
childrenReact.ReactNode——

Theming

Themed via border-border, bg-muted, text-muted-foreground, bg-accent, text-foreground, bg-background.

Usage

(o) => ( <TaskList> <TaskTrigger /> <TaskContent> <TaskItem status={(o.status as 'completed') ?? 'completed'}> <Text className="text-sm text-foreground">Install 56 items via the RNR CLI</Text> </TaskItem> <TaskItem status="running"> <Text className="text-sm text-foreground">Verify the Expo Go dependency graph</Text> </TaskItem> <TaskItem status="pending"> <Text className="text-sm text-foreground">Cut the v0.2.0 release tag</Text> </TaskItem> <TaskItem status="failed"> <Text className="text-sm text-foreground">Boot the gallery on a physical device</Text> </TaskItem> </TaskContent> </TaskList> )