/** * Task — one step an agent took, and what it did while it was there. * * The header is the step; the body is the detail nobody reads unless something * went wrong. Open by default, because a task that is running is the thing the * reader is watching — and it stays open afterwards rather than folding itself * away, because unlike a reasoning trace the steps are the record of what * happened and are worth scrolling back through. * * ```tsx * * * * * Read calendar/index.tsx * * * * ``` * * ## Where the props come from * * With the AI SDK a task is a tool-call part: `part.type` is `tool-` and * `part.state` runs `input-streaming` → `input-available` → `output-available` * or `output-error`. Those map onto `status` as pending, running, complete and * error; `part.input` is usually what the body should say. */ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from 'react'; import { Pressable, View, type PressableProps, type ViewProps } from 'react-native'; import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { AlertTriangleIcon, CheckCircleIcon, ChevronDownIcon, CircleIcon, SearchIcon, } from '../../icons'; import { Collapse } from '../../primitives/collapse'; import { Text, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { Shimmer } from '../shimmer'; export type TaskStatus = 'pending' | 'running' | 'complete' | 'error'; const taskVariants = tv({ slots: { root: 'w-full gap-2', trigger: 'flex-row items-center gap-2 py-0.5', title: 'flex-1 text-sm text-muted-foreground', /* * Indented behind a rule rather than merely padded. The rule is what says * these lines belong to the step above them instead of being the next few * steps, which matters as soon as there is more than one task in a row. */ content: 'ms-2 gap-1.5 border-s border-border ps-4', item: 'text-sm leading-relaxed text-muted-foreground', file: 'flex-row items-center gap-1 self-start rounded-md border border-border bg-muted px-1.5 py-0.5', fileLabel: 'font-mono text-xs text-foreground', }, variants: { status: { pending: { title: 'text-muted-foreground/60' }, running: {}, complete: {}, error: { title: 'text-destructive' }, }, }, defaultVariants: { status: 'complete', }, }); interface TaskContextValue { status: TaskStatus; open: boolean; setOpen: (open: boolean) => void; } const TaskContext = createContext(null); function useTask(component: string): TaskContextValue { const context = useContext(TaskContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface TaskProps extends Omit { className?: string; /** * Where the step has got to. Drives the leading glyph, and puts a shimmer on * the title while it is `running`. */ status?: TaskStatus; /** Controlled open state. */ open?: boolean; /** Initial state when uncontrolled. Open — the steps are the record. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode; } function TaskRoot({ className, status = 'complete', open: openProp, defaultOpen = true, onOpenChange, children, ...props }: TaskProps) { const { root } = taskVariants({ status }); const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = openProp !== undefined; const open = isControlled ? openProp : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange] ); const context = useMemo(() => ({ status, open, setOpen }), [status, open, setOpen]); return ( {children} ); } TaskRoot.displayName = 'Task'; export interface TaskTriggerProps extends Omit { className?: string; /** What the step is. Shimmers while the status is `running`. */ title?: string; /** Leading glyph. Derived from `status` when not given. */ icon?: ReactNode; /** Replaces the whole row. */ children?: ReactNode; } function TaskTrigger({ className, title, icon, children, onPress, ...props }: TaskTriggerProps) { const { status, open, setOpen } = useTask('Task.Trigger'); const slots = taskVariants({ status }); const reducedMotion = useReducedMotion(); const progress = useSharedValue(open ? 1 : 0); useEffect(() => { progress.value = reducedMotion ? open ? 1 : 0 : withTiming(open ? 1 : 0, { duration: 180 }); }, [open, reducedMotion, progress]); const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${progress.value * 180}deg` }], })); return ( { onPress?.(event); setOpen(!open); }} className={cn(slots.trigger(), className)} {...props} > {children ?? ( <> {icon ?? } {status === 'running' && title ? ( {title} ) : ( {title} )} )} ); } /** * The glyph for each status. A magnifier while running: a step is a search. * * Every one is given an explicit colour. The filled status icons fall back to * `currentColor`, which React Native's SVG does not resolve — it paints black, * which on a dark theme is a black disc floating in the row. They are built to * take their colour from an enclosing `IconColorProvider`, and a task row is * not one, so the tint is resolved here. */ function TaskStatusIcon({ status }: { status: TaskStatus }) { const muted = useTint('--color-muted-foreground'); const success = useTint('--color-success'); const destructive = useTint('--color-destructive'); switch (status) { case 'pending': return ; case 'error': return ; case 'complete': return ; default: return ; } } /** * A theme token as a colour an icon will accept. * * `useCSSVariable` answers with whatever the token holds, which for a length or * a number is not a colour at all — so anything that is not a string is dropped * and the icon falls back to what it would have used anyway. */ function useTint(variable: string): string | undefined { const raw = useCSSVariable(variable); return typeof raw === 'string' ? raw : undefined; } export interface TaskContentProps extends Omit { className?: string; children?: ReactNode; } function TaskContent({ className, children, ...props }: TaskContentProps) { const { open } = useTask('Task.Content'); const { content } = taskVariants(); return ( {children} ); } export interface TaskItemProps extends Omit { className?: string; children?: ReactNode; } /** One line of what the step did. */ function TaskItem({ className, children, ...props }: TaskItemProps) { const { item } = taskVariants(); return ( {textChildren(children, (text) => ( {text} ))} ); } export interface TaskFileProps extends Omit { className?: string; /** A glyph for the file's kind, drawn before the name. */ icon?: ReactNode; children?: ReactNode; } /** * A filename inside a line, drawn as a chip. * * Bordered rather than merely monospaced, because a path in the middle of a * sentence is otherwise indistinguishable from the sentence — and the paths * are the part of a task line anyone actually scans for. */ function TaskFile({ className, icon, children, ...props }: TaskFileProps) { const { file, fileLabel } = taskVariants(); return ( {icon} {textChildren(children, (text) => ( {text} ))} ); } TaskTrigger.displayName = 'Task.Trigger'; TaskContent.displayName = 'Task.Content'; TaskItem.displayName = 'Task.Item'; TaskFile.displayName = 'Task.File'; export const Task = Object.assign(TaskRoot, { Trigger: TaskTrigger, Content: TaskContent, Item: TaskItem, File: TaskFile, });