feat(components): add comprehensive TodoReadWidget with advanced viewing capabilities

- Add TodoReadWidget component with multiple view modes (list, board, timeline, stats)
- Implement search and filtering functionality for todo items
- Add export capabilities (JSON and Markdown formats)
- Include rich UI with animations, progress tracking, and interactive elements
- Integrate TodoReadWidget into StreamMessage component for todoread tool support
- Add status indicators, dependency tracking, and completion rate calculations
This commit is contained in:
Mufeed VH 2025-07-04 20:38:29 +05:30
parent 19cf623d64
commit 7accf1cd03
2 changed files with 520 additions and 1 deletions

View file

@ -15,6 +15,7 @@ import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
import type { ClaudeStreamMessage } from "./AgentExecution";
import {
TodoWidget,
TodoReadWidget,
LSWidget,
ReadWidget,
ReadResultWidget,
@ -205,6 +206,12 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
return <TodoWidget todos={input.todos} result={toolResult} />;
}
// TodoRead tool
if (toolName === "todoread") {
renderedSomething = true;
return <TodoReadWidget todos={input?.todos} result={toolResult} />;
}
// LS tool
if (toolName === "ls" && input?.path) {
renderedSomething = true;
@ -368,7 +375,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch','webfetch'];
const toolsWithWidgets = ['task','edit','multiedit','todowrite','todoread','ls','read','glob','bash','write','grep','websearch','webfetch'];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
hasCorrespondingWidget = true;
}

View file

