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:
Adam Cohen Hillel 2025-04-17 11:29:38 +01:00 committed by GitHub
commit b2c307ef7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 1608 additions and 67 deletions

View File

@ -9,7 +9,7 @@ import {
FileEdit, Search, Globe, Code, MessageSquare, Folder, FileX, CloudUpload, Wrench, Cog
} from 'lucide-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 { Skeleton } from "@/components/ui/skeleton";
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 { 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 = {
@ -224,7 +225,6 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
const [toolCallData, setToolCallData] = useState<ToolCallData | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
const [projectName, setProjectName] = useState<string>('Project');
const streamCleanupRef = useRef<(() => void) | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const initialLoadCompleted = useRef<boolean>(false);
@ -237,6 +237,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
const [buttonOpacity, setButtonOpacity] = useState(0);
const [userHasScrolled, setUserHasScrolled] = useState(false);
const hasInitiallyScrolled = useRef<boolean>(false);
const [project, setProject] = useState<Project | null>(null);
const [sandboxId, setSandboxId] = useState<string | null>(null);
const [fileViewerOpen, setFileViewerOpen] = useState(false);
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
@ -362,6 +363,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
message?: string;
name?: string;
arguments?: string;
finish_reason?: string;
function_name?: string;
xml_tag_name?: string;
tool_call?: {
id: string;
function: {
@ -373,7 +377,10 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
};
} | null = null;
let currentLiveToolCall: ToolCallData | null = null;
// Handle data: prefix format (SSE standard)
if (processedData.startsWith('data: ')) {
processedData = processedData.substring(6).trim();
}
try {
jsonData = JSON.parse(processedData);
@ -415,11 +422,74 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
setAgentRunId(null);
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 ---
if (jsonData?.type === 'tool_call' && jsonData.tool_call) {
console.log('[PAGE] Received tool_call update:', jsonData.tool_call);
currentLiveToolCall = {
const currentLiveToolCall: ToolCallData = {
id: jsonData.tool_call.id,
name: jsonData.tool_call.function.name,
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
setCurrentPairIndex(null); // Live data means not viewing a historical pair
setSidePanelContent(currentLiveToolCall); // Update side panel
// Add to stream content so it's visible
setStreamContent(prev =>
prev + `\nCalling tool: ${currentLiveToolCall.name}\n`
);
if (!isSidePanelOpen) {
// Optionally auto-open side panel? Maybe only if user hasn't closed it recently.
// setIsSidePanelOpen(true);
}
} else if (jsonData?.type === 'tool_result') {
// 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
return;
}
// --- End Side Panel Update Logic ---
// If we reach here and have JSON data but it's not a recognized type,
// log it for debugging purposes
console.log('[PAGE] Unhandled message type:', jsonData?.type);
} catch (e) {
console.warn('[PAGE] Failed to parse message:', 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);
}
// Continue with normal message processing...
// ... rest of the onMessage handler ...
} catch (error) {
console.error('[PAGE] Error processing message:', error);
toast.error('Failed to process agent response');
@ -551,6 +624,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
if (threadData && threadData.project_id) {
const projectData = await getProject(threadData.project_id);
if (isMounted && projectData && projectData.sandbox) {
// Store the full project object
setProject(projectData);
// Extract the sandbox ID correctly
setSandboxId(typeof projectData.sandbox === 'string' ? projectData.sandbox : projectData.sandbox.id);
@ -1027,6 +1103,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
currentIndex={currentPairIndex}
totalPairs={allHistoricalPairs.length}
onNavigate={handleSidePanelNavigate}
project={project}
/>
</div>
);
@ -1060,6 +1137,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
currentIndex={currentPairIndex}
totalPairs={allHistoricalPairs.length}
onNavigate={handleSidePanelNavigate}
project={project}
/>
</div>
);
@ -1387,6 +1465,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}
@ -1413,6 +1500,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
currentIndex={currentPairIndex}
totalPairs={allHistoricalPairs.length}
onNavigate={handleSidePanelNavigate}
project={project}
/>
{sandboxId && (

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

File diff suppressed because it is too large Load Diff