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
|
||||
} 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);
|
||||
|
@ -416,10 +423,73 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
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 ---
|
||||
} catch (e) {
|
||||
console.warn('[PAGE] Failed to parse message:', e);
|
||||
}
|
||||
|
||||
// Continue with normal message processing...
|
||||
// ... rest of the onMessage handler ...
|
||||
// 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) {
|
||||
// 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) {
|
||||
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 && (
|
||||
|
|
|
@ -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