todo panel

This commit is contained in:
Adam Cohen Hillel 2025-04-17 02:02:03 +01:00
parent 2807a91fe3
commit c098444fb2
3 changed files with 300 additions and 0 deletions

View File

@ -17,6 +17,7 @@ import { FileViewerModal } from '@/components/thread/file-viewer-modal';
import { SiteHeader } from "@/components/thread/thread-site-header"
import { ToolCallSidePanel, SidePanelContent, ToolCallData } from "@/components/thread/tool-call-side-panel";
import { useSidebar } from "@/components/ui/sidebar";
import { TodoPanel } from '@/components/thread/todo-panel';
// Define a type for the params to make React.use() work properly
type ThreadParams = {
@ -1392,6 +1393,15 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
<div className="bg-sidebar backdrop-blur-sm">
<div className="mx-auto max-w-3xl px-6 py-2">
{/* Show Todo panel above chat input when side panel is closed */}
{!isSidePanelOpen && sandboxId && (
<TodoPanel
sandboxId={sandboxId}
isSidePanelOpen={isSidePanelOpen}
className="mb-3"
/>
)}
<ChatInput
value={newMessage}
onChange={setNewMessage}

View File

@ -0,0 +1,278 @@
'use client';
import React, { useEffect, useState } from 'react';
import { getSandboxFileContent } from '@/lib/api';
import { Skeleton } from "@/components/ui/skeleton";
import { Check, RefreshCw, ChevronDown, ChevronUp, Circle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/ui/scroll-area';
interface TodoPanelProps {
sandboxId: string | null;
isSidePanelOpen: boolean;
className?: string;
}
interface TodoTask {
text: string;
completed: boolean;
}
export function TodoPanel({ sandboxId, isSidePanelOpen, className = '' }: TodoPanelProps) {
const [tasks, setTasks] = useState<TodoTask[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefreshed, setLastRefreshed] = useState<Date>(new Date());
const [isCollapsed, setIsCollapsed] = useState(true);
const fetchTodoContent = async () => {
if (!sandboxId) return;
setIsLoading(true);
setError(null);
try {
const content = await getSandboxFileContent(sandboxId, '/workspace/todo.md');
if (typeof content === 'string') {
parseTasks(content);
} else if (content instanceof Blob) {
// Handle blob content (convert to text)
const text = await content.text();
parseTasks(text);
} else {
throw new Error('Unexpected content format');
}
} catch (err) {
console.error('Failed to load todo.md:', err);
// Don't show error toast when file doesn't exist yet, as this is expected initially
if (err instanceof Error && !err.message.includes('404')) {
setError('Tasks will show up shortly');
// toast.error('Failed to load todo file');
} else {
setTasks([]);
}
} finally {
setIsLoading(false);
}
};
// Extract just the task items from todo.md content
const parseTasks = (content: string) => {
if (!content) {
setTasks([]);
return;
}
const lines = content.split('\n');
const extractedTasks: TodoTask[] = [];
lines.forEach(line => {
const trimmedLine = line.trim();
// Only look for task items with checkbox
const taskMatch = trimmedLine.match(/^\s*[-*]\s+\[([ x])\]\s+(.+)$/);
if (taskMatch) {
extractedTasks.push({
text: taskMatch[2].trim(),
completed: taskMatch[1] === 'x'
});
}
});
setTasks(extractedTasks);
};
// Fetch the todo.md file when component mounts or sandboxId changes
useEffect(() => {
if (sandboxId) {
fetchTodoContent();
}
}, [sandboxId, lastRefreshed]);
// Set up periodic refresh (every 10 seconds)
useEffect(() => {
const intervalId = setInterval(() => {
fetchTodoContent();
}, 10000);
return () => clearInterval(intervalId);
}, [sandboxId]);
const handleRefresh = () => {
setLastRefreshed(new Date());
};
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
// Only get incomplete tasks
const incompleteTasks = tasks.filter(task => !task.completed);
const completedTasks = tasks.filter(task => task.completed);
// Get first incomplete task
const firstIncompleteTask = incompleteTasks.length > 0 ? incompleteTasks[0] : null;
// Styling based on whether the side panel is open
const containerClasses = isSidePanelOpen
? 'border-t p-2 bg-sidebar' // At bottom of side panel
: 'border rounded-md shadow-sm mb-2 bg-card'; // Above chat input
const heightClasses = isSidePanelOpen
? isCollapsed ? 'h-[70px]' : 'h-[200px]' // Fixed height in side panel
: isCollapsed ? 'max-h-[70px]' : 'max-h-[200px]'; // Max height above chat input
return (
<div className={cn(
`${containerClasses} ${heightClasses} transition-all duration-300 ease-out`,
className
)}>
{isLoading ? (
<div className="space-y-2 p-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : error ? (
<div className="flex items-center justify-center h-10">
<p className="text-xs text-muted-foreground">{error}</p>
</div>
) : tasks.length === 0 ? (
<div className="flex items-center justify-center h-10 text-xs text-muted-foreground">
Tasks will show up shortly
</div>
) : (
<>
{/* Display tasks based on collapsed state */}
{isCollapsed ? (
<div className="px-1 pt-1">
{firstIncompleteTask ? (
<div className="flex items-start gap-2">
<div className="mt-0.5 relative flex-shrink-0">
<Circle className="h-3.5 w-3.5 text-secondary" />
{/* Pulse animation inside circle */}
<span className="absolute inset-1 rounded-full bg-secondary/30 animate-ping opacity-75"></span>
</div>
<span className="text-sm">{firstIncompleteTask.text}</span>
<div className="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleRefresh}
title="Refresh todo list"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={toggleCollapse}
title={isCollapsed ? "Expand" : "Collapse"}
>
{isCollapsed ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
) : (
<div className="flex items-center h-8 px-1 text-sm">
<Check className="h-4 w-4 mr-2" />
All tasks completed!
<div className="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleRefresh}
title="Refresh todo list"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={toggleCollapse}
title={isCollapsed ? "Expand" : "Collapse"}
>
{isCollapsed ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
)}
</div>
) : (
<>
<div className="flex items-center justify-between px-1 mb-1">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleRefresh}
title="Refresh todo list"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={toggleCollapse}
title={isCollapsed ? "Expand" : "Collapse"}
>
{isCollapsed ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
<ScrollArea className="h-[calc(100%-30px)]">
<div className="space-y-1.5 px-1">
{/* Incomplete tasks first */}
{incompleteTasks.map((task, index) => (
<div key={`incomplete-${index}`} className="flex items-start gap-2">
<div className="mt-0.5 flex-shrink-0">
<Circle className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<span className="text-sm leading-tight">{task.text}</span>
</div>
))}
{/* Show completed tasks after incomplete ones */}
{completedTasks.length > 0 && (
<>
{incompleteTasks.length > 0 && <div className="border-t my-2 border-border/40"></div>}
{completedTasks.map((task, index) => (
<div key={`complete-${index}`} className="flex items-start gap-2 opacity-60">
<div className="mt-0.5 flex-shrink-0">
<Check className="h-3.5 w-3.5 text-green-500" />
</div>
<span className="text-sm text-muted-foreground line-through leading-tight">{task.text}</span>
</div>
))}
</>
)}
</div>
</ScrollArea>
</>
)}
</>
)}
</div>
);
}

View File

@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { X, Package, Info, Terminal, CheckCircle, SkipBack, SkipForward, MonitorPlay, FileSymlink, FileDiff, FileEdit, Search, Globe, ExternalLink, Database, Code, ListFilter } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { Project } from "@/lib/api";
import { TodoPanel } from "./todo-panel";
// Define the structure for LIVE tool call data (from streaming)
export interface ToolCallData {
@ -1186,6 +1187,9 @@ export function ToolCallSidePanel({
// Get VNC preview URL from project if available
const vncPreviewUrl = project?.sandbox?.vnc_preview;
// Get the sandbox ID from project for todo.md fetching
const sandboxId = project?.sandbox?.id || null;
return (
<div
className={`
@ -1352,6 +1356,14 @@ export function ToolCallSidePanel({
</div>
)}
</div>
{/* Todo Panel at the bottom of side panel */}
{sandboxId && (
<TodoPanel
sandboxId={sandboxId}
isSidePanelOpen={isOpen}
/>
)}
</>
)}
</div>