Task
What the agent did or is doing: status per row, animated on change, with file chips that flow and wrap. Collapsed by default on a phone — status stays glanceable on the trigger row — the one divergence from the web original, which opens by default.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/task.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/task.jsonDependencies
react-native-reanimated
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 |
https://reactnativereusables.com/r/uniwind/badge.json |
https://reactnativereusables.com/r/uniwind/collapsible.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/native-only-animated-view.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 { 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 — 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
| Name | Type | Default | Description |
|---|---|---|---|
| defaultOpen | boolean | undefined | false | Collapsed by default on a phone — see the divergence note above. |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| children | React.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>
)