@ -41,6 +41,12 @@ import {
FileCode,
Folder,
ChevronUp,
BarChart3,
Download,
LayoutGrid,
LayoutList,
Activity,
Hash,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@ -53,6 +59,9 @@ import { Card, CardContent } from "@/components/ui/card";
import { detectLinks, makeLinksClickable } from "@/lib/linkDetector";
import ReactMarkdown from "react-markdown";
import { open } from "@tauri-apps/plugin-shell";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { motion, AnimatePresence } from "framer-motion";
/**
* Widget for TodoWrite tool - displays a beautiful TODO list
@ -2472,3 +2481,506 @@ export const WebFetchWidget: React.FC<{
</div>
);
};
/**
* Widget for TodoRead tool - displays todos with advanced viewing capabilities
*/
export const TodoReadWidget: React.FC<{ todos?: any[]; result?: any }> = ({ todos: inputTodos, result }) => {
// Extract todos from result if not directly provided
let todos: any[] = inputTodos || [];
if (!todos.length && result) {
if (typeof result === 'object' && Array.isArray(result.todos)) {
todos = result.todos;
} else if (typeof result.content === 'string') {
try {
const parsed = JSON.parse(result.content);
if (Array.isArray(parsed)) todos = parsed;
else if (parsed.todos) todos = parsed.todos;
} catch (e) {
// Not JSON, ignore
}
}
}
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [viewMode, setViewMode] = useState<"list" | "board" | "timeline" | "stats">("list");
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
// Status icons and colors
const statusConfig = {
completed: {
icon: <CheckCircle2 className="h-4 w-4" />,
color: "text-green-500",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/20",
label: "Completed"
},
in_progress: {
icon: <Clock className="h-4 w-4 animate-pulse" />,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
borderColor: "border-blue-500/20",
label: "In Progress"
},
pending: {
icon: <Circle className="h-4 w-4" />,
color: "text-muted-foreground",
bgColor: "bg-muted/50",
borderColor: "border-muted",
label: "Pending"
},
cancelled: {
icon: <X className="h-4 w-4" />,
color: "text-red-500",
bgColor: "bg-red-500/10",
borderColor: "border-red-500/20",
label: "Cancelled"
}
};
// Filter todos based on search and status
const filteredTodos = todos.filter(todo => {
const matchesSearch = !searchQuery ||
todo.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
(todo.id && todo.id.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesStatus = statusFilter === "all" || todo.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Calculate statistics
const stats = {
total: todos.length,
completed: todos.filter(t => t.status === "completed").length,
inProgress: todos.filter(t => t.status === "in_progress").length,
pending: todos.filter(t => t.status === "pending").length,
cancelled: todos.filter(t => t.status === "cancelled").length,
completionRate: todos.length > 0
? Math.round((todos.filter(t => t.status === "completed").length / todos.length) * 100)
: 0
};
// Group todos by status for board view
const todosByStatus = {
pending: filteredTodos.filter(t => t.status === "pending"),
in_progress: filteredTodos.filter(t => t.status === "in_progress"),
completed: filteredTodos.filter(t => t.status === "completed"),
cancelled: filteredTodos.filter(t => t.status === "cancelled")
};
// Toggle expanded state for a todo
const toggleExpanded = (todoId: string) => {
setExpandedTodos(prev => {
const next = new Set(prev);
if (next.has(todoId)) {
next.delete(todoId);
} else {
next.add(todoId);
}
return next;
});
};
// Export todos as JSON
const exportAsJson = () => {
const dataStr = JSON.stringify(todos, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
const exportFileDefaultName = 'todos.json';
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
};
// Export todos as Markdown
const exportAsMarkdown = () => {
let markdown = "# Todo List\n\n";
markdown += `**Total**: ${stats.total} | **Completed**: ${stats.completed} | **In Progress**: ${stats.inProgress} | **Pending**: ${stats.pending}\n\n`;
const statusGroups = ["pending", "in_progress", "completed", "cancelled"];
statusGroups.forEach(status => {
const todosInStatus = todos.filter(t => t.status === status);
if (todosInStatus.length > 0) {
markdown += `## ${statusConfig[status as keyof typeof statusConfig]?.label || status}\n\n`;
todosInStatus.forEach(todo => {
const checkbox = todo.status === "completed" ? "[x]" : "[ ]";
markdown += `- ${checkbox} ${todo.content}${todo.id ? ` (${todo.id})` : ""}\n`;
if (todo.dependencies?.length > 0) {
markdown += ` - Dependencies: ${todo.dependencies.join(", ")}\n`;
}
});
markdown += "\n";
}
});
const dataUri = 'data:text/markdown;charset=utf-8,'+ encodeURIComponent(markdown);
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', 'todos.md');
linkElement.click();
};
// Render todo card
const TodoCard = ({ todo, isExpanded }: { todo: any; isExpanded: boolean }) => {
const config = statusConfig[todo.status as keyof typeof statusConfig] || statusConfig.pending;
return (
<motion.div
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={cn(
"group rounded-lg border p-4 transition-all hover:shadow-md cursor-pointer",
config.bgColor,
config.borderColor,
todo.status === "completed" && "opacity-75"
)}
onClick={() => todo.id && toggleExpanded(todo.id)}
>
<div className="flex items-start gap-3">
<div className={cn("mt-0.5", config.color)}>
{config.icon}
</div>
<div className="flex-1 space-y-2">
<p className={cn(
"text-sm",
todo.status === "completed" && "line-through"
)}>
{todo.content}
</p>
{/* Todo metadata */}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{todo.id && (
<div className="flex items-center gap-1">
<Hash className="h-3 w-3" />
<span className="font-mono">{todo.id}</span>
</div>
)}
{todo.dependencies?.length > 0 && (
<div className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
<span>{todo.dependencies.length} deps</span>
</div>
)}
</div>
{/* Expanded details */}
<AnimatePresence>
{isExpanded && todo.dependencies?.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="pt-2 mt-2 border-t space-y-1">
<span className="text-xs font-medium text-muted-foreground">Dependencies:</span>
<div className="flex flex-wrap gap-1">
{todo.dependencies.map((dep: string) => (
<Badge
key={dep}
variant="outline"
className="text-xs font-mono"
>
{dep}
</Badge>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
);
};
// Render statistics view
const StatsView = () => (
<div className="space-y-4">
{/* Overall Progress */}
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium">Overall Progress</h4>
<span className="text-2xl font-bold text-primary">{stats.completionRate}%</span>
</div>
<div className="w-full bg-muted rounded-full h-3 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${stats.completionRate}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="h-full bg-gradient-to-r from-primary to-primary/80"
/>
</div>
</Card>
{/* Status Breakdown */}
<div className="grid grid-cols-2 gap-3">
{Object.entries(statusConfig).map(([status, config]) => {
const count = stats[status as keyof typeof stats] || 0;
const percentage = stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
return (
<Card key={status} className={cn("p-4", config.bgColor)}>
<div className="flex items-center gap-3">
<div className={config.color}>{config.icon}</div>
<div className="flex-1">
<p className="text-xs text-muted-foreground">{config.label}</p>
<p className="text-lg font-semibold">{count}</p>
<p className="text-xs text-muted-foreground">{percentage}%</p>
</div>
</div>
</Card>
);
})}
</div>
{/* Activity Chart */}
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 text-primary" />
<h4 className="text-sm font-medium">Activity Overview</h4>
</div>
<div className="space-y-2">
{Object.entries(statusConfig).map(([status, config]) => {
const count = stats[status as keyof typeof stats] || 0;
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
return (
<div key={status} className="flex items-center gap-3">
<span className="text-xs w-20 text-right">{config.label}</span>
<div className="flex-1 bg-muted rounded-full h-2 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percentage}%` }}
transition={{ duration: 0.5, delay: 0.1 }}
className={cn("h-full", config.bgColor)}
/>
</div>
<span className="text-xs w-12 text-left">{count}</span>
</div>
);
})}
</div>
</Card>
</div>
);
// Render board view
const BoardView = () => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(todosByStatus).map(([status, todos]) => {
const config = statusConfig[status as keyof typeof statusConfig];
return (
<div key={status} className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b">
<div className={config.color}>{config.icon}</div>
<h3 className="text-sm font-medium">{config.label}</h3>
<Badge variant="secondary" className="ml-auto text-xs">
{todos.length}
</Badge>
</div>
<div className="space-y-2">
{todos.map(todo => (
<TodoCard
key={todo.id || todos.indexOf(todo)}
todo={todo}
isExpanded={expandedTodos.has(todo.id)}
/>
))}
{todos.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">
No todos
</p>
)}
</div>
</div>
);
})}
</div>
);
// Render timeline view
const TimelineView = () => {
// Group todos by their dependencies to create a timeline
const rootTodos = todos.filter(t => !t.dependencies || t.dependencies.length === 0);
const rendered = new Set<string>();
const renderTodoWithDependents = (todo: any, level = 0) => {
if (rendered.has(todo.id)) return null;
rendered.add(todo.id);
const dependents = todos.filter(t =>
t.dependencies?.includes(todo.id) && !rendered.has(t.id)
);
return (
<div key={todo.id} className="relative">
{level > 0 && (
<div className="absolute left-6 top-0 w-px h-6 bg-border" />
)}
<div className={cn("flex gap-4", level > 0 && "ml-12")}>
<div className="relative">
<div className={cn(
"w-3 h-3 rounded-full border-2 bg-background",
statusConfig[todo.status as keyof typeof statusConfig]?.borderColor
)} />
{dependents.length > 0 && (
<div className="absolute left-1/2 top-3 w-px h-full bg-border -translate-x-1/2" />
)}
</div>
<div className="flex-1 pb-6">
<TodoCard
todo={todo}
isExpanded={expandedTodos.has(todo.id)}
/>
</div>
</div>
{dependents.map(dep => renderTodoWithDependents(dep, level + 1))}
</div>
);
};
return (
<div className="space-y-4">
{rootTodos.map(todo => renderTodoWithDependents(todo))}
{todos.filter(t => !rendered.has(t.id)).map(todo => renderTodoWithDependents(todo))}
</div>
);
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ListChecks className="h-5 w-5 text-primary" />
<div>
<h3 className="text-sm font-medium">Todo Overview</h3>
<p className="text-xs text-muted-foreground">
{stats.total} total {stats.completed} completed {stats.completionRate}% done
</p>
</div>
</div>
{/* Export Options */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={exportAsJson}
>
<Download className="h-3 w-3 mr-1" />
JSON
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={exportAsMarkdown}
>
<Download className="h-3 w-3 mr-1" />
Markdown
</Button>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Search todos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
<div className="flex gap-2">
<div className="flex gap-1 p-1 bg-muted rounded-md">
{["all", "pending", "in_progress", "completed", "cancelled"].map(status => (
<Button
key={status}
size="sm"
variant={statusFilter === status ? "default" : "ghost"}
className="h-7 px-2 text-xs"
onClick={() => setStatusFilter(status)}
>
{status === "all" ? "All" : statusConfig[status as keyof typeof statusConfig]?.label}
{status === "all" && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">
{stats.total}
</Badge>
)}
</Button>
))}
</div>
</div>
</div>
{/* View Mode Tabs */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as typeof viewMode)}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="list" className="text-xs">
<LayoutList className="h-4 w-4 mr-1" />
List
</TabsTrigger>
<TabsTrigger value="board" className="text-xs">
<LayoutGrid className="h-4 w-4 mr-1" />
Board
</TabsTrigger>
<TabsTrigger value="timeline" className="text-xs">
<GitBranch className="h-4 w-4 mr-1" />
Timeline
</TabsTrigger>
<TabsTrigger value="stats" className="text-xs">
<BarChart3 className="h-4 w-4 mr-1" />
Stats
</TabsTrigger>
</TabsList>
<TabsContent value="list" className="mt-4">
<div className="space-y-2">
<AnimatePresence mode="popLayout">
{filteredTodos.map(todo => (
<TodoCard
key={todo.id || filteredTodos.indexOf(todo)}
todo={todo}
isExpanded={expandedTodos.has(todo.id)}
/>
))}
</AnimatePresence>
{filteredTodos.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
{searchQuery || statusFilter !== "all"
? "No todos match your filters"
: "No todos available"}
</div>
)}
</div>
</TabsContent>
<TabsContent value="board" className="mt-4">
<BoardView />
</TabsContent>
<TabsContent value="timeline" className="mt-4">
<TimelineView />
</TabsContent>
<TabsContent value="stats" className="mt-4">
<StatsView />
</TabsContent>
</Tabs>
</div>
);
};