diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx deleted file mode 100644 index 8876c15a..00000000 --- a/src/components/TodoList.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react'; -import { Badge } from './ui/badge'; -import { CheckCircle2, Clock, Circle } from 'lucide-react'; - -const TodoList = ({ todos, isResult = false }) => { - if (!todos || !Array.isArray(todos)) { - return null; - } - - const getStatusIcon = (status) => { - switch (status) { - case 'completed': - return ; - case 'in_progress': - return ; - case 'pending': - default: - return ; - } - }; - - const getStatusColor = (status) => { - switch (status) { - case 'completed': - return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800'; - case 'in_progress': - return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800'; - case 'pending': - default: - return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700'; - } - }; - - const getPriorityColor = (priority) => { - switch (priority) { - case 'high': - return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800'; - case 'medium': - return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800'; - case 'low': - default: - return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700'; - } - }; - - return ( -
- {isResult && ( -
- Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'}) -
- )} - - {todos.map((todo, index) => ( -
-
- {getStatusIcon(todo.status)} -
- -
-
-

- {todo.content} -

- -
- - {todo.priority} - - - {todo.status.replace('_', ' ')} - -
-
-
-
- ))} -
- ); -}; - -export default TodoList; diff --git a/src/components/chat/tools/components/ContentRenderers/TodoList.tsx b/src/components/chat/tools/components/ContentRenderers/TodoList.tsx new file mode 100644 index 00000000..1df49564 --- /dev/null +++ b/src/components/chat/tools/components/ContentRenderers/TodoList.tsx @@ -0,0 +1,152 @@ +import { memo, useMemo } from 'react'; +import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react'; +import { Badge } from '../../../../ui/badge'; + +type TodoStatus = 'completed' | 'in_progress' | 'pending'; +type TodoPriority = 'high' | 'medium' | 'low'; + +export type TodoItem = { + id?: string; + content: string; + status: string; + priority?: string; +}; + +type NormalizedTodoItem = { + id?: string; + content: string; + status: TodoStatus; + priority: TodoPriority; +}; + +type StatusConfig = { + icon: LucideIcon; + iconClassName: string; + badgeClassName: string; + textClassName: string; +}; + +// Centralized visual config keeps rendering logic compact and easier to scan. +const STATUS_CONFIG: Record = { + completed: { + icon: CheckCircle2, + iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400', + badgeClassName: + 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800', + textClassName: 'line-through text-gray-500 dark:text-gray-400', + }, + in_progress: { + icon: Clock, + iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400', + badgeClassName: + 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800', + textClassName: 'text-gray-900 dark:text-gray-100', + }, + pending: { + icon: Circle, + iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500', + badgeClassName: + 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700', + textClassName: 'text-gray-900 dark:text-gray-100', + }, +}; + +const PRIORITY_BADGE_CLASS: Record = { + high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800', + medium: + 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800', + low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700', +}; + +// Incoming tool payloads can vary; normalize to supported UI states. +const normalizeStatus = (status: string): TodoStatus => { + if (status === 'completed' || status === 'in_progress') { + return status; + } + return 'pending'; +}; + +const normalizePriority = (priority?: string): TodoPriority => { + if (priority === 'high' || priority === 'medium') { + return priority; + } + return 'low'; +}; + +const TodoRow = memo( + ({ todo }: { todo: NormalizedTodoItem }) => { + const statusConfig = STATUS_CONFIG[todo.status]; + const StatusIcon = statusConfig.icon; + + return ( +
+
+ +
+
+
+

+ {todo.content} +

+
+ + {todo.priority} + + + {todo.status.replace('_', ' ')} + +
+
+
+
+ ); + } +); + +const TodoList = memo( + ({ + todos, + isResult = false, + }: { + todos: TodoItem[]; + isResult?: boolean; + }) => { + // Memoize normalization to avoid recomputing list metadata on every render. + const normalizedTodos = useMemo( + () => + todos.map((todo) => ({ + id: todo.id, + content: todo.content, + status: normalizeStatus(todo.status), + priority: normalizePriority(todo.priority), + })), + [todos] + ); + + if (normalizedTodos.length === 0) { + return null; + } + + return ( +
+ {isResult && ( +
+ Todo List ({normalizedTodos.length}{' '} + {normalizedTodos.length === 1 ? 'item' : 'items'}) +
+ )} + {normalizedTodos.map((todo, index) => ( + + ))} +
+ ); + } +); + +export default TodoList; diff --git a/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx b/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx index 5d318ba1..decbc9c3 100644 --- a/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx +++ b/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx @@ -1,23 +1,40 @@ -import React from 'react'; -import TodoList from '../../../../TodoList'; +import { memo, useMemo } from 'react'; +import TodoList, { type TodoItem } from './TodoList'; -interface TodoListContentProps { - todos: Array<{ - id?: string; - content: string; - status: string; - priority?: string; - }>; - isResult?: boolean; -} +const isTodoItem = (value: unknown): value is TodoItem => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const todo = value as Record; + return typeof todo.content === 'string' && typeof todo.status === 'string'; +}; /** * Renders a todo list * Used by: TodoWrite, TodoRead */ -export const TodoListContent: React.FC = ({ - todos, - isResult = false -}) => { - return ; -}; +export const TodoListContent = memo( + ({ + todos, + isResult = false, + }: { + todos: unknown; + isResult?: boolean; + }) => { + const safeTodos = useMemo(() => { + if (!Array.isArray(todos)) { + return []; + } + + // Tool payloads are runtime data; render only validated todo objects. + return todos.filter(isTodoItem); + }, [todos]); + + if (safeTodos.length === 0) { + return null; + } + + return ; + } +);