This commit is contained in:
marko-kraemer 2025-04-16 18:04:54 +01:00
parent 2467f68efb
commit 6240f4bcc3
6 changed files with 714 additions and 99 deletions

View File

@ -23,6 +23,7 @@
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0",
@ -3063,6 +3064,39 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.4.tgz",
"integrity": "sha512-Vr/OgNejNJPAghIhjS7Mf/2F/EXGDT0qgtiHf2BHz71+KqgN+jndFLKq5xAB9JOGejGzejfJLIvT04Do+yzhcg==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.0.3",
"@radix-ui/react-use-controllable-state": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",

View File

@ -24,6 +24,7 @@
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0",

View File

@ -1,16 +1,16 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { ArrowDown, File } from 'lucide-react';
import { ArrowDown, File, Terminal, ExternalLink, SkipBack, SkipForward } from 'lucide-react';
import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread } from '@/lib/api';
import { toast } from 'sonner';
import { Skeleton } from "@/components/ui/skeleton";
import { ChatInput } from '@/components/thread/chat-input';
import { FileViewerModal } from '@/components/thread/file-viewer-modal';
import { SiteHeader } from "@/components/thread/thread-site-header"
import { ToolCallSidePanel } from "@/components/thread/tool-call-side-panel";
import { ToolCallSidePanel, SidePanelContent, ToolCallData } from "@/components/thread/tool-call-side-panel";
import { useSidebar } from "@/components/ui/sidebar";
// Define a type for the params to make React.use() work properly
@ -35,6 +35,92 @@ interface ApiMessage {
};
}
// Define structure for grouped tool call/result sequences
type ToolSequence = {
type: 'tool_sequence';
items: ApiMessage[];
};
// Type for items that will be rendered
type RenderItem = ApiMessage | ToolSequence;
// Type guard to check if an item is a ToolSequence
function isToolSequence(item: RenderItem): item is ToolSequence {
return (item as ToolSequence).type === 'tool_sequence';
}
// Function to group consecutive assistant tool call / user tool result pairs
function groupMessages(messages: ApiMessage[]): RenderItem[] {
const grouped: RenderItem[] = [];
let i = 0;
while (i < messages.length) {
const currentMsg = messages[i];
const nextMsg = i + 1 < messages.length ? messages[i + 1] : null;
let currentSequence: ApiMessage[] = [];
// Check if current message is the start of a potential sequence
if (currentMsg.role === 'assistant') {
// Regex to find the first XML-like tag: <tagname ...> or <tagname>
const toolTagMatch = currentMsg.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/);
if (toolTagMatch && nextMsg && nextMsg.role === 'user') {
const expectedTag = toolTagMatch[1];
// Regex to check for <tool_result><tagname>...</tagname></tool_result>
// Using 's' flag for dotall to handle multiline content within tags -> Replaced with [\s\S] to avoid ES target issues
const toolResultRegex = new RegExp(`^<tool_result>\\s*<(${expectedTag})(?:\\s+[^>]*)?>[\\s\\S]*?</\\1>\\s*</tool_result>`);
if (nextMsg.content?.match(toolResultRegex)) {
// Found a pair, start a sequence
currentSequence.push(currentMsg);
currentSequence.push(nextMsg);
i += 2; // Move past this pair
// Check for continuation
while (i < messages.length) {
const potentialAssistant = messages[i];
const potentialUser = i + 1 < messages.length ? messages[i + 1] : null;
if (potentialAssistant.role === 'assistant') {
const nextToolTagMatch = potentialAssistant.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/);
if (nextToolTagMatch && potentialUser && potentialUser.role === 'user') {
const nextExpectedTag = nextToolTagMatch[1];
// Replaced dotall 's' flag with [\s\S]
const nextToolResultRegex = new RegExp(`^<tool_result>\\s*<(${nextExpectedTag})(?:\\s+[^>]*)?>[\\s\\S]*?</\\1>\\s*</tool_result>`);
if (potentialUser.content?.match(nextToolResultRegex)) {
// Sequence continues
currentSequence.push(potentialAssistant);
currentSequence.push(potentialUser);
i += 2; // Move past the added pair
} else {
// Assistant/User message, but not a matching tool result pair - break sequence
break;
}
} else {
// Assistant message without tool tag, or no following user message - break sequence
break;
}
} else {
// Not an assistant message - break sequence
break;
}
}
// Add the completed sequence to grouped results
grouped.push({ type: 'tool_sequence', items: currentSequence });
continue; // Continue the outer loop from the new 'i'
}
}
}
// If no sequence was started or continued, add the current message normally
if (currentSequence.length === 0) {
grouped.push(currentMsg);
i++; // Move to the next message
}
}
return grouped;
}
export default function ThreadPage({ params }: { params: Promise<ThreadParams> }) {
const unwrappedParams = React.use(params);
const threadId = unwrappedParams.threadId;
@ -49,7 +135,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
const [agentStatus, setAgentStatus] = useState<'idle' | 'running' | 'paused'>('idle');
const [isStreaming, setIsStreaming] = useState(false);
const [streamContent, setStreamContent] = useState('');
const [toolCallData, setToolCallData] = useState<{id?: string, name?: string, arguments?: string, index?: number} | null>(null);
const [toolCallData, setToolCallData] = useState<ToolCallData | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
const [projectName, setProjectName] = useState<string>('Project');
@ -69,6 +155,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
const [fileViewerOpen, setFileViewerOpen] = useState(false);
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
const initialLayoutAppliedRef = useRef(false);
const [sidePanelContent, setSidePanelContent] = useState<SidePanelContent | null>(null);
const [allHistoricalPairs, setAllHistoricalPairs] = useState<{ assistantCall: ApiMessage, userResult: ApiMessage }[]>([]);
const [currentPairIndex, setCurrentPairIndex] = useState<number | null>(null);
// Access the state and controls for the main SidebarLeft
const { state: leftSidebarState, setOpen: setLeftSidebarOpen } = useSidebar();
@ -123,6 +212,25 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
};
}, [toggleSidePanel]); // Dependency: the toggle function
// Preprocess messages to group tool call/result sequences and extract historical pairs
const processedMessages = useMemo(() => {
const grouped = groupMessages(messages);
const historicalPairs: { assistantCall: ApiMessage, userResult: ApiMessage }[] = [];
grouped.forEach(item => {
if (isToolSequence(item)) {
for (let i = 0; i < item.items.length; i += 2) {
if (item.items[i+1]) {
historicalPairs.push({ assistantCall: item.items[i], userResult: item.items[i+1] });
}
}
}
});
// Update the state containing all historical pairs
// Use a functional update if necessary to avoid stale state issues, though likely fine here
setAllHistoricalPairs(historicalPairs);
return grouped;
}, [messages]);
const handleStreamAgent = useCallback(async (runId: string) => {
// Prevent multiple streams for the same run
if (streamCleanupRef.current && agentRunId === runId) {
@ -139,6 +247,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
setIsStreaming(true);
setStreamContent('');
setToolCallData(null); // Clear old live tool call data
setSidePanelContent(null); // Clear side panel when starting new stream
setCurrentPairIndex(null); // Reset index when starting new stream
console.log(`[PAGE] Setting up stream for agent run ${runId}`);
@ -171,6 +282,8 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
};
} | null = null;
let currentLiveToolCall: ToolCallData | null = null;
try {
jsonData = JSON.parse(processedData);
@ -211,6 +324,32 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
setAgentRunId(null);
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 = {
id: jsonData.tool_call.id,
name: jsonData.tool_call.function.name,
arguments: jsonData.tool_call.function.arguments,
index: jsonData.tool_call.index,
};
setToolCallData(currentLiveToolCall); // Keep for stream content rendering
setCurrentPairIndex(null); // Live data means not viewing a historical pair
setSidePanelContent(currentLiveToolCall); // Update side panel
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
}
// --- End Side Panel Update Logic ---
} catch (e) {
console.warn('[PAGE] Failed to parse message:', e);
}
@ -235,6 +374,8 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
setAgentRunId(null);
setStreamContent(''); // Clear any partial content
setToolCallData(null); // Clear tool call data on error
setSidePanelContent(null); // Clear side panel on error
setCurrentPairIndex(null);
},
onClose: async () => {
console.log('[PAGE] Stream connection closed');
@ -245,6 +386,8 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
// Reset tool call data
setToolCallData(null);
setSidePanelContent(null); // Clear side panel on close
setCurrentPairIndex(null);
try {
// Only check status if we still have an agent run ID
@ -329,6 +472,12 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
if (!messagesLoadedRef.current) {
const messagesData = await getMessages(threadId);
if (isMounted) {
// Log the parsed messages structure
console.log('[PAGE] Loaded messages structure:', {
count: messagesData.length,
fullMessages: messagesData
});
setMessages(messagesData as ApiMessage[]);
messagesLoadedRef.current = true;
@ -697,6 +846,60 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
setFileViewerOpen(true);
};
// Click handler for historical tool previews
const handleHistoricalToolClick = (pair: { assistantCall: ApiMessage, userResult: ApiMessage }) => {
// Extract tool names for display in the side panel
const userToolName = pair.userResult.content?.match(/<tool_result>\s*<([a-zA-Z\-_]+)/)?.[1] || 'Tool';
// Extract only the XML part and the tool name from the assistant message
const assistantContent = pair.assistantCall.content || '';
// Find the first opening tag and the corresponding closing tag
const xmlRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>[\s\S]*?<\/\1>/;
const xmlMatch = assistantContent.match(xmlRegex);
const toolCallXml = xmlMatch ? xmlMatch[0] : '[Could not extract XML tag]';
const assistantToolName = xmlMatch ? xmlMatch[1] : 'Tool'; // Extract name from the matched tag
const userResultContent = pair.userResult.content?.match(/<tool_result>([\s\S]*)<\/tool_result>/)?.[1].trim() || '[Could not parse result]';
setSidePanelContent({
type: 'historical',
assistantCall: { name: assistantToolName, content: toolCallXml },
userResult: { name: userToolName, content: userResultContent }
});
// Find and set the index of the clicked pair
const pairIndex = allHistoricalPairs.findIndex(p =>
p.assistantCall.content === pair.assistantCall.content &&
p.userResult.content === pair.userResult.content
// Note: This comparison might be fragile if messages aren't unique.
// A unique ID per message would be better.
);
setCurrentPairIndex(pairIndex !== -1 ? pairIndex : null);
setIsSidePanelOpen(true);
};
// Handler for navigation within the side panel
const handleSidePanelNavigate = (newIndex: number) => {
if (newIndex >= 0 && newIndex < allHistoricalPairs.length) {
const pair = allHistoricalPairs[newIndex];
setCurrentPairIndex(newIndex);
// Re-extract data for the side panel (similar to handleHistoricalToolClick)
const assistantContent = pair.assistantCall.content || '';
const xmlRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>[\s\S]*?<\/\1>/;
const xmlMatch = assistantContent.match(xmlRegex);
const toolCallXml = xmlMatch ? xmlMatch[0] : '[Could not extract XML tag]';
const assistantToolName = xmlMatch ? xmlMatch[1] : 'Tool';
const userToolName = pair.userResult.content?.match(/<tool_result>\s*<([a-zA-Z\-_]+)/)?.[1] || 'Tool';
const userResultContent = pair.userResult.content?.match(/<tool_result>([\s\S]*)<\/tool_result>/)?.[1].trim() || '[Could not parse result]';
setSidePanelContent({
type: 'historical',
assistantCall: { name: assistantToolName, content: toolCallXml },
userResult: { name: userToolName, content: userResultContent }
});
}
};
// Only show a full-screen loader on the very first load
if (isLoading && !initialLoadCompleted.current) {
return (
@ -739,8 +942,11 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
</div>
<ToolCallSidePanel
isOpen={isSidePanelOpen}
onClose={() => setIsSidePanelOpen(false)}
toolCallData={toolCallData}
onClose={() => { setIsSidePanelOpen(false); setSidePanelContent(null); setCurrentPairIndex(null); }}
content={sidePanelContent}
currentIndex={currentPairIndex}
totalPairs={allHistoricalPairs.length}
onNavigate={handleSidePanelNavigate}
/>
</div>
);
@ -768,8 +974,11 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
</div>
<ToolCallSidePanel
isOpen={isSidePanelOpen}
onClose={() => setIsSidePanelOpen(false)}
toolCallData={toolCallData}
onClose={() => { setIsSidePanelOpen(false); setSidePanelContent(null); setCurrentPairIndex(null); }}
content={sidePanelContent}
currentIndex={currentPairIndex}
totalPairs={allHistoricalPairs.length}
onNavigate={handleSidePanelNavigate}
/>
</div>
);
@ -801,52 +1010,158 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
</div>
) : (
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={index}
ref={index === messages.length - 1 && message.role === 'assistant' ? latestMessageRef : null}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-lg px-4 py-3 text-sm ${
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
<div className="whitespace-pre-wrap break-words">
{message.type === 'tool_call' ? (
<div className="font-mono text-xs">
<div className="flex items-center gap-2 mb-1 text-muted-foreground">
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-primary/10">
<div className="h-2 w-2 rounded-full bg-primary"></div>
{/* Map over processed messages */}
{processedMessages.map((item, index) => {
// ---- Rendering Logic for Tool Sequences ----
if (isToolSequence(item)) {
// 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
pairs.push({ assistantCall: item.items[i], userResult: item.items[i+1] });
}
}
return (
<div
key={`seq-${index}`}
ref={index === processedMessages.length - 1 ? latestMessageRef : null}
className="border-l-2 border-blue-500 pl-4 ml-2 my-2 relative group"
>
{/* "Kortix Suna" label */}
<span className="absolute -left-px top-1 -translate-x-full bg-blue-100 text-blue-700 text-xs font-semibold px-1.5 py-0.5 rounded z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
Kortix Suna
</span>
<div className="space-y-1"> {/* Tighter spacing for previews */}
{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) : '';
return (
<div
key={`${index}-pair-${pairIndex}`}
// Render assistant's natural language + tool button + user result button
className="flex flex-col items-start space-y-1" // Arrange vertically
>
{/* Render pre-XML content if it exists */}
{preContent.trim() && (
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-muted">
<div className="whitespace-pre-wrap break-words">
{preContent.trim()}
</div>
</div>
)}
{/* Render the clickable preview button for the tool */}
{xmlMatch && (
<Button
variant="outline"
size="sm"
className="h-auto py-1.5 px-3 text-xs w-full sm:w-auto justify-start bg-muted hover:bg-muted/90 border-muted-foreground/20"
onClick={() => handleHistoricalToolClick(pair)}
>
<Terminal className="h-3 w-3 mr-1.5 flex-shrink-0" />
<span className="font-mono truncate mr-2">{toolName}</span>
<span className="ml-auto text-muted-foreground/70 flex items-center">
View Details <ExternalLink className="h-3 w-3 ml-1" />
</span>
</Button>
)}
{/* Render post-XML content if it exists (less common) */}
{postContent.trim() && (
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-muted">
<div className="whitespace-pre-wrap break-words">
{postContent.trim()}
</div>
</div>
)}
{/* Render user result button (or preview) - Currently simplified */}
{/* You might want a similar button style for consistency */}
<div className="flex justify-end w-full">
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-green-100 text-green-900 font-mono text-xs">
Tool Result: {pair.userResult.content?.match(/<tool_result>\s*<([a-zA-Z\-_]+)/)?.[1] || 'Result'} (Click button above)
{/* Alternative: Make this a button too? */}
{/* <Button variant="outline" size="sm" ... onClick={() => handleHistoricalToolClick(pair)}> ... </Button> */}
</div>
</div>
</div>
<span>Tool: {message.name}</span>
</div>
<div className="mt-1 p-3 bg-secondary/20 rounded-md overflow-x-auto">
{message.arguments}
</div>
</div>
) : message.role === 'tool' ? (
<div className="font-mono text-xs">
<div className="flex items-center gap-2 mb-1 text-muted-foreground">
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-success/10">
<div className="h-2 w-2 rounded-full bg-success"></div>
</div>
<span>Tool Result: {message.name}</span>
</div>
<div className="mt-1 p-3 bg-success/5 rounded-md">
{message.content}
</div>
</div>
) : (
message.content
)}
);
})}
</div>
</div>
</div>
</div>
))}
);
}
// ---- 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 (
<div
key={index} // Use the index from processedMessages
ref={index === processedMessages.length - 1 && message.role === 'assistant' ? latestMessageRef : null} // Ref on the regular message div if it's last
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-lg px-4 py-3 text-sm ${
message.role === 'user'
? 'bg-primary text-primary-foreground'
: message.role === 'tool' // Style standard 'tool' role differently?
? 'bg-purple-100' // Example: Different background for standard tool results
: 'bg-muted' // Default assistant or other roles
}`}
>
<div className="whitespace-pre-wrap break-words">
{/* 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
<div className="font-mono text-xs">
<div className="flex items-center gap-2 mb-1 text-muted-foreground">
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-primary/10">
<div className="h-2 w-2 rounded-full bg-primary"></div> {/* Maybe pulse if active? */}
</div>
<span>Tool Call: {message.tool_call.function.name}</span>
</div>
<div className="mt-1 p-3 bg-secondary/20 rounded-md overflow-x-auto">
{message.tool_call.function.arguments}
</div>
</div>
) : message.role === 'tool' ? (
// Existing rendering for standard 'tool' role messages
<div className="font-mono text-xs">
<div className="flex items-center gap-2 mb-1 text-muted-foreground">
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-success/10">
<div className="h-2 w-2 rounded-full bg-success"></div>
</div>
<span>Tool Result: {message.name || 'Unknown Tool'}</span>
</div>
<div className="mt-1 p-3 bg-success/5 rounded-md">
{/* Render content safely, handle potential objects */}
{typeof message.content === 'string' ? message.content : JSON.stringify(message.content)}
</div>
</div>
) : (
// Default rendering for user messages or plain assistant messages
message.content
)}
</div>
</div>
</div>
);
}
})}
{/* ---- End of Message Mapping ---- */}
{streamContent && (
<div
ref={latestMessageRef}
@ -946,10 +1261,13 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
</div>
</div>
<ToolCallSidePanel
isOpen={isSidePanelOpen}
onClose={() => setIsSidePanelOpen(false)}
toolCallData={toolCallData}
<ToolCallSidePanel
isOpen={isSidePanelOpen}
onClose={() => { setIsSidePanelOpen(false); setSidePanelContent(null); setCurrentPairIndex(null); }}
content={sidePanelContent}
currentIndex={currentPairIndex}
totalPairs={allHistoricalPairs.length}
onNavigate={handleSidePanelNavigate}
/>
{sandboxId && (

View File

@ -1,7 +1,7 @@
"use client"
import { Button } from "@/components/ui/button"
import { Copy, File, PanelRightOpen } from "lucide-react"
import { Copy, File, PanelRightOpen, Check, X } from "lucide-react"
import { usePathname } from "next/navigation"
import { toast } from "sonner"
import {
@ -10,16 +10,31 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useState, useRef, KeyboardEvent } from "react"
import { Input } from "@/components/ui/input"
import { updateProject } from "@/lib/api"
interface ThreadSiteHeaderProps {
threadId: string
projectId: string
projectName: string
onViewFiles: () => void
onToggleSidePanel: () => void
onProjectRenamed?: (newName: string) => void
}
export function SiteHeader({ threadId, projectName, onViewFiles, onToggleSidePanel }: ThreadSiteHeaderProps) {
export function SiteHeader({
threadId,
projectId,
projectName,
onViewFiles,
onToggleSidePanel,
onProjectRenamed
}: ThreadSiteHeaderProps) {
const pathname = usePathname()
const [isEditing, setIsEditing] = useState(false)
const [editName, setEditName] = useState(projectName)
const inputRef = useRef<HTMLInputElement>(null)
const copyCurrentUrl = () => {
const url = window.location.origin + pathname
@ -27,12 +42,90 @@ export function SiteHeader({ threadId, projectName, onViewFiles, onToggleSidePan
toast.success("URL copied to clipboard")
}
const startEditing = () => {
setEditName(projectName)
setIsEditing(true)
setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 0)
}
const cancelEditing = () => {
setIsEditing(false)
setEditName(projectName)
}
const saveNewName = async () => {
if (editName.trim() === "") {
setEditName(projectName)
setIsEditing(false)
return
}
if (editName !== projectName) {
try {
await updateProject(projectId, { name: editName })
onProjectRenamed?.(editName)
toast.success("Project renamed successfully")
} catch (error) {
console.error("Failed to rename project:", error)
toast.error("Failed to rename project")
setEditName(projectName)
}
}
setIsEditing(false)
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
saveNewName()
} else if (e.key === "Escape") {
cancelEditing()
}
}
return (
<header className="bg-background sticky top-0 flex h-14 shrink-0 items-center gap-2 z-20 border-b w-full">
<div className="flex flex-1 items-center gap-2 px-3">
<div className="text-sm font-medium tracking-wide uppercase text-muted-foreground">
{projectName}
</div>
{isEditing ? (
<div className="flex items-center gap-1">
<Input
ref={inputRef}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={saveNewName}
className="h-7 w-auto min-w-[180px] text-sm font-medium"
maxLength={50}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={saveNewName}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={cancelEditing}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div
className="text-sm font-medium tracking-wide uppercase text-muted-foreground hover:text-foreground cursor-pointer flex items-center"
onClick={startEditing}
title="Click to rename project"
>
{projectName}
</div>
)}
</div>
<div className="flex items-center gap-1 pr-4">

View File

@ -1,23 +1,50 @@
import { Button } from "@/components/ui/button";
import { X, Package, Info } from "lucide-react";
import { X, Package, Info, Terminal, CheckCircle, SkipBack, SkipForward } from "lucide-react";
import { Slider } from "@/components/ui/slider";
// Define the structure for tool call data based on page.tsx state
interface ToolCallData {
// Define the structure for LIVE tool call data (from streaming)
export interface ToolCallData {
id?: string;
name?: string;
arguments?: string;
index?: number;
}
// Define the structure for HISTORICAL tool call pairs
export interface HistoricalToolPair {
type: 'historical';
assistantCall: { content?: string; name?: string }; // Include name from parsed content if needed
userResult: { content?: string; name?: string };
}
// Union type for side panel content
export type SidePanelContent = ToolCallData | HistoricalToolPair;
// Type guard to check if content is a HistoricalToolPair
function isHistoricalPair(content: SidePanelContent | null): content is HistoricalToolPair {
return !!content && (content as HistoricalToolPair).type === 'historical';
}
interface ToolCallSidePanelProps {
isOpen: boolean;
onClose: () => void;
toolCallData: ToolCallData | null;
// Add other props later if needed, e.g., history of tool calls
content: SidePanelContent | null;
currentIndex: number | null;
totalPairs: number;
onNavigate: (newIndex: number) => void;
}
export function ToolCallSidePanel({ isOpen, onClose, toolCallData }: ToolCallSidePanelProps) {
export function ToolCallSidePanel({
isOpen,
onClose,
content,
currentIndex,
totalPairs,
onNavigate
}: ToolCallSidePanelProps) {
// Updated styling for full-height panel that sits alongside the header
const showNavigation = isHistoricalPair(content) && totalPairs > 1 && currentIndex !== null;
return (
<div
className={`
@ -41,41 +68,120 @@ export function ToolCallSidePanel({ isOpen, onClose, toolCallData }: ToolCallSid
</Button>
</div>
<div className="flex-1 p-4 overflow-y-auto">
{toolCallData ? (
<div className="space-y-4">
<div>
<h3 className="font-semibold text-sm mb-1 text-muted-foreground">Tool Name:</h3>
<p className="text-sm font-mono bg-muted p-2 rounded break-all">{toolCallData.name || 'N/A'}</p>
{/* Navigation Controls - Conditionally Rendered */}
{showNavigation && (
<div className="mb-6 pb-4 border-b">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
Step {currentIndex + 1} of {totalPairs}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => onNavigate(currentIndex - 1)}
disabled={currentIndex === 0}
className="h-7 w-7"
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onNavigate(currentIndex + 1)}
disabled={currentIndex === totalPairs - 1}
className="h-7 w-7"
>
<SkipForward className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h3 className="font-semibold text-sm mb-1 text-muted-foreground">Arguments:</h3>
<pre className="text-xs font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-all">
{toolCallData.arguments
? (() => {
try {
// Attempt to parse and pretty-print JSON arguments
return JSON.stringify(JSON.parse(toolCallData.arguments), null, 2);
} catch (e) {
// Fallback for non-JSON arguments
return toolCallData.arguments;
}
})()
: 'No arguments'}
</pre>
</div>
{/* Placeholder for future details */}
{/*
<div>
<h3 className="font-semibold text-sm mb-1 text-muted-foreground">Status:</h3>
<p className="text-sm">Running / Completed / Error</p>
</div>
<div>
<h3 className="font-semibold text-sm mb-1 text-muted-foreground">Result:</h3>
<pre className="text-xs font-mono bg-muted p-2 rounded overflow-x-auto">Tool output...</pre>
</div>
*/}
<Slider
value={[currentIndex]} // Slider value is an array
max={totalPairs - 1}
step={1}
onValueChange={(value) => onNavigate(value[0])} // onValueChange gives an array
/>
</div>
)}
{content ? (
// ---- Render Historical Pair ----
'type' in content && content.type === 'historical' ? (
<div className="space-y-6"> {/* Increased spacing for sections */}
<div>
<h3 className="font-semibold text-sm mb-2 flex items-center gap-1.5 text-muted-foreground">
<Terminal className="h-4 w-4" />
Tool Call (Assistant)
</h3>
<div className="space-y-2">
<div>
<h4 className="text-xs font-medium text-muted-foreground/80 mb-1">Name:</h4>
<p className="text-sm font-mono bg-muted p-2 rounded break-all">{content.assistantCall.name || 'N/A'}</p>
</div>
<div>
<h4 className="text-xs font-medium text-muted-foreground/80 mb-1">Content/Arguments:</h4>
<pre className="text-xs font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-all">
{content.assistantCall.content || 'No content'}
</pre>
</div>
</div>
</div>
<div>
<h3 className="font-semibold text-sm mb-2 flex items-center gap-1.5 text-muted-foreground">
<CheckCircle className="h-4 w-4 text-green-600" />
Tool Result (User)
</h3>
<div className="space-y-2">
<div>
<h4 className="text-xs font-medium text-muted-foreground/80 mb-1">Name:</h4>
<p className="text-sm font-mono bg-muted p-2 rounded break-all">{content.userResult.name || 'N/A'}</p>
</div>
<div>
<h4 className="text-xs font-medium text-muted-foreground/80 mb-1">Content/Output:</h4>
<pre className="text-xs font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-all">
{content.userResult.content || 'No content'}
</pre>
</div>
</div>
</div>
</div>
) :
// ---- Render Live Tool Call Data ----
'name' in content ? ( // Check if it's ToolCallData
<div className="space-y-4">
<div>
<h3 className="font-semibold text-sm mb-1 text-muted-foreground">Tool Name (Live):</h3>
<p className="text-sm font-mono bg-muted p-2 rounded break-all">{content.name || 'N/A'}</p>
</div>
<div>
<h3 className="font-semibold text-sm mb-1 text-muted-foreground">Arguments (Live):</h3>
<pre className="text-xs font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-all">
{content.arguments
? (() => {
try {
// Attempt to parse and pretty-print JSON arguments
return JSON.stringify(JSON.parse(content.arguments || ''), null, 2);
} catch (e) {
// Fallback for non-JSON arguments
return content.arguments;
}
})()
: 'No arguments'}
</pre>
</div>
{/* Add optional ID/Index if needed */}
{content.id && (
<div>
<h3 className="font-semibold text-sm mb-1 text-muted-foreground">Tool Call ID:</h3>
<p className="text-xs font-mono bg-muted p-1 rounded break-all">{content.id}</p>
</div>
)}
</div>
) : null // Should not happen if content is not null, but handles edge case
) : (
// ---- Render Empty State ----
<div className="text-center text-muted-foreground text-sm mt-8 flex flex-col items-center gap-2">
<Package className="h-10 w-10 mb-2 text-muted-foreground/50" />
<p className="font-medium">No Tool Call Active</p>

View File

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }