From 900a9014dc4437ed2c63230560a03471462a85c7 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 7 Jul 2025 15:09:57 -0400 Subject: [PATCH] Display todos (#219) --- .../app/(authenticated)/usage/Messages.tsx | 15 +- .../components/ui/TodoListDisplay.test.tsx | 97 +++++++++++++ .../web/src/components/ui/TodoListDisplay.tsx | 133 ++++++++++++++++++ apps/web/src/lib/toolUsageParser.ts | 22 +++ 4 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/ui/TodoListDisplay.test.tsx create mode 100644 apps/web/src/components/ui/TodoListDisplay.tsx diff --git a/apps/web/src/app/(authenticated)/usage/Messages.tsx b/apps/web/src/app/(authenticated)/usage/Messages.tsx index 27f93e1cc9..f3bb448fd0 100644 --- a/apps/web/src/app/(authenticated)/usage/Messages.tsx +++ b/apps/web/src/app/(authenticated)/usage/Messages.tsx @@ -11,6 +11,7 @@ import { formatTimestamp } from '@/lib/formatters'; import { useAutoScroll } from '@/hooks/useAutoScroll'; import { CodeBlock } from '@/components/ui/CodeBlock'; import { ToolUsageBadge } from '@/components/ui/ToolUsageBadge'; +import { TodoListDisplay } from '@/components/ui/TodoListDisplay'; import { parseToolUsage } from '@/lib/toolUsageParser'; // Custom component to render links as plain text to avoid broken/nonsensical links @@ -317,12 +318,18 @@ export const Messages = ({ ); } - // For other tool messages, render only the tool usage badge in the space between bubbles + // For tool messages, render the tool usage badge and todo list if present if (isTool) { return ( -
- {message.toolUsage && ( - +
+ {message.toolUsage?.todoData ? ( + + ) : ( + message.toolUsage && ( +
+ +
+ ) )}
); diff --git a/apps/web/src/components/ui/TodoListDisplay.test.tsx b/apps/web/src/components/ui/TodoListDisplay.test.tsx new file mode 100644 index 0000000000..2be38b5922 --- /dev/null +++ b/apps/web/src/components/ui/TodoListDisplay.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { TodoListDisplay, type TodoItem } from './TodoListDisplay'; + +describe('TodoListDisplay', () => { + const mockTodos = [ + { + id: '1', + content: 'Complete the feature', + status: 'completed' as const, + }, + { + id: '2', + content: 'Write tests', + status: 'in_progress' as const, + }, + { + id: '3', + content: 'Update documentation', + status: 'pending' as const, + }, + ]; + + it('renders todo list with header and progress', () => { + render(); + + expect(screen.getByText('Todo List Updated')).toBeInTheDocument(); + expect(screen.getByText('1/3')).toBeInTheDocument(); // completed/total + }); + + it('shows current task in collapsed view by default', () => { + render(); + + // Should show the in-progress task by default + expect(screen.getByText('Write tests')).toBeInTheDocument(); + // Should not show other tasks in collapsed view + expect(screen.queryByText('Complete the feature')).not.toBeInTheDocument(); + expect(screen.queryByText('Update documentation')).not.toBeInTheDocument(); + }); + + it('shows all tasks when expanded', () => { + render(); + + // Click to expand + const expandButton = screen.getByRole('button'); + fireEvent.click(expandButton); + + // Should show all tasks + expect(screen.getByText('Complete the feature')).toBeInTheDocument(); + expect(screen.getByText('Write tests')).toBeInTheDocument(); + expect(screen.getByText('Update documentation')).toBeInTheDocument(); + }); + + it('applies correct styling for completed items', () => { + render(); + + // Expand to see all items + const expandButton = screen.getByRole('button'); + fireEvent.click(expandButton); + + const completedItem = screen.getByText('Complete the feature'); + expect(completedItem).toHaveClass('line-through'); + }); + + it('shows pending task when no in-progress task exists', () => { + const todosWithoutInProgress = [ + { + id: '1', + content: 'Complete the feature', + status: 'completed' as const, + }, + { + id: '3', + content: 'Update documentation', + status: 'pending' as const, + }, + ]; + + render(); + + // Should show the pending task since no in-progress exists + expect(screen.getByText('Update documentation')).toBeInTheDocument(); + }); + + it('renders nothing when todos array is empty', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when todos is undefined', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ui/TodoListDisplay.tsx b/apps/web/src/components/ui/TodoListDisplay.tsx new file mode 100644 index 0000000000..314db1cbb2 --- /dev/null +++ b/apps/web/src/components/ui/TodoListDisplay.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type TodoItem = { + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; +}; + +type TodoListDisplayProps = { + todos: TodoItem[]; + className?: string; +}; + +const getStatusIcon = (status: TodoItem['status']) => { + switch (status) { + case 'completed': + return '●'; // Filled circle for completed + case 'in_progress': + return '●'; // Filled circle for in progress + case 'pending': + return '○'; // Empty circle for pending + default: + return '○'; + } +}; + +const getStatusColor = (status: TodoItem['status']) => { + switch (status) { + case 'completed': + return 'text-green-500'; + case 'in_progress': + return 'text-yellow-500'; + case 'pending': + return 'text-gray-400'; + default: + return 'text-gray-400'; + } +}; + +export const TodoListDisplay = ({ todos, className }: TodoListDisplayProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!todos || todos.length === 0) { + return null; + } + + // Get counts for each status + const completed = todos.filter((t) => t.status === 'completed').length; + + // Find the most relevant current task (first in-progress, or first pending if no in-progress) + const currentTask = + todos.find((t) => t.status === 'in_progress') || + todos.find((t) => t.status === 'pending'); + + return ( +
+ + + {/* Collapsed view - show current task */} + {!isExpanded && currentTask && ( +
+ + {getStatusIcon(currentTask.status)} + + + {currentTask.content} + +
+ )} + + {/* Expanded view - show all tasks */} + {isExpanded && ( +
    + {todos.map((todo) => ( +
  • + + {getStatusIcon(todo.status)} + + + {todo.content} + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/lib/toolUsageParser.ts b/apps/web/src/lib/toolUsageParser.ts index 56a305393f..999e955931 100644 --- a/apps/web/src/lib/toolUsageParser.ts +++ b/apps/web/src/lib/toolUsageParser.ts @@ -5,6 +5,13 @@ export type ToolUsage = { action: string; details?: string; + todoData?: { + todos: Array<{ + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + }>; + }; }; /** @@ -108,6 +115,21 @@ export function extractToolUsageFromAsk(message: { action: 'Edited', details: path, }; + case 'updateTodoList': + // Handle todo list updates + if (toolData.todos && Array.isArray(toolData.todos)) { + return { + action: 'Updated', + details: 'todo list', + todoData: { + todos: toolData.todos, + }, + }; + } + return { + action: 'Updated', + details: 'todo list', + }; default: return null; }