"use client";
import { ChevronDown, ListTodo } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionSwapRollText } from "@/components/motion/action-swap-roll";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_SWAP,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TodoItemStatus =
| "pending"
| "in-progress"
| "completed"
| "cancelled";
export interface TodoItem {
id: string;
title: ReactNode;
status?: TodoItemStatus;
progress?: number;
detail?: ReactNode;
}
export interface TodoListProps {
items: TodoItem[];
title?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
className?: string;
}
function statusLabel(status: TodoItemStatus) {
if (status === "in-progress") return "In progress";
if (status === "completed") return "Completed";
if (status === "cancelled") return "Cancelled";
return "Pending";
}
function TodoHeaderIcon({ complete }: { complete: boolean }) {
const reduce = useReducedMotion() ?? false;
return (
{complete ? (
) : (
)}
);
}
function TodoStatusIcon({
status,
progress,
}: {
status: TodoItemStatus;
progress?: number;
}) {
const reduce = useReducedMotion() ?? false;
const normalizedProgress =
progress === undefined ? 0.68 : Math.min(100, Math.max(0, progress)) / 100;
return (
);
}
export function TodoList({
items,
title = "To-dos",
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 248,
className,
}: TodoListProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const viewportRef = useRef(null);
const previousComplete = useRef(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const completed = items.filter((item) => item.status === "completed").length;
const allComplete = items.length > 0 && completed === items.length;
const itemCount = items.length;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousComplete.current && !allComplete) {
setOpen(true);
}
if (!previousComplete.current && allComplete && collapseOnComplete) {
setOpen(false);
}
previousComplete.current = allComplete;
}, [allComplete, collapseOnComplete, setOpen]);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || itemCount === 0) return;
const frame = requestAnimationFrame(() => {
if (viewport.scrollHeight <= viewport.clientHeight) return;
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
}, [itemCount, reduce]);
return (
{items.length ? (
{items.map((item) => {
const status = item.status ?? "pending";
return (
{statusLabel(status)}:
{item.title}
{item.detail ? (
{item.detail}
) : null}
);
})}
) : (
No tasks yet
)}
);
}