Skip to content

Commit 900a901

Browse files
authored
Display todos (#219)
1 parent 5a3f867 commit 900a901

File tree

4 files changed

+263
-4
lines changed

4 files changed

+263
-4
lines changed

apps/web/src/app/(authenticated)/usage/Messages.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { formatTimestamp } from '@/lib/formatters';
1111
import { useAutoScroll } from '@/hooks/useAutoScroll';
1212
import { CodeBlock } from '@/components/ui/CodeBlock';
1313
import { ToolUsageBadge } from '@/components/ui/ToolUsageBadge';
14+
import { TodoListDisplay } from '@/components/ui/TodoListDisplay';
1415
import { parseToolUsage } from '@/lib/toolUsageParser';
1516

1617
// Custom component to render links as plain text to avoid broken/nonsensical links
@@ -317,12 +318,18 @@ export const Messages = ({
317318
);
318319
}
319320

320-
// For other tool messages, render only the tool usage badge in the space between bubbles
321+
// For tool messages, render the tool usage badge and todo list if present
321322
if (isTool) {
322323
return (
323-
<div key={message.id} className="py-2 pl-4">
324-
{message.toolUsage && (
325-
<ToolUsageBadge usage={message.toolUsage} />
324+
<div key={message.id} className="py-2 space-y-3">
325+
{message.toolUsage?.todoData ? (
326+
<TodoListDisplay todos={message.toolUsage.todoData.todos} />
327+
) : (
328+
message.toolUsage && (
329+
<div className="pl-4">
330+
<ToolUsageBadge usage={message.toolUsage} />
331+
</div>
332+
)
326333
)}
327334
</div>
328335
);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { TodoListDisplay, type TodoItem } from './TodoListDisplay';
3+
4+
describe('TodoListDisplay', () => {
5+
const mockTodos = [
6+
{
7+
id: '1',
8+
content: 'Complete the feature',
9+
status: 'completed' as const,
10+
},
11+
{
12+
id: '2',
13+
content: 'Write tests',
14+
status: 'in_progress' as const,
15+
},
16+
{
17+
id: '3',
18+
content: 'Update documentation',
19+
status: 'pending' as const,
20+
},
21+
];
22+
23+
it('renders todo list with header and progress', () => {
24+
render(<TodoListDisplay todos={mockTodos} />);
25+
26+
expect(screen.getByText('Todo List Updated')).toBeInTheDocument();
27+
expect(screen.getByText('1/3')).toBeInTheDocument(); // completed/total
28+
});
29+
30+
it('shows current task in collapsed view by default', () => {
31+
render(<TodoListDisplay todos={mockTodos} />);
32+
33+
// Should show the in-progress task by default
34+
expect(screen.getByText('Write tests')).toBeInTheDocument();
35+
// Should not show other tasks in collapsed view
36+
expect(screen.queryByText('Complete the feature')).not.toBeInTheDocument();
37+
expect(screen.queryByText('Update documentation')).not.toBeInTheDocument();
38+
});
39+
40+
it('shows all tasks when expanded', () => {
41+
render(<TodoListDisplay todos={mockTodos} />);
42+
43+
// Click to expand
44+
const expandButton = screen.getByRole('button');
45+
fireEvent.click(expandButton);
46+
47+
// Should show all tasks
48+
expect(screen.getByText('Complete the feature')).toBeInTheDocument();
49+
expect(screen.getByText('Write tests')).toBeInTheDocument();
50+
expect(screen.getByText('Update documentation')).toBeInTheDocument();
51+
});
52+
53+
it('applies correct styling for completed items', () => {
54+
render(<TodoListDisplay todos={mockTodos} />);
55+
56+
// Expand to see all items
57+
const expandButton = screen.getByRole('button');
58+
fireEvent.click(expandButton);
59+
60+
const completedItem = screen.getByText('Complete the feature');
61+
expect(completedItem).toHaveClass('line-through');
62+
});
63+
64+
it('shows pending task when no in-progress task exists', () => {
65+
const todosWithoutInProgress = [
66+
{
67+
id: '1',
68+
content: 'Complete the feature',
69+
status: 'completed' as const,
70+
},
71+
{
72+
id: '3',
73+
content: 'Update documentation',
74+
status: 'pending' as const,
75+
},
76+
];
77+
78+
render(<TodoListDisplay todos={todosWithoutInProgress} />);
79+
80+
// Should show the pending task since no in-progress exists
81+
expect(screen.getByText('Update documentation')).toBeInTheDocument();
82+
});
83+
84+
it('renders nothing when todos array is empty', () => {
85+
const { container } = render(<TodoListDisplay todos={[]} />);
86+
87+
expect(container.firstChild).toBeNull();
88+
});
89+
90+
it('renders nothing when todos is undefined', () => {
91+
const { container } = render(
92+
<TodoListDisplay todos={undefined as unknown as TodoItem[]} />,
93+
);
94+
95+
expect(container.firstChild).toBeNull();
96+
});
97+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useState } from 'react';
2+
import { ChevronRight } from 'lucide-react';
3+
import { cn } from '@/lib/utils';
4+
5+
export type TodoItem = {
6+
id: string;
7+
content: string;
8+
status: 'pending' | 'in_progress' | 'completed';
9+
};
10+
11+
type TodoListDisplayProps = {
12+
todos: TodoItem[];
13+
className?: string;
14+
};
15+
16+
const getStatusIcon = (status: TodoItem['status']) => {
17+
switch (status) {
18+
case 'completed':
19+
return '●'; // Filled circle for completed
20+
case 'in_progress':
21+
return '●'; // Filled circle for in progress
22+
case 'pending':
23+
return '○'; // Empty circle for pending
24+
default:
25+
return '○';
26+
}
27+
};
28+
29+
const getStatusColor = (status: TodoItem['status']) => {
30+
switch (status) {
31+
case 'completed':
32+
return 'text-green-500';
33+
case 'in_progress':
34+
return 'text-yellow-500';
35+
case 'pending':
36+
return 'text-gray-400';
37+
default:
38+
return 'text-gray-400';
39+
}
40+
};
41+
42+
export const TodoListDisplay = ({ todos, className }: TodoListDisplayProps) => {
43+
const [isExpanded, setIsExpanded] = useState(false);
44+
45+
if (!todos || todos.length === 0) {
46+
return null;
47+
}
48+
49+
// Get counts for each status
50+
const completed = todos.filter((t) => t.status === 'completed').length;
51+
52+
// Find the most relevant current task (first in-progress, or first pending if no in-progress)
53+
const currentTask =
54+
todos.find((t) => t.status === 'in_progress') ||
55+
todos.find((t) => t.status === 'pending');
56+
57+
return (
58+
<div
59+
className={cn(
60+
'rounded-lg bg-secondary/10 border border-border/50 p-3 space-y-2',
61+
className,
62+
)}
63+
>
64+
<button
65+
onClick={() => setIsExpanded(!isExpanded)}
66+
className="flex items-center gap-2 w-full text-left hover:bg-secondary/20 rounded p-1 -m-1 transition-colors"
67+
>
68+
<ChevronRight
69+
className={cn(
70+
'h-3 w-3 text-muted-foreground transition-transform',
71+
isExpanded && 'rotate-90',
72+
)}
73+
/>
74+
<div className="text-sm font-medium text-muted-foreground">
75+
Todo List Updated
76+
</div>
77+
<div className="text-xs text-muted-foreground/70 ml-auto">
78+
{completed}/{todos.length}
79+
</div>
80+
</button>
81+
82+
{/* Collapsed view - show current task */}
83+
{!isExpanded && currentTask && (
84+
<div className="flex items-start gap-2 text-sm pl-5">
85+
<span
86+
className={cn(
87+
'mt-0.5 select-none',
88+
getStatusColor(currentTask.status),
89+
)}
90+
>
91+
{getStatusIcon(currentTask.status)}
92+
</span>
93+
<span
94+
className={cn(
95+
'leading-relaxed',
96+
currentTask.status === 'completed' &&
97+
'line-through text-muted-foreground/70',
98+
)}
99+
>
100+
{currentTask.content}
101+
</span>
102+
</div>
103+
)}
104+
105+
{/* Expanded view - show all tasks */}
106+
{isExpanded && (
107+
<ul className="space-y-1.5 pl-5">
108+
{todos.map((todo) => (
109+
<li key={todo.id} className="flex items-start gap-2 text-sm">
110+
<span
111+
className={cn(
112+
'mt-0.5 select-none',
113+
getStatusColor(todo.status),
114+
)}
115+
>
116+
{getStatusIcon(todo.status)}
117+
</span>
118+
<span
119+
className={cn(
120+
'leading-relaxed',
121+
todo.status === 'completed' &&
122+
'line-through text-muted-foreground/70',
123+
)}
124+
>
125+
{todo.content}
126+
</span>
127+
</li>
128+
))}
129+
</ul>
130+
)}
131+
</div>
132+
);
133+
};

apps/web/src/lib/toolUsageParser.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
export type ToolUsage = {
66
action: string;
77
details?: string;
8+
todoData?: {
9+
todos: Array<{
10+
id: string;
11+
content: string;
12+
status: 'pending' | 'in_progress' | 'completed';
13+
}>;
14+
};
815
};
916

1017
/**
@@ -108,6 +115,21 @@ export function extractToolUsageFromAsk(message: {
108115
action: 'Edited',
109116
details: path,
110117
};
118+
case 'updateTodoList':
119+
// Handle todo list updates
120+
if (toolData.todos && Array.isArray(toolData.todos)) {
121+
return {
122+
action: 'Updated',
123+
details: 'todo list',
124+
todoData: {
125+
todos: toolData.todos,
126+
},
127+
};
128+
}
129+
return {
130+
action: 'Updated',
131+
details: 'todo list',
132+
};
111133
default:
112134
return null;
113135
}

0 commit comments

Comments
 (0)