Display todos (#219)

This commit is contained in:
Matt Rubens 2025-07-07 15:09:57 -04:00 committed by GitHub
parent 5a3f867564
commit 900a9014dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 263 additions and 4 deletions

View file

@ -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 (
<div key={message.id} className="py-2 pl-4">
{message.toolUsage && (
<ToolUsageBadge usage={message.toolUsage} />
<div key={message.id} className="py-2 space-y-3">
{message.toolUsage?.todoData ? (
<TodoListDisplay todos={message.toolUsage.todoData.todos} />
) : (
message.toolUsage && (
<div className="pl-4">
<ToolUsageBadge usage={message.toolUsage} />
</div>
)
)}
</div>
);

View file

@ -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(<TodoListDisplay todos={mockTodos} />);
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(<TodoListDisplay todos={mockTodos} />);
// 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(<TodoListDisplay todos={mockTodos} />);
// 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(<TodoListDisplay todos={mockTodos} />);
// 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(<TodoListDisplay todos={todosWithoutInProgress} />);
// 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(<TodoListDisplay todos={[]} />);
expect(container.firstChild).toBeNull();
});
it('renders nothing when todos is undefined', () => {
const { container } = render(
<TodoListDisplay todos={undefined as unknown as TodoItem[]} />,
);
expect(container.firstChild).toBeNull();
});
});

View file

@ -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 (
<div
className={cn(
'rounded-lg bg-secondary/10 border border-border/50 p-3 space-y-2',
className,
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 w-full text-left hover:bg-secondary/20 rounded p-1 -m-1 transition-colors"
>
<ChevronRight
className={cn(
'h-3 w-3 text-muted-foreground transition-transform',
isExpanded && 'rotate-90',
)}
/>
<div className="text-sm font-medium text-muted-foreground">
Todo List Updated
</div>
<div className="text-xs text-muted-foreground/70 ml-auto">
{completed}/{todos.length}
</div>
</button>
{/* Collapsed view - show current task */}
{!isExpanded && currentTask && (
<div className="flex items-start gap-2 text-sm pl-5">
<span
className={cn(
'mt-0.5 select-none',
getStatusColor(currentTask.status),
)}
>
{getStatusIcon(currentTask.status)}
</span>
<span
className={cn(
'leading-relaxed',
currentTask.status === 'completed' &&
'line-through text-muted-foreground/70',
)}
>
{currentTask.content}
</span>
</div>
)}
{/* Expanded view - show all tasks */}
{isExpanded && (
<ul className="space-y-1.5 pl-5">
{todos.map((todo) => (
<li key={todo.id} className="flex items-start gap-2 text-sm">
<span
className={cn(
'mt-0.5 select-none',
getStatusColor(todo.status),
)}
>
{getStatusIcon(todo.status)}
</span>
<span
className={cn(
'leading-relaxed',
todo.status === 'completed' &&
'line-through text-muted-foreground/70',
)}
>
{todo.content}
</span>
</li>
))}
</ul>
)}
</div>
);
};

View file

@ -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;
}