mirror of https://github.com/kortix-ai/suna.git
wip
This commit is contained in:
parent
2467f68efb
commit
6240f4bcc3
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }
|
Loading…
Reference in New Issue