mirror of https://github.com/kortix-ai/suna.git
Merge pull request #46 from kortix-ai/fuck-frontend
Streaming almost working again, no rendering though, and still very buggy
This commit is contained in:
commit
b2c307ef7e
|
@ -9,7 +9,7 @@ import {
|
||||||
FileEdit, Search, Globe, Code, MessageSquare, Folder, FileX, CloudUpload, Wrench, Cog
|
FileEdit, Search, Globe, Code, MessageSquare, Folder, FileX, CloudUpload, Wrench, Cog
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ElementType } from 'react';
|
import type { ElementType } from 'react';
|
||||||
import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject } from '@/lib/api';
|
import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject, Project } from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ChatInput } from '@/components/thread/chat-input';
|
import { ChatInput } from '@/components/thread/chat-input';
|
||||||
|
@ -17,6 +17,7 @@ import { FileViewerModal } from '@/components/thread/file-viewer-modal';
|
||||||
import { SiteHeader } from "@/components/thread/thread-site-header"
|
import { SiteHeader } from "@/components/thread/thread-site-header"
|
||||||
import { ToolCallSidePanel, SidePanelContent, ToolCallData } from "@/components/thread/tool-call-side-panel";
|
import { ToolCallSidePanel, SidePanelContent, ToolCallData } from "@/components/thread/tool-call-side-panel";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
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
|
// Define a type for the params to make React.use() work properly
|
||||||
type ThreadParams = {
|
type ThreadParams = {
|
||||||
|
@ -224,7 +225,6 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
const [toolCallData, setToolCallData] = useState<ToolCallData | null>(null);
|
const [toolCallData, setToolCallData] = useState<ToolCallData | null>(null);
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
const [projectName, setProjectName] = useState<string>('Project');
|
const [projectName, setProjectName] = useState<string>('Project');
|
||||||
|
|
||||||
const streamCleanupRef = useRef<(() => void) | null>(null);
|
const streamCleanupRef = useRef<(() => void) | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const initialLoadCompleted = useRef<boolean>(false);
|
const initialLoadCompleted = useRef<boolean>(false);
|
||||||
|
@ -237,6 +237,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
const [buttonOpacity, setButtonOpacity] = useState(0);
|
const [buttonOpacity, setButtonOpacity] = useState(0);
|
||||||
const [userHasScrolled, setUserHasScrolled] = useState(false);
|
const [userHasScrolled, setUserHasScrolled] = useState(false);
|
||||||
const hasInitiallyScrolled = useRef<boolean>(false);
|
const hasInitiallyScrolled = useRef<boolean>(false);
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [sandboxId, setSandboxId] = useState<string | null>(null);
|
const [sandboxId, setSandboxId] = useState<string | null>(null);
|
||||||
const [fileViewerOpen, setFileViewerOpen] = useState(false);
|
const [fileViewerOpen, setFileViewerOpen] = useState(false);
|
||||||
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
|
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
|
||||||
|
@ -362,6 +363,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
message?: string;
|
message?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
arguments?: string;
|
arguments?: string;
|
||||||
|
finish_reason?: string;
|
||||||
|
function_name?: string;
|
||||||
|
xml_tag_name?: string;
|
||||||
tool_call?: {
|
tool_call?: {
|
||||||
id: string;
|
id: string;
|
||||||
function: {
|
function: {
|
||||||
|
@ -373,7 +377,10 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
};
|
};
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
let currentLiveToolCall: ToolCallData | null = null;
|
// Handle data: prefix format (SSE standard)
|
||||||
|
if (processedData.startsWith('data: ')) {
|
||||||
|
processedData = processedData.substring(6).trim();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
jsonData = JSON.parse(processedData);
|
jsonData = JSON.parse(processedData);
|
||||||
|
@ -416,10 +423,73 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle finish message
|
||||||
|
if (jsonData?.type === 'finish') {
|
||||||
|
console.log(`[PAGE] Received finish message with reason: ${jsonData.finish_reason}`);
|
||||||
|
// If there's a specific finish reason, handle it
|
||||||
|
if (jsonData.finish_reason === 'xml_tool_limit_reached') {
|
||||||
|
// Potentially show a toast notification
|
||||||
|
toast.info('Tool execution limit reached. The agent will continue with available information.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle content type messages (text from the agent)
|
||||||
|
if (jsonData?.type === 'content' && jsonData?.content) {
|
||||||
|
console.log('[PAGE] Adding content to stream:', jsonData.content);
|
||||||
|
setStreamContent(prev => prev + jsonData.content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool status messages
|
||||||
|
if (jsonData?.type === 'tool_status') {
|
||||||
|
console.log(`[PAGE] Tool status: ${jsonData.status} for ${jsonData.function_name}`);
|
||||||
|
|
||||||
|
// Update UI based on tool status
|
||||||
|
if (jsonData.status === 'started') {
|
||||||
|
// Could show a loading indicator for the specific tool
|
||||||
|
const toolInfo = {
|
||||||
|
id: jsonData.xml_tag_name || `tool-${Date.now()}`,
|
||||||
|
name: jsonData.function_name || 'unknown',
|
||||||
|
arguments: '{}',
|
||||||
|
index: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
setToolCallData(toolInfo);
|
||||||
|
// Optionally add to stream content to show tool execution
|
||||||
|
setStreamContent(prev =>
|
||||||
|
prev + `\n\nExecuting tool: ${jsonData.function_name}\n`
|
||||||
|
);
|
||||||
|
} else if (jsonData.status === 'completed') {
|
||||||
|
// Update UI to show tool completion
|
||||||
|
setToolCallData(null);
|
||||||
|
// Optionally add to stream content
|
||||||
|
setStreamContent(prev =>
|
||||||
|
prev + `\nTool execution completed: ${jsonData.function_name}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool result messages
|
||||||
|
if (jsonData?.type === 'tool_result') {
|
||||||
|
console.log('[PAGE] Received tool result for:', jsonData.function_name);
|
||||||
|
|
||||||
|
// Clear the tool data since execution is complete
|
||||||
|
setSidePanelContent(null);
|
||||||
|
setToolCallData(null);
|
||||||
|
|
||||||
|
// Add tool result to the stream content for visibility
|
||||||
|
setStreamContent(prev =>
|
||||||
|
prev + `\nReceived result from ${jsonData.function_name}\n`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Handle Live Tool Call Updates for Side Panel ---
|
// --- Handle Live Tool Call Updates for Side Panel ---
|
||||||
if (jsonData?.type === 'tool_call' && jsonData.tool_call) {
|
if (jsonData?.type === 'tool_call' && jsonData.tool_call) {
|
||||||
console.log('[PAGE] Received tool_call update:', jsonData.tool_call);
|
console.log('[PAGE] Received tool_call update:', jsonData.tool_call);
|
||||||
currentLiveToolCall = {
|
const currentLiveToolCall: ToolCallData = {
|
||||||
id: jsonData.tool_call.id,
|
id: jsonData.tool_call.id,
|
||||||
name: jsonData.tool_call.function.name,
|
name: jsonData.tool_call.function.name,
|
||||||
arguments: jsonData.tool_call.function.arguments,
|
arguments: jsonData.tool_call.function.arguments,
|
||||||
|
@ -428,25 +498,28 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
setToolCallData(currentLiveToolCall); // Keep for stream content rendering
|
setToolCallData(currentLiveToolCall); // Keep for stream content rendering
|
||||||
setCurrentPairIndex(null); // Live data means not viewing a historical pair
|
setCurrentPairIndex(null); // Live data means not viewing a historical pair
|
||||||
setSidePanelContent(currentLiveToolCall); // Update side panel
|
setSidePanelContent(currentLiveToolCall); // Update side panel
|
||||||
|
|
||||||
|
// Add to stream content so it's visible
|
||||||
|
setStreamContent(prev =>
|
||||||
|
prev + `\nCalling tool: ${currentLiveToolCall.name}\n`
|
||||||
|
);
|
||||||
|
|
||||||
if (!isSidePanelOpen) {
|
if (!isSidePanelOpen) {
|
||||||
// Optionally auto-open side panel? Maybe only if user hasn't closed it recently.
|
// Optionally auto-open side panel? Maybe only if user hasn't closed it recently.
|
||||||
// setIsSidePanelOpen(true);
|
// setIsSidePanelOpen(true);
|
||||||
}
|
}
|
||||||
} else if (jsonData?.type === 'tool_result') {
|
return;
|
||||||
// When tool result comes in, clear the live tool from side panel?
|
|
||||||
// Or maybe wait until stream end?
|
|
||||||
console.log('[PAGE] Received tool_result, clearing live tool from side panel');
|
|
||||||
setSidePanelContent(null);
|
|
||||||
setToolCallData(null);
|
|
||||||
// Don't necessarily clear currentPairIndex here, user might want to navigate back
|
|
||||||
}
|
}
|
||||||
// --- End Side Panel Update Logic ---
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[PAGE] Failed to parse message:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue with normal message processing...
|
// If we reach here and have JSON data but it's not a recognized type,
|
||||||
// ... rest of the onMessage handler ...
|
// log it for debugging purposes
|
||||||
|
console.log('[PAGE] Unhandled message type:', jsonData?.type);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
// If JSON parsing fails, treat it as raw text content
|
||||||
|
console.warn('[PAGE] Failed to parse as JSON, treating as raw content:', e);
|
||||||
|
setStreamContent(prev => prev + processedData);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PAGE] Error processing message:', error);
|
console.error('[PAGE] Error processing message:', error);
|
||||||
toast.error('Failed to process agent response');
|
toast.error('Failed to process agent response');
|
||||||
|
@ -551,6 +624,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
if (threadData && threadData.project_id) {
|
if (threadData && threadData.project_id) {
|
||||||
const projectData = await getProject(threadData.project_id);
|
const projectData = await getProject(threadData.project_id);
|
||||||
if (isMounted && projectData && projectData.sandbox) {
|
if (isMounted && projectData && projectData.sandbox) {
|
||||||
|
// Store the full project object
|
||||||
|
setProject(projectData);
|
||||||
|
|
||||||
// Extract the sandbox ID correctly
|
// Extract the sandbox ID correctly
|
||||||
setSandboxId(typeof projectData.sandbox === 'string' ? projectData.sandbox : projectData.sandbox.id);
|
setSandboxId(typeof projectData.sandbox === 'string' ? projectData.sandbox : projectData.sandbox.id);
|
||||||
|
|
||||||
|
@ -1027,6 +1103,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
currentIndex={currentPairIndex}
|
currentIndex={currentPairIndex}
|
||||||
totalPairs={allHistoricalPairs.length}
|
totalPairs={allHistoricalPairs.length}
|
||||||
onNavigate={handleSidePanelNavigate}
|
onNavigate={handleSidePanelNavigate}
|
||||||
|
project={project}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1060,6 +1137,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
currentIndex={currentPairIndex}
|
currentIndex={currentPairIndex}
|
||||||
totalPairs={allHistoricalPairs.length}
|
totalPairs={allHistoricalPairs.length}
|
||||||
onNavigate={handleSidePanelNavigate}
|
onNavigate={handleSidePanelNavigate}
|
||||||
|
project={project}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1387,6 +1465,15 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
|
|
||||||
<div className="bg-sidebar backdrop-blur-sm">
|
<div className="bg-sidebar backdrop-blur-sm">
|
||||||
<div className="mx-auto max-w-3xl px-6 py-2">
|
<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
|
<ChatInput
|
||||||
value={newMessage}
|
value={newMessage}
|
||||||
onChange={setNewMessage}
|
onChange={setNewMessage}
|
||||||
|
@ -1413,6 +1500,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
||||||
currentIndex={currentPairIndex}
|
currentIndex={currentPairIndex}
|
||||||
totalPairs={allHistoricalPairs.length}
|
totalPairs={allHistoricalPairs.length}
|
||||||
onNavigate={handleSidePanelNavigate}
|
onNavigate={handleSidePanelNavigate}
|
||||||
|
project={project}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{sandboxId && (
|
{sandboxId && (
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue