TodoTool
Render task list changes from input.todos, optionally diffed against output.oldTodos.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
New list
Single update
Multiple updates
Pending update
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
tools/TodoTool.tsx
import React, { memo, useMemo, useState } from 'react';
import { Badge, Box, Button, Group, Text, type MantineColor } from '@mantine/core';
import { CheckIcon, IconArrowRight } from '../icons';
import { TextShimmer } from '../TextShimmer/TextShimmer';
import type { ToolPart } from '../types';
import { cx } from '../utils/cx';
import {
areToolPropsEqual,
getPartInput,
getPartOutput,
getToolStatus,
} from '../utils/format-tool';
import {
DEFAULT_TODO_TOOL_LABELS,
describeHiddenTodos,
getTodoBlockers,
limitTodos,
type TodoToolLabels,
} from './todo-utils';
import classes from './TodoTool.module.css';
export type TodoItem = {
/** Task text */
content: string;
status: 'pending' | 'in_progress' | 'completed';
/** Present-tense form shown while the task is in progress */
activeForm?: string;
/** Stable id other todos refer to in `blockedBy` */
id?: string;
/** Ids of todos that must be completed first */
blockedBy?: string[];
/** Agent or teammate working on the todo */
owner?: { name: string; color?: MantineColor };
};
export interface TodoToolProps {
/** Tool part in AI SDK v5 shape: `{ type, toolCallId, state, input, output }` */
part: ToolPart;
/** Chat status from `useChat()`, used to tell a pending tool from an interrupted one */
chatStatus?: string;
/** Shows at most this many todos, in-progress first, with a summary of the rest */
maxVisible?: number;
/** Overrides of the default English labels */
labels?: Partial<TodoToolLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
export type TodoChange = {
todo: TodoItem;
oldStatus?: TodoItem['status'];
newStatus: TodoItem['status'];
index: number;
};
type ChangeType = 'creation' | 'single' | 'multiple';
export type DetectedChanges = {
type: ChangeType;
items: TodoChange[];
};
function detectChanges(oldTodos: TodoItem[], newTodos: TodoItem[]): DetectedChanges {
if (!oldTodos || oldTodos.length === 0) {
return {
type: 'creation',
items: newTodos.map((todo, index) => ({ todo, newStatus: todo.status, index })),
};
}
const changes: TodoChange[] = [];
newTodos.forEach((newTodo, index) => {
const oldTodo = oldTodos[index];
if (!oldTodo || oldTodo.status !== newTodo.status) {
changes.push({
todo: newTodo,
oldStatus: oldTodo?.status,
newStatus: newTodo.status,
index,
});
}
});
if (changes.length === 1) {
return { type: 'single', items: changes };
}
return { type: 'multiple', items: changes };
}
function TodoStatusIcon({ status }: { status: TodoItem['status'] }) {
switch (status) {
case 'completed':
return (
<span className={classes.statusIcon} data-status="completed">
<CheckIcon size={8} className={classes.statusGlyph} />
</span>
);
case 'in_progress':
return (
<span className={classes.statusIcon}>
<IconArrowRight size={8} className={classes.statusGlyph} />
</span>
);
default:
return <span className={classes.statusIcon} />;
}
}
const TodoListItem = memo(function TodoListItem({
todo,
isPending,
blockers,
blockedByLabel,
}: {
todo: TodoItem;
isPending: boolean;
blockers: string[];
blockedByLabel: string;
}) {
const dimmed = isPending || todo.status === 'completed' || todo.status === 'pending';
return (
<div className={classes.item}>
<span className={classes.iconWrap}>
<TodoStatusIcon status={todo.status} />
</span>
<span className={classes.body}>
<span
className={classes.text}
data-completed={todo.status === 'completed' || undefined}
data-dimmed={dimmed || undefined}
>
{todo.content}
</span>
{todo.owner && (
<Badge
size="xs"
variant="dot"
color={todo.owner.color ?? 'gray'}
tt="none"
className={classes.owner}
>
{todo.owner.name}
</Badge>
)}
{blockers.length > 0 && (
<Text span size="xs" c="dimmed" className={classes.blocked}>
{blockedByLabel} {blockers.join(', ')}
</Text>
)}
</span>
</div>
);
});
function firstNonEmptyList(...candidates: unknown[]): TodoItem[] {
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.length > 0) {
return candidate as TodoItem[];
}
}
return [];
}
/** Renders a `tool-TodoWrite` part as a checklist */
export const TodoTool = memo(function TodoTool({
part,
chatStatus,
maxVisible = 0,
labels: labelsProp,
className,
style,
}: TodoToolProps) {
const labels = { ...DEFAULT_TODO_TOOL_LABELS, ...labelsProp };
const [showAll, setShowAll] = useState(false);
const { isPending } = getToolStatus(part, chatStatus);
const input = getPartInput(part);
const output = getPartOutput(part);
const isStreaming = part.state === 'input-streaming';
const oldTodos: TodoItem[] = output?.oldTodos || [];
const newTodos: TodoItem[] = firstNonEmptyList(input.todos, output?.newTodos);
const isCreation = oldTodos.length === 0;
const changes = useMemo(() => detectChanges(oldTodos, newTodos), [oldTodos, newTodos]);
const limited = useMemo(
() => limitTodos(newTodos, showAll ? 0 : maxVisible),
[newTodos, maxVisible, showAll]
);
const blockers = useMemo(() => getTodoBlockers(newTodos), [newTodos]);
const canToggle = maxVisible > 0 && newTodos.length > maxVisible;
const hiddenText = describeHiddenTodos(limited.hidden, labels);
if (!isStreaming && part.state === 'output-available' && newTodos.length === 0) {
return null;
}
if (isStreaming || newTodos.length === 0) {
return (
<Box className={cx(classes.root, className)} style={style}>
<div className={classes.placeholder}>
<TextShimmer as="span" duration={1.2} className={classes.shimmer}>
{isCreation ? 'Creating to-do list...' : 'Updating to-dos...'}
</TextShimmer>
</div>
</Box>
);
}
return (
<Box className={cx(classes.root, className)} style={style} data-change-type={changes.type}>
{limited.visible.map(({ todo, index }) => (
<TodoListItem
key={todo.id ?? `idx-${index}`}
todo={todo}
isPending={isPending}
blockers={blockers.get(todo) ?? []}
blockedByLabel={labels.blockedBy}
/>
))}
{canToggle && (
<Group gap={10} wrap="nowrap" className={classes.hidden}>
{hiddenText && (
<Text size="xs" c="dimmed">
{hiddenText}
</Text>
)}
<Button
variant="subtle"
color="gray"
size="compact-xs"
onClick={() => setShowAll((value) => !value)}
>
{showAll ? labels.showLess : labels.showAll}
</Button>
</Group>
)}
</Box>
);
}, areToolPropsEqual);
TodoTool.displayName = 'TodoTool';
Depends on:
API reference
Prop
Type
Required
part
ToolPart
Yes
Tool part in AI SDK v5 shape: `{ type, toolCallId, state, input, output }`
chatStatus
string
No
Chat status from `useChat()`, used to tell a pending tool from an interrupted one
maxVisible
number
No
Shows at most this many todos, in-progress first, with a summary of the rest
labels
Partial<TodoToolLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element