From 0632a4aa4f41405c53162a9eb81ee76ff72c1857 Mon Sep 17 00:00:00 2001 From: marko-kraemer Date: Wed, 16 Apr 2025 18:27:18 +0100 Subject: [PATCH] thread renaming, thread page wip --- .../app/dashboard/agents/[threadId]/page.tsx | 345 ++++++++++-------- .../dashboard/sidebar/nav-agents.tsx | 269 +++++++++----- .../components/thread/thread-site-header.tsx | 17 +- frontend/src/lib/api.ts | 28 +- 4 files changed, 400 insertions(+), 259 deletions(-) diff --git a/frontend/src/app/dashboard/agents/[threadId]/page.tsx b/frontend/src/app/dashboard/agents/[threadId]/page.tsx index 2038362e..1e9c2c4f 100644 --- a/frontend/src/app/dashboard/agents/[threadId]/page.tsx +++ b/frontend/src/app/dashboard/agents/[threadId]/page.tsx @@ -1,10 +1,11 @@ 'use client'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; -import { ArrowDown, File, Terminal, ExternalLink, SkipBack, SkipForward } from 'lucide-react'; -import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread } from '@/lib/api'; +import { ArrowDown, File, Terminal, ExternalLink, User, CheckCircle, CircleDashed } from 'lucide-react'; +import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject } from '@/lib/api'; import { toast } from 'sonner'; import { Skeleton } from "@/components/ui/skeleton"; import { ChatInput } from '@/components/thread/chat-input'; @@ -53,6 +54,8 @@ function isToolSequence(item: RenderItem): item is ToolSequence { function groupMessages(messages: ApiMessage[]): RenderItem[] { const grouped: RenderItem[] = []; let i = 0; + const excludedTags = ['ask', 'inform']; // Tags to exclude from grouping + while (i < messages.length) { const currentMsg = messages[i]; const nextMsg = i + 1 < messages.length ? messages[i + 1] : null; @@ -65,6 +68,14 @@ function groupMessages(messages: ApiMessage[]): RenderItem[] { const toolTagMatch = currentMsg.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/); if (toolTagMatch && nextMsg && nextMsg.role === 'user') { const expectedTag = toolTagMatch[1]; + // *** Check if the tag is excluded *** + if (excludedTags.includes(expectedTag)) { + // If excluded, treat as a normal message and break potential sequence start + grouped.push(currentMsg); + i++; + continue; + } + // Regex to check for ... // Using 's' flag for dotall to handle multiline content within tags -> Replaced with [\s\S] to avoid ES target issues const toolResultRegex = new RegExp(`^\\s*<(${expectedTag})(?:\\s+[^>]*)?>[\\s\\S]*?\\s*`); @@ -84,6 +95,12 @@ function groupMessages(messages: ApiMessage[]): RenderItem[] { const nextToolTagMatch = potentialAssistant.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/); if (nextToolTagMatch && potentialUser && potentialUser.role === 'user') { const nextExpectedTag = nextToolTagMatch[1]; + // *** Check if the continuation tag is excluded *** + if (excludedTags.includes(nextExpectedTag)) { + // If excluded, break the sequence + break; + } + // Replaced dotall 's' flag with [\s\S] const nextToolResultRegex = new RegExp(`^\\s*<(${nextExpectedTag})(?:\\s+[^>]*)?>[\\s\\S]*?\\s*`); @@ -167,6 +184,11 @@ export default function ThreadPage({ params }: { params: Promise } setIsSidePanelOpen(prevIsOpen => !prevIsOpen); }, []); + // Function to handle project renaming from SiteHeader + const handleProjectRenamed = useCallback((newName: string) => { + setProjectName(newName); + }, []); + // Effect to enforce exclusivity: Close left sidebar if right panel opens useEffect(() => { if (isSidePanelOpen && leftSidebarState !== 'collapsed') { @@ -712,18 +734,6 @@ export default function ThreadPage({ params }: { params: Promise } return () => window.removeEventListener('resize', adjustHeight); }, [newMessage]); - // // Handle keyboard shortcuts - // const handleKeyDown = (e: React.KeyboardEvent) => { - // // Send on Enter (without Shift) - // if (e.key === 'Enter' && !e.shiftKey) { - // e.preventDefault(); - - // if (newMessage.trim() && !isSending && agentStatus !== 'running') { - // handleSubmitMessage(newMessage); - // } - // } - // }; - // Check if user has scrolled up from bottom const handleScroll = () => { if (!messagesContainerRef.current) return; @@ -908,6 +918,7 @@ export default function ThreadPage({ params }: { params: Promise } setFileViewerOpen(true)} onToggleSidePanel={toggleSidePanel} /> @@ -959,6 +970,7 @@ export default function ThreadPage({ params }: { params: Promise } setFileViewerOpen(true)} onToggleSidePanel={toggleSidePanel} /> @@ -990,8 +1002,10 @@ export default function ThreadPage({ params }: { params: Promise } setFileViewerOpen(true)} onToggleSidePanel={toggleSidePanel} + onProjectRenamed={handleProjectRenamed} />
@@ -1009,7 +1023,7 @@ export default function ThreadPage({ params }: { params: Promise }
) : ( -
+
{/* Map over processed messages */} {processedMessages.map((item, index) => { // ---- Rendering Logic for Tool Sequences ---- @@ -1017,7 +1031,7 @@ export default function ThreadPage({ params }: { params: Promise } // Group sequence items into pairs of [assistant, user] const pairs: { assistantCall: ApiMessage, userResult: ApiMessage }[] = []; for (let i = 0; i < item.items.length; i += 2) { - if (item.items[i+1]) { // Ensure pair exists + if (item.items[i+1]) { pairs.push({ assistantCall: item.items[i], userResult: item.items[i+1] }); } } @@ -1026,69 +1040,84 @@ export default function ThreadPage({ params }: { params: Promise }
- {/* "Kortix Suna" label */} - + {/* Left border for the sequence */} + + + {/* Kortix Suna Label (Hover) */} + Kortix Suna -
{/* Tighter spacing for previews */} + + {/* Render Avatar & Name ONCE for the sequence */} +
{/* Position avatar centered on the line */} +
+ Suna Logo +
+
+
{/* Adjust margin to align name */} + Suna +
+ + {/* Container for the pairs within the sequence */} +
{pairs.map((pair, pairIndex) => { // Parse assistant message content const assistantContent = pair.assistantCall.content || ''; const xmlRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>[\s\S]*?<\/\1>/; const xmlMatch = assistantContent.match(xmlRegex); const toolName = xmlMatch ? xmlMatch[1] : 'Tool'; - const preContent = xmlMatch ? assistantContent.substring(0, xmlMatch.index) : assistantContent; - const postContent = xmlMatch ? assistantContent.substring(xmlMatch.index + xmlMatch[0].length) : ''; + const preContent = xmlMatch ? assistantContent.substring(0, xmlMatch.index).trim() : assistantContent.trim(); + const postContent = xmlMatch ? assistantContent.substring(xmlMatch.index + xmlMatch[0].length).trim() : ''; + const userResultName = pair.userResult.content?.match(/\s*<([a-zA-Z\-_]+)/)?.[1] || 'Result'; return ( -
- {/* Render pre-XML content if it exists */} - {preContent.trim() && ( -
-
- {preContent.trim()} +
+ {/* Assistant Content (No Avatar/Name here) */} +
+ {/* Pre-XML Content */} + {preContent && ( +
+
+ {preContent} +
-
- )} + )} - {/* Render the clickable preview button for the tool */} - {xmlMatch && ( - - )} + {/* Tool Call Button */} + {xmlMatch && ( + + )} - {/* Render post-XML content if it exists (less common) */} - {postContent.trim() && ( -
-
- {postContent.trim()} + {/* Post-XML Content (Less Common) */} + {postContent && ( +
+
+ {postContent} +
-
- )} + )} +
- {/* Render user result button (or preview) - Currently simplified */} - {/* You might want a similar button style for consistency */} -
-
- Tool Result: {pair.userResult.content?.match(/\s*<([a-zA-Z\-_]+)/)?.[1] || 'Result'} (Click button above) - {/* Alternative: Make this a button too? */} - {/* */} + {/* User Tool Result Part */} +
+
+ + {userResultName} Result Received + {/* Optional: Add a button to show result details here too? */} + {/* */}
@@ -1101,122 +1130,136 @@ export default function ThreadPage({ params }: { params: Promise } // ---- Rendering Logic for Regular Messages ---- else { const message = item as ApiMessage; // Safe cast now due to type guard - // Skip rendering standard tool role messages if they were part of a sequence handled above - // Note: This check might be redundant if grouping is perfect, but adds safety. // We rely on the existing rendering for *structured* tool calls/results (message.type === 'tool_call', message.role === 'tool') // which are populated differently (likely via streaming updates) than the raw XML content. return (
-
-
- {/* Use existing logic for structured tool calls/results and normal messages */} - {message.type === 'tool_call' && message.tool_call ? ( - // Existing rendering for structured tool_call type -
-
-
-
{/* Maybe pulse if active? */} -
- Tool Call: {message.tool_call.function.name} -
-
- {message.tool_call.function.arguments} + {/* Avatar (User = Right, Assistant/Tool = Left) */} + {message.role === 'user' ? ( + // User bubble comes first in flex-end + <> +
+ {/* User message bubble */} +
+
+ {message.content}
- ) : message.role === 'tool' ? ( - // Existing rendering for standard 'tool' role messages -
-
-
-
-
- Tool Result: {message.name || 'Unknown Tool'} -
-
- {/* Render content safely, handle potential objects */} - {typeof message.content === 'string' ? message.content : JSON.stringify(message.content)} +
+ + ) : ( + // Assistant / Tool bubble on the left + <> + {/* Assistant Avatar */} +
+ Suna Logo +
+ {/* Content Bubble */} +
+ Suna +
+
+ {/* Use existing logic for structured tool calls/results and normal messages */} + {message.type === 'tool_call' && message.tool_call ? ( + // Existing rendering for structured tool_call type +
+
+ + Tool Call: {message.tool_call.function.name} +
+
+ {message.tool_call.function.arguments} +
+
+ ) : message.role === 'tool' ? ( + // Existing rendering for standard 'tool' role messages +
+
+ + Tool Result: {message.name || 'Unknown Tool'} +
+
+ {/* Render content safely, handle potential objects */} + {typeof message.content === 'string' ? message.content : JSON.stringify(message.content)} +
+
+ ) : ( + // Default rendering for plain assistant messages + message.content + )}
- ) : ( - // Default rendering for user messages or plain assistant messages - message.content - )} -
-
+
+ + )}
); } })} {/* ---- End of Message Mapping ---- */} - + {streamContent && (
-
-
- {toolCallData ? ( -
-
-
-
+ {/* Assistant Avatar */} +
+ Suna Logo +
+ {/* Content Bubble */} +
+ Suna +
+
+ {toolCallData ? ( + // Streaming Tool Call +
+
+ + Tool Call: {toolCallData.name} +
+
+ {toolCallData.arguments || ''}
- Tool: {toolCallData.name}
-
- {toolCallData.arguments || ''} -
-
- ) : ( - streamContent - )} - {isStreaming && ( - - - - - )} + ) : ( + // Streaming Text Content + streamContent + )} + {/* Blinking Cursor */} + {isStreaming && ( + + )} +
)} - {agentStatus === 'running' && !streamContent && ( -
-
-
-
-
+ {/* Loading indicator (three dots) */} + {agentStatus === 'running' && !streamContent && !toolCallData && ( +
{/* Assistant style */} +
+ Suna Logo +
+
+ Suna +
+
+
+
+
+
+
)} - -
)}
@@ -1261,8 +1304,8 @@ export default function ThreadPage({ params }: { params: Promise }
- { setIsSidePanelOpen(false); setSidePanelContent(null); setCurrentPairIndex(null); }} content={sidePanelContent} currentIndex={currentPairIndex} diff --git a/frontend/src/components/dashboard/sidebar/nav-agents.tsx b/frontend/src/components/dashboard/sidebar/nav-agents.tsx index 426cae73..0f9ba74b 100644 --- a/frontend/src/components/dashboard/sidebar/nav-agents.tsx +++ b/frontend/src/components/dashboard/sidebar/nav-agents.tsx @@ -6,11 +6,11 @@ import { Link as LinkIcon, MoreHorizontal, Trash2, - StarOff, Plus, MessagesSquare, } from "lucide-react" import { toast } from "sonner" +import { usePathname } from "next/navigation" import { DropdownMenu, @@ -41,65 +41,127 @@ type ProjectResponse = { id: string; project_id?: string; name: string; + updated_at?: string; [key: string]: any; // Allow other properties } +// Agent type with project ID for easier updating +type Agent = { + projectId: string; + threadId: string; + name: string; + url: string; + updatedAt: string; // Store updated_at for consistent sorting +} + export function NavAgents() { const { isMobile, state } = useSidebar() - const [agents, setAgents] = useState<{name: string, url: string}[]>([]) + const [agents, setAgents] = useState([]) const [isLoading, setIsLoading] = useState(true) + const pathname = usePathname() - // Load agents dynamically from the API - useEffect(() => { - async function loadAgents() { - try { - // Get all projects - const projectsData = await getProjects() as ProjectResponse[] - console.log("Projects data:", projectsData) + // Helper to sort agents by updated_at (most recent first) + const sortAgents = (agentsList: Agent[]): Agent[] => { + return [...agentsList].sort((a, b) => { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + }; + + // Function to load agents data + const loadAgents = async (showLoading = true) => { + try { + if (showLoading) { + setIsLoading(true) + } + + // Get all projects + const projectsData = await getProjects() as ProjectResponse[] + + // Get all threads at once + const allThreads = await getThreads() + + // For each project, find its matching threads + const agentsList: Agent[] = [] + for (const project of projectsData) { + // Get the project ID (handle potential different field names) + const projectId = project.id || project.project_id || '' - const agentsList = [] + // Get the updated_at timestamp (default to current time if not available) + const updatedAt = project.updated_at || new Date().toISOString() - // Get all threads at once - const allThreads = await getThreads() - console.log("All threads:", allThreads) + // Match threads that belong to this project + const projectThreads = allThreads.filter(thread => + thread.project_id === projectId + ) - // For each project, find its matching threads - for (const project of projectsData) { - console.log("Processing project:", project) - - // Get the project ID (handle potential different field names) - const projectId = project.id || project.project_id - - // Match threads that belong to this project - const projectThreads = allThreads.filter(thread => - thread.project_id === projectId - ) - - console.log(`Found ${projectThreads.length} threads for project ${project.name}:`, projectThreads) - - if (projectThreads.length > 0) { - // For each thread in this project, create an agent entry - for (const thread of projectThreads) { - agentsList.push({ - name: project.name || 'Unnamed Project', - url: `/dashboard/agents/${thread.thread_id}` - }) - console.log(`Added agent with name: ${project.name} and thread: ${thread.thread_id}`) - } + if (projectThreads.length > 0) { + // For each thread in this project, create an agent entry + for (const thread of projectThreads) { + agentsList.push({ + projectId, + threadId: thread.thread_id, + name: project.name || 'Unnamed Project', + url: `/dashboard/agents/${thread.thread_id}`, + updatedAt: thread.updated_at || updatedAt // Use thread update time if available + }) } } - - setAgents(agentsList) - } catch (err) { - console.error("Error loading agents for sidebar:", err) - } finally { + } + + // Set agents, ensuring consistent sort order + setAgents(sortAgents(agentsList)) + } catch (err) { + console.error("Error loading agents for sidebar:", err) + } finally { + if (showLoading) { setIsLoading(false) } } - - loadAgents() + } + + // Load agents dynamically from the API on initial load + useEffect(() => { + loadAgents(true) }, []) + // Listen for project-updated events to update the sidebar without full reload + useEffect(() => { + const handleProjectUpdate = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail) { + const { projectId, updatedData } = customEvent.detail; + + // Update just the name for the agents with the matching project ID + // Don't update the timestamp here to prevent immediate re-sorting + setAgents(prevAgents => { + const updatedAgents = prevAgents.map(agent => + agent.projectId === projectId + ? { + ...agent, + name: updatedData.name, + // Keep the original updatedAt timestamp locally + } + : agent + ); + + // Return the agents without re-sorting immediately + return updatedAgents; + }); + + // Silently refresh in background to fetch updated timestamp and re-sort + setTimeout(() => loadAgents(false), 1000); + } + } + + // Add event listener + window.addEventListener('project-updated', handleProjectUpdate as EventListener); + + // Cleanup + return () => { + window.removeEventListener('project-updated', handleProjectUpdate as EventListener); + } + }, []); + return (
@@ -132,64 +194,69 @@ export function NavAgents() { ) : agents.length > 0 ? ( // Show all agents <> - {agents.map((item, index) => ( - - {state === "collapsed" ? ( - - - - - - {item.name} - - - - {item.name} - - ) : ( - - - - {item.name} - - - )} - {state !== "collapsed" && ( - - - - - More - - - - { - navigator.clipboard.writeText(window.location.origin + item.url) - toast.success("Link copied to clipboard") - }}> - - Copy Link - - - - - Open in New Tab - - - - - - Delete - - - - )} - - ))} + {agents.map((agent, index) => { + // Check if this agent is currently active + const isActive = pathname.includes(agent.threadId); + + return ( + + {state === "collapsed" ? ( + + + + + + {agent.name} + + + + {agent.name} + + ) : ( + + + + {agent.name} + + + )} + {state !== "collapsed" && ( + + + + + More + + + + { + navigator.clipboard.writeText(window.location.origin + agent.url) + toast.success("Link copied to clipboard") + }}> + + Copy Link + + + + + Open in New Tab + + + + + + Delete + + + + )} + + ); + })} ) : ( // Empty state diff --git a/frontend/src/components/thread/thread-site-header.tsx b/frontend/src/components/thread/thread-site-header.tsx index db3426eb..4bf8960f 100644 --- a/frontend/src/components/thread/thread-site-header.tsx +++ b/frontend/src/components/thread/thread-site-header.tsx @@ -65,12 +65,17 @@ export function SiteHeader({ if (editName !== projectName) { try { - await updateProject(projectId, { name: editName }) - onProjectRenamed?.(editName) - toast.success("Project renamed successfully") + const updatedProject = await updateProject(projectId, { name: editName }) + if (updatedProject) { + onProjectRenamed?.(editName) + toast.success("Project renamed successfully") + } else { + throw new Error("Failed to update project") + } } catch (error) { - console.error("Failed to rename project:", error) - toast.error("Failed to rename project") + const errorMessage = error instanceof Error ? error.message : "Failed to rename project" + console.error("Failed to rename project:", errorMessage) + toast.error(errorMessage) setEditName(projectName) } } @@ -119,7 +124,7 @@ export function SiteHeader({
) : (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 93506ce6..2759e5dc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -231,7 +231,33 @@ export const updateProject = async (projectId: string, data: Partial): .select() .single(); - if (error) throw error; + if (error) { + console.error('Error updating project:', error); + throw error; + } + + if (!updatedData) { + throw new Error('No data returned from update'); + } + + // Invalidate cache after successful update + apiCache.projects.delete(projectId); + apiCache.projects.delete('all'); + + // Dispatch a custom event to notify components about the project change + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('project-updated', { + detail: { + projectId, + updatedData: { + id: updatedData.project_id || updatedData.id, + name: updatedData.name, + description: updatedData.description + } + } + })); + } + return updatedData; };