mirror of https://github.com/kortix-ai/suna.git
Merge pull request #457 from escapade-mckv/main
Payment required dialog on 402 error
This commit is contained in:
commit
191239d178
|
@ -136,6 +136,8 @@ export default function ThreadPage({
|
|||
const agentRunsCheckedRef = useRef(false);
|
||||
const previousAgentStatus = useRef<typeof agentStatus>('idle');
|
||||
|
||||
const [externalNavIndex, setExternalNavIndex] = React.useState<number | undefined>(undefined);
|
||||
|
||||
// Add debug mode state - check for debug=true in URL
|
||||
const [debugMode, setDebugMode] = useState(false);
|
||||
|
||||
|
@ -850,8 +852,11 @@ export default function ThreadPage({
|
|||
console.log(
|
||||
`[PAGE] Found tool call at index ${toolIndex} for assistant message ${clickedAssistantMessageId}`,
|
||||
);
|
||||
setExternalNavIndex(toolIndex);
|
||||
setCurrentToolIndex(toolIndex);
|
||||
setIsSidePanelOpen(true); // Explicitly open the panel
|
||||
|
||||
setTimeout(() => setExternalNavIndex(undefined), 100);
|
||||
} else {
|
||||
console.warn(
|
||||
`[PAGE] Could not find matching tool call in toolCalls array for assistant message ID: ${clickedAssistantMessageId}`,
|
||||
|
@ -1129,6 +1134,7 @@ export default function ThreadPage({
|
|||
}}
|
||||
toolCalls={toolCalls}
|
||||
messages={messages as ApiMessageType[]}
|
||||
externalNavigateToIndex={externalNavIndex}
|
||||
agentStatus={agentStatus}
|
||||
currentIndex={currentToolIndex}
|
||||
onNavigate={handleSidePanelNavigate}
|
||||
|
@ -1236,6 +1242,7 @@ export default function ThreadPage({
|
|||
}}
|
||||
toolCalls={toolCalls}
|
||||
messages={messages as ApiMessageType[]}
|
||||
externalNavigateToIndex={externalNavIndex}
|
||||
agentStatus={agentStatus}
|
||||
currentIndex={currentToolIndex}
|
||||
onNavigate={handleSidePanelNavigate}
|
||||
|
|
|
@ -9,14 +9,8 @@ import {
|
|||
ChatInputHandles,
|
||||
} from '@/components/thread/chat-input/chat-input';
|
||||
import {
|
||||
initiateAgent,
|
||||
createThread,
|
||||
addUserMessage,
|
||||
startAgent,
|
||||
createProject,
|
||||
BillingError,
|
||||
} from '@/lib/api';
|
||||
import { generateThreadName } from '@/lib/actions/threads';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
@ -28,11 +22,11 @@ import {
|
|||
import { useBillingError } from '@/hooks/useBillingError';
|
||||
import { BillingErrorAlert } from '@/components/billing/usage-limit-alert';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
import { isLocalMode, config } from '@/lib/config';
|
||||
import { toast } from 'sonner';
|
||||
import { config } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent';
|
||||
import { ModalProviders } from '@/providers/modal-providers';
|
||||
|
||||
// Constant for localStorage key to ensure consistency
|
||||
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
|
||||
|
||||
function DashboardContent() {
|
||||
|
@ -47,6 +41,7 @@ function DashboardContent() {
|
|||
const { data: accounts } = useAccounts();
|
||||
const personalAccount = accounts?.find((account) => account.personal_account);
|
||||
const chatInputRef = useRef<ChatInputHandles>(null);
|
||||
const initiateAgentMutation = useInitiateAgentWithInvalidation();
|
||||
|
||||
const secondaryGradient =
|
||||
'bg-gradient-to-r from-blue-500 to-blue-500 bg-clip-text text-transparent';
|
||||
|
@ -73,16 +68,13 @@ function DashboardContent() {
|
|||
const files = chatInputRef.current?.getPendingFiles() || [];
|
||||
localStorage.removeItem(PENDING_PROMPT_KEY);
|
||||
|
||||
// Always use FormData for consistency
|
||||
const formData = new FormData();
|
||||
formData.append('prompt', message);
|
||||
|
||||
// Append files if present
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file, file.name);
|
||||
});
|
||||
|
||||
// Append options
|
||||
if (options?.model_name) formData.append('model_name', options.model_name);
|
||||
formData.append('enable_thinking', String(options?.enable_thinking ?? false));
|
||||
formData.append('reasoning_effort', options?.reasoning_effort ?? 'low');
|
||||
|
@ -91,7 +83,7 @@ function DashboardContent() {
|
|||
|
||||
console.log('FormData content:', Array.from(formData.entries()));
|
||||
|
||||
const result = await initiateAgent(formData);
|
||||
const result = await initiateAgentMutation.mutateAsync(formData);
|
||||
console.log('Agent initiated:', result);
|
||||
|
||||
if (result.thread_id) {
|
||||
|
@ -115,35 +107,26 @@ function DashboardContent() {
|
|||
plan_name: 'Free',
|
||||
},
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isConnectionError =
|
||||
error instanceof TypeError && error.message.includes('Failed to fetch');
|
||||
if (!isLocalMode() || isConnectionError) {
|
||||
toast.error(error.message || 'An unexpected error occurred');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check for pending prompt in localStorage on mount
|
||||
|
||||
useEffect(() => {
|
||||
// Use a small delay to ensure we're fully mounted
|
||||
const timer = setTimeout(() => {
|
||||
const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY);
|
||||
|
||||
if (pendingPrompt) {
|
||||
setInputValue(pendingPrompt);
|
||||
setAutoSubmit(true); // Flag to auto-submit after mounting
|
||||
setAutoSubmit(true);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Auto-submit the form if we have a pending prompt
|
||||
useEffect(() => {
|
||||
if (autoSubmit && inputValue && !isSubmitting) {
|
||||
const timer = setTimeout(() => {
|
||||
|
@ -156,57 +139,59 @@ function DashboardContent() {
|
|||
}, [autoSubmit, inputValue, isSubmitting]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full">
|
||||
{isMobile && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setOpenMobile(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open menu</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<ModalProviders />
|
||||
<div className="flex flex-col items-center justify-center h-full w-full">
|
||||
{isMobile && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setOpenMobile(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open menu</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
|
||||
<div className="flex flex-col items-center text-center mb-2 w-full">
|
||||
<h1 className={cn('tracking-tight text-4xl font-semibold leading-tight')}>
|
||||
Hey
|
||||
</h1>
|
||||
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2 flex items-center gap-2">
|
||||
What would you like Suna to do today?
|
||||
</p>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
|
||||
<div className="flex flex-col items-center text-center mb-2 w-full">
|
||||
<h1 className={cn('tracking-tight text-4xl font-semibold leading-tight')}>
|
||||
Hey
|
||||
</h1>
|
||||
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2 flex items-center gap-2">
|
||||
What would you like Suna to do today?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
placeholder="Describe what you need help with..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
hideAttachments={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
placeholder="Describe what you need help with..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
hideAttachments={false}
|
||||
<BillingErrorAlert
|
||||
message={billingError?.message}
|
||||
currentUsage={billingError?.currentUsage}
|
||||
limit={billingError?.limit}
|
||||
accountId={personalAccount?.account_id}
|
||||
onDismiss={clearBillingError}
|
||||
isOpen={!!billingError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Billing Error Alert */}
|
||||
<BillingErrorAlert
|
||||
message={billingError?.message}
|
||||
currentUsage={billingError?.currentUsage}
|
||||
limit={billingError?.limit}
|
||||
accountId={personalAccount?.account_id}
|
||||
onDismiss={clearBillingError}
|
||||
isOpen={!!billingError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import { PricingSection } from '../home/sections/pricing-section';
|
||||
|
||||
const returnUrl = process.env.NEXT_PUBLIC_URL as string;
|
||||
|
||||
export const PaymentRequiredDialog = () => {
|
||||
const { isOpen, type, onClose } = useModal();
|
||||
const isModalOpen = isOpen && type === 'paymentRequiredDialog';
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="w-[95vw] max-w-[750px] max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-6 flex-shrink-0">
|
||||
<DialogTitle>
|
||||
Upgrade Required
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
You've reached your plan's usage limit. Upgrade to continue enjoying our premium features.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 pb-2 overflow-y-auto scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent px-4 sm:px-6 min-h-0">
|
||||
<div className="space-y-4 sm:space-y-6 pb-4">
|
||||
<div className="flex items-start p-3 sm:p-4 bg-destructive/5 border border-destructive/50 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<Zap className="w-4 h-4 sm:w-5 sm:h-5 text-destructive" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm min-w-0">
|
||||
<p className="font-medium text-destructive">Usage Limit Reached</p>
|
||||
<p className="text-destructive break-words">
|
||||
Your current plan has been exhausted for this billing period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<PricingSection
|
||||
insideDialog={true}
|
||||
hideFree={true}
|
||||
returnUrl={`${returnUrl}/dashboard`}
|
||||
showTitleAndTabs={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -74,6 +74,7 @@ interface PricingTierProps {
|
|||
onSubscriptionUpdate?: () => void;
|
||||
isAuthenticated?: boolean;
|
||||
returnUrl: string;
|
||||
insideDialog?: boolean;
|
||||
}
|
||||
|
||||
// Components
|
||||
|
@ -170,6 +171,7 @@ function PricingTier({
|
|||
onSubscriptionUpdate,
|
||||
isAuthenticated = false,
|
||||
returnUrl,
|
||||
insideDialog = false,
|
||||
}: PricingTierProps) {
|
||||
const [localSelectedPlan, setLocalSelectedPlan] = useState(
|
||||
selectedPlan || DEFAULT_SELECTED_PLAN,
|
||||
|
@ -500,10 +502,10 @@ function PricingTier({
|
|||
<div
|
||||
className={cn(
|
||||
'rounded-xl flex flex-col relative h-fit min-h-[400px] min-[650px]:h-full min-[900px]:h-fit',
|
||||
tier.isPopular
|
||||
tier.isPopular && !insideDialog
|
||||
? 'md:shadow-[0px_61px_24px_-10px_rgba(0,0,0,0.01),0px_34px_20px_-8px_rgba(0,0,0,0.05),0px_15px_15px_-6px_rgba(0,0,0,0.09),0px_4px_8px_-2px_rgba(0,0,0,0.10),0px_0px_0px_1px_rgba(0,0,0,0.08)] bg-accent'
|
||||
: 'bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] border border-border',
|
||||
ringClass,
|
||||
!insideDialog && ringClass,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
|
@ -601,11 +603,15 @@ function PricingTier({
|
|||
interface PricingSectionProps {
|
||||
returnUrl?: string;
|
||||
showTitleAndTabs?: boolean;
|
||||
hideFree?: boolean;
|
||||
insideDialog?: boolean;
|
||||
}
|
||||
|
||||
export function PricingSection({
|
||||
returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
|
||||
showTitleAndTabs = true,
|
||||
hideFree = false,
|
||||
insideDialog = false
|
||||
}: PricingSectionProps) {
|
||||
const [deploymentType, setDeploymentType] = useState<'cloud' | 'self-hosted'>(
|
||||
'cloud',
|
||||
|
@ -679,7 +685,7 @@ export function PricingSection({
|
|||
return (
|
||||
<section
|
||||
id="pricing"
|
||||
className="flex flex-col items-center justify-center gap-10 pb-20 w-full relative"
|
||||
className={cn("flex flex-col items-center justify-center gap-10 w-full relative", { "pb-20": !insideDialog })}
|
||||
>
|
||||
{showTitleAndTabs && (
|
||||
<>
|
||||
|
@ -705,8 +711,15 @@ export function PricingSection({
|
|||
)}
|
||||
|
||||
{deploymentType === 'cloud' && (
|
||||
<div className="grid min-[650px]:grid-cols-2 min-[900px]:grid-cols-3 gap-4 w-full max-w-6xl mx-auto px-6">
|
||||
{siteConfig.cloudPricingItems.map((tier) => (
|
||||
<div className={cn(
|
||||
"grid gap-4 w-full max-w-6xl mx-auto",
|
||||
{
|
||||
"px-6": !insideDialog
|
||||
},
|
||||
(!hideFree || siteConfig.cloudPricingItems.filter((tier) => !hideFree || tier.price !== '$0').length > 2)
|
||||
? "min-[650px]:grid-cols-2 min-[900px]:grid-cols-3" : "min-[650px]:grid-cols-2"
|
||||
)}>
|
||||
{siteConfig.cloudPricingItems.filter((tier) => !hideFree || tier.price !== '$0').map((tier) => (
|
||||
<PricingTier
|
||||
key={tier.name}
|
||||
tier={tier}
|
||||
|
@ -717,6 +730,7 @@ export function PricingSection({
|
|||
onSubscriptionUpdate={handleSubscriptionUpdate}
|
||||
isAuthenticated={isAuthenticated}
|
||||
returnUrl={returnUrl}
|
||||
insideDialog={insideDialog}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -32,6 +32,7 @@ interface ToolCallSidePanelProps {
|
|||
toolCalls: ToolCallInput[];
|
||||
currentIndex: number;
|
||||
onNavigate: (newIndex: number) => void;
|
||||
externalNavigateToIndex?: number;
|
||||
messages?: ApiMessageType[];
|
||||
agentStatus: string;
|
||||
project?: Project;
|
||||
|
@ -46,6 +47,13 @@ interface ToolCallSidePanelProps {
|
|||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface ToolCallSnapshot {
|
||||
id: string;
|
||||
toolCall: ToolCallInput;
|
||||
index: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function ToolCallSidePanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
@ -56,30 +64,104 @@ export function ToolCallSidePanel({
|
|||
agentStatus,
|
||||
project,
|
||||
isLoading = false,
|
||||
externalNavigateToIndex,
|
||||
}: ToolCallSidePanelProps) {
|
||||
// Move hooks outside of conditional
|
||||
const [dots, setDots] = React.useState('');
|
||||
const [showJumpToLive, setShowJumpToLive] = React.useState(false);
|
||||
const currentToolCall = toolCalls[currentIndex];
|
||||
const totalCalls = toolCalls.length;
|
||||
const [internalIndex, setInternalIndex] = React.useState(0);
|
||||
const [navigationMode, setNavigationMode] = React.useState<'live' | 'manual'>('live');
|
||||
const [toolCallSnapshots, setToolCallSnapshots] = React.useState<ToolCallSnapshot[]>([]);
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
const newSnapshots = toolCalls.map((toolCall, index) => ({
|
||||
id: `${index}-${toolCall.assistantCall.timestamp || Date.now()}`,
|
||||
toolCall,
|
||||
index,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
const hadSnapshots = toolCallSnapshots.length > 0;
|
||||
const hasNewSnapshots = newSnapshots.length > toolCallSnapshots.length;
|
||||
setToolCallSnapshots(newSnapshots);
|
||||
|
||||
if (!isInitialized && newSnapshots.length > 0) {
|
||||
setInternalIndex(Math.max(0, newSnapshots.length - 1));
|
||||
setIsInitialized(true);
|
||||
} else if (hasNewSnapshots && navigationMode === 'live') {
|
||||
setInternalIndex(newSnapshots.length - 1);
|
||||
} else if (hasNewSnapshots && navigationMode === 'manual') {
|
||||
}
|
||||
}, [toolCalls, navigationMode, toolCallSnapshots.length, isInitialized]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && !isInitialized && toolCallSnapshots.length > 0) {
|
||||
setInternalIndex(Math.min(currentIndex, toolCallSnapshots.length - 1));
|
||||
}
|
||||
}, [isOpen, currentIndex, isInitialized, toolCallSnapshots.length]);
|
||||
|
||||
const currentSnapshot = toolCallSnapshots[internalIndex];
|
||||
const currentToolCall = currentSnapshot?.toolCall;
|
||||
const totalCalls = toolCallSnapshots.length;
|
||||
|
||||
const currentToolName = currentToolCall?.assistantCall?.name || 'Tool Call';
|
||||
const CurrentToolIcon = getToolIcon(
|
||||
currentToolName === 'Tool Call' ? 'unknown' : currentToolName,
|
||||
);
|
||||
const isStreaming = currentToolCall?.toolResult?.content === 'STREAMING';
|
||||
const isSuccess = currentToolCall?.toolResult?.isSuccess ?? true;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Show jump to live button when agent is running and user is not on the last step
|
||||
React.useEffect(() => {
|
||||
if (agentStatus === 'running' && currentIndex + 1 < totalCalls) {
|
||||
setShowJumpToLive(true);
|
||||
const internalNavigate = React.useCallback((newIndex: number, source: string = 'internal') => {
|
||||
if (newIndex < 0 || newIndex >= totalCalls) return;
|
||||
|
||||
const isNavigatingToLatest = newIndex === totalCalls - 1;
|
||||
|
||||
console.log(`[INTERNAL_NAV] ${source}: ${internalIndex} -> ${newIndex}, mode will be: ${isNavigatingToLatest ? 'live' : 'manual'}`);
|
||||
|
||||
setInternalIndex(newIndex);
|
||||
|
||||
if (isNavigatingToLatest) {
|
||||
setNavigationMode('live');
|
||||
} else {
|
||||
setShowJumpToLive(false);
|
||||
setNavigationMode('manual');
|
||||
}
|
||||
}, [agentStatus, currentIndex, totalCalls]);
|
||||
|
||||
if (source === 'user_explicit') {
|
||||
onNavigate(newIndex);
|
||||
}
|
||||
}, [internalIndex, totalCalls, onNavigate]);
|
||||
|
||||
const isLiveMode = navigationMode === 'live';
|
||||
const showJumpToLive = navigationMode === 'manual' && agentStatus === 'running';
|
||||
const showJumpToLatest = navigationMode === 'manual' && agentStatus !== 'running';
|
||||
|
||||
const navigateToPrevious = React.useCallback(() => {
|
||||
if (internalIndex > 0) {
|
||||
internalNavigate(internalIndex - 1, 'user_explicit');
|
||||
}
|
||||
}, [internalIndex, internalNavigate]);
|
||||
|
||||
const navigateToNext = React.useCallback(() => {
|
||||
if (internalIndex < totalCalls - 1) {
|
||||
internalNavigate(internalIndex + 1, 'user_explicit');
|
||||
}
|
||||
}, [internalIndex, totalCalls, internalNavigate]);
|
||||
|
||||
const jumpToLive = React.useCallback(() => {
|
||||
setNavigationMode('live');
|
||||
internalNavigate(totalCalls - 1, 'user_explicit');
|
||||
}, [totalCalls, internalNavigate]);
|
||||
|
||||
const jumpToLatest = React.useCallback(() => {
|
||||
setNavigationMode('manual');
|
||||
internalNavigate(totalCalls - 1, 'user_explicit');
|
||||
}, [totalCalls, internalNavigate]);
|
||||
|
||||
const handleSliderChange = React.useCallback(([newValue]: [number]) => {
|
||||
internalNavigate(newValue, 'user_explicit');
|
||||
}, [internalNavigate]);
|
||||
|
||||
// Add keyboard shortcut for CMD+I to close panel
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
|
@ -94,10 +176,8 @@ export function ToolCallSidePanel({
|
|||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Listen for sidebar toggle events
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleSidebarToggle = (event: CustomEvent) => {
|
||||
if (event.detail.expanded) {
|
||||
onClose();
|
||||
|
@ -115,6 +195,12 @@ export function ToolCallSidePanel({
|
|||
);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (externalNavigateToIndex !== undefined && externalNavigateToIndex >= 0 && externalNavigateToIndex < totalCalls) {
|
||||
internalNavigate(externalNavigateToIndex, 'external_click');
|
||||
}
|
||||
}, [externalNavigateToIndex, totalCalls, internalNavigate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
const interval = setInterval(() => {
|
||||
|
@ -127,24 +213,6 @@ export function ToolCallSidePanel({
|
|||
return () => clearInterval(interval);
|
||||
}, [isStreaming]);
|
||||
|
||||
const navigateToPrevious = React.useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
onNavigate(currentIndex - 1);
|
||||
}
|
||||
}, [currentIndex, onNavigate]);
|
||||
|
||||
const navigateToNext = React.useCallback(() => {
|
||||
if (currentIndex < totalCalls - 1) {
|
||||
onNavigate(currentIndex + 1);
|
||||
}
|
||||
}, [currentIndex, totalCalls, onNavigate]);
|
||||
|
||||
const jumpToLive = React.useCallback(() => {
|
||||
// Jump to the last step (totalCalls - 1)
|
||||
onNavigate(totalCalls - 1);
|
||||
setShowJumpToLive(false);
|
||||
}, [totalCalls, onNavigate]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (isLoading) {
|
||||
|
@ -250,7 +318,7 @@ export function ToolCallSidePanel({
|
|||
project={project}
|
||||
messages={messages}
|
||||
agentStatus={agentStatus}
|
||||
currentIndex={currentIndex}
|
||||
currentIndex={internalIndex}
|
||||
totalCalls={totalCalls}
|
||||
/>
|
||||
);
|
||||
|
@ -373,15 +441,20 @@ export function ToolCallSidePanel({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{agentStatus === 'running' && (
|
||||
{isLiveMode && agentStatus === 'running' ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">Live</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="w-1.5 h-1.5 bg-neutral-500 rounded-full"></div>
|
||||
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-400">Live</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400 flex-shrink-0">
|
||||
Step {currentIndex + 1} of {totalCalls}
|
||||
Step {internalIndex + 1} of {totalCalls}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -393,7 +466,7 @@ export function ToolCallSidePanel({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={navigateToPrevious}
|
||||
disabled={currentIndex <= 0}
|
||||
disabled={internalIndex <= 0}
|
||||
className="h-9 px-3"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
|
@ -401,15 +474,20 @@ export function ToolCallSidePanel({
|
|||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{agentStatus === 'running' && (
|
||||
{isLiveMode && agentStatus === 'running' ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">Live</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="w-1.5 h-1.5 bg-neutral-500 rounded-full"></div>
|
||||
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-400">Live</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{currentIndex + 1} / {totalCalls}
|
||||
{internalIndex + 1} / {totalCalls}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
@ -417,7 +495,7 @@ export function ToolCallSidePanel({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={navigateToNext}
|
||||
disabled={currentIndex >= totalCalls - 1}
|
||||
disabled={internalIndex >= totalCalls - 1}
|
||||
className="h-9 px-3"
|
||||
>
|
||||
<span>Next</span>
|
||||
|
@ -431,7 +509,7 @@ export function ToolCallSidePanel({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={navigateToPrevious}
|
||||
disabled={currentIndex <= 0}
|
||||
disabled={internalIndex <= 0}
|
||||
className="h-6 w-6 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
|
@ -440,21 +518,20 @@ export function ToolCallSidePanel({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={navigateToNext}
|
||||
disabled={currentIndex >= totalCalls - 1}
|
||||
disabled={internalIndex >= totalCalls - 1}
|
||||
className="h-6 w-6 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative w-full">
|
||||
<Slider
|
||||
min={0}
|
||||
max={totalCalls - 1}
|
||||
step={1}
|
||||
value={[currentIndex]}
|
||||
onValueChange={([newValue]) => onNavigate(newValue)}
|
||||
value={[internalIndex]}
|
||||
onValueChange={handleSliderChange}
|
||||
className="w-full [&>span:first-child]:h-1 [&>span:first-child]:bg-zinc-200 dark:[&>span:first-child]:bg-zinc-800 [&>span:first-child>span]:bg-zinc-500 dark:[&>span:first-child>span]:bg-zinc-400 [&>span:first-child>span]:h-1"
|
||||
/>
|
||||
|
||||
|
@ -464,16 +541,29 @@ export function ToolCallSidePanel({
|
|||
<Button
|
||||
onClick={jumpToLive}
|
||||
size="sm"
|
||||
className="h-8 px-3 bg-red-500 hover:bg-red-600 text-white shadow-lg border border-red-600 dark:border-red-400 flex items-center gap-1.5"
|
||||
className="h-8 px-3 bg-red-500 hover:bg-red-600 text-white shadow-lg dark:border-red-400 flex items-center gap-1.5"
|
||||
>
|
||||
<Radio className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">Jump to Live</span>
|
||||
</Button>
|
||||
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-red-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showJumpToLatest && (
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-12 z-50">
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={jumpToLatest}
|
||||
size="sm"
|
||||
className="h-8 px-3 shadow-lg flex items-center gap-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium">Jump to Latest</span>
|
||||
</Button>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -0,0 +1,380 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
CircleDashed,
|
||||
ExternalLink,
|
||||
Code,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
TerminalIcon,
|
||||
Check,
|
||||
X,
|
||||
Power,
|
||||
StopCircle
|
||||
} from 'lucide-react';
|
||||
import { ToolViewProps } from './types';
|
||||
import {
|
||||
extractExitCode,
|
||||
formatTimestamp,
|
||||
getToolTitle,
|
||||
} from './utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function TerminateCommandToolView({
|
||||
name = 'terminate-command',
|
||||
assistantContent,
|
||||
toolContent,
|
||||
assistantTimestamp,
|
||||
toolTimestamp,
|
||||
isSuccess = true,
|
||||
isStreaming = false,
|
||||
}: ToolViewProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkTheme = resolvedTheme === 'dark';
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [showFullOutput, setShowFullOutput] = useState(true);
|
||||
|
||||
const rawSessionName = React.useMemo(() => {
|
||||
if (!assistantContent) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(assistantContent);
|
||||
if (parsed.content) {
|
||||
const sessionMatch = parsed.content.match(
|
||||
/<terminate-command[^>]*session_name=["']([^"']+)["'][^>]*>/,
|
||||
);
|
||||
if (sessionMatch) {
|
||||
return sessionMatch[1].trim();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const sessionMatch = assistantContent.match(
|
||||
/<terminate-command[^>]*session_name=["']([^"']+)["'][^>]*>/,
|
||||
);
|
||||
if (sessionMatch) {
|
||||
return sessionMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [assistantContent]);
|
||||
|
||||
const sessionName = rawSessionName?.trim();
|
||||
|
||||
const output = React.useMemo(() => {
|
||||
if (!toolContent) return null;
|
||||
|
||||
let extractedOutput = '';
|
||||
let success = true;
|
||||
|
||||
try {
|
||||
if (typeof toolContent === 'string') {
|
||||
// Handle ToolResult format: ToolResult(success=False, output="message")
|
||||
const toolResultMatch = toolContent.match(/ToolResult\(success=(true|false),\s*output="([^"]+)"\)/i);
|
||||
if (toolResultMatch) {
|
||||
success = toolResultMatch[1].toLowerCase() === 'true';
|
||||
extractedOutput = toolResultMatch[2];
|
||||
}
|
||||
// Handle other formats
|
||||
else if (toolContent.includes('ToolResult')) {
|
||||
const successMatch = toolContent.match(/success=(true|false)/i);
|
||||
success = successMatch ? successMatch[1].toLowerCase() === 'true' : true;
|
||||
|
||||
//@ts-expect-error IGNORE
|
||||
const outputMatch = toolContent.match(/output=['"](.*)['"]/s);
|
||||
if (outputMatch && outputMatch[1]) {
|
||||
extractedOutput = outputMatch[1]
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\'/g, "'");
|
||||
} else {
|
||||
extractedOutput = toolContent;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const parsed = JSON.parse(toolContent);
|
||||
if (parsed.output) {
|
||||
extractedOutput = parsed.output;
|
||||
success = parsed.success !== false;
|
||||
} else if (parsed.content) {
|
||||
extractedOutput = parsed.content;
|
||||
} else {
|
||||
extractedOutput = JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
extractedOutput = toolContent;
|
||||
}
|
||||
}
|
||||
} else if (typeof toolContent === 'object' && toolContent !== null) {
|
||||
// Handle case where toolContent is already an object
|
||||
const typedToolContent = toolContent as Record<string, any>;
|
||||
if ('output' in typedToolContent) {
|
||||
extractedOutput = typedToolContent.output;
|
||||
success = 'success' in typedToolContent ? !!typedToolContent.success : true;
|
||||
} else {
|
||||
extractedOutput = JSON.stringify(toolContent, null, 2);
|
||||
}
|
||||
} else {
|
||||
extractedOutput = String(toolContent);
|
||||
}
|
||||
} catch (e) {
|
||||
extractedOutput = String(toolContent);
|
||||
console.error('Error parsing tool content:', e);
|
||||
}
|
||||
|
||||
return extractedOutput;
|
||||
}, [toolContent]);
|
||||
|
||||
const exitCode = extractExitCode(output);
|
||||
const toolTitle = getToolTitle(name) || 'Terminate Session';
|
||||
|
||||
// Determine if the termination was successful based on output content
|
||||
const terminationSuccess = React.useMemo(() => {
|
||||
if (!output) return false;
|
||||
|
||||
// Check if the output indicates success or failure
|
||||
const outputLower = output.toLowerCase();
|
||||
if (outputLower.includes('does not exist')) return false;
|
||||
if (outputLower.includes('terminated') || outputLower.includes('killed')) return true;
|
||||
|
||||
// Check if toolContent contains ToolResult with success=false
|
||||
if (typeof toolContent === 'string') {
|
||||
const toolResultMatch = toolContent.match(/ToolResult\(success=(true|false)/i);
|
||||
if (toolResultMatch) {
|
||||
return toolResultMatch[1].toLowerCase() === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// Default to checking the success flag
|
||||
return isSuccess;
|
||||
}, [output, isSuccess, toolContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
const timer = setInterval(() => {
|
||||
setProgress((prevProgress) => {
|
||||
if (prevProgress >= 95) {
|
||||
clearInterval(timer);
|
||||
return prevProgress;
|
||||
}
|
||||
return prevProgress + 5;
|
||||
});
|
||||
}, 300);
|
||||
return () => clearInterval(timer);
|
||||
} else {
|
||||
setProgress(100);
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
const formattedOutput = React.useMemo(() => {
|
||||
if (!output) return [];
|
||||
let processedOutput = output;
|
||||
try {
|
||||
if (typeof output === 'string' && (output.trim().startsWith('{') || output.trim().startsWith('{'))) {
|
||||
const parsed = JSON.parse(output);
|
||||
if (parsed && typeof parsed === 'object' && parsed.output) {
|
||||
processedOutput = parsed.output;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
processedOutput = String(processedOutput);
|
||||
processedOutput = processedOutput.replace(/\\\\/g, '\\');
|
||||
|
||||
processedOutput = processedOutput
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'");
|
||||
|
||||
processedOutput = processedOutput.replace(/\\u([0-9a-fA-F]{4})/g, (match, group) => {
|
||||
return String.fromCharCode(parseInt(group, 16));
|
||||
});
|
||||
return processedOutput.split('\n');
|
||||
}, [output]);
|
||||
|
||||
const hasMoreLines = formattedOutput.length > 10;
|
||||
const previewLines = formattedOutput.slice(0, 10);
|
||||
const linesToShow = showFullOutput ? formattedOutput : previewLines;
|
||||
|
||||
return (
|
||||
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-white dark:bg-zinc-950">
|
||||
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative p-2 rounded-lg bg-gradient-to-br from-red-500/20 to-red-600/10 border border-red-500/20">
|
||||
<StopCircle className="w-5 h-5 text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{toolTitle}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isStreaming && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
terminationSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
{terminationSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{terminationSuccess ? 'Session terminated' : 'Termination failed'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
|
||||
{isStreaming ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className="text-center w-full max-w-xs">
|
||||
<div className="w-16 h-16 rounded-full mx-auto mb-6 flex items-center justify-center bg-gradient-to-b from-red-100 to-red-50 shadow-inner dark:from-red-800/40 dark:to-red-900/60 dark:shadow-red-950/20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-2">
|
||||
Terminating session
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
<span className="font-mono text-xs break-all">{sessionName || 'Processing termination...'}</span>
|
||||
</p>
|
||||
<Progress value={progress} className="w-full h-2" />
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">{progress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
) : sessionName ? (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 bg-zinc-100 dark:bg-neutral-900 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="bg-zinc-200 dark:bg-zinc-800 px-4 py-2 flex items-center gap-2">
|
||||
<Power className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Session</span>
|
||||
</div>
|
||||
<div className="p-4 font-mono text-sm text-zinc-700 dark:text-zinc-300 flex gap-2">
|
||||
<span className="text-red-500 dark:text-red-400 select-none">●</span>
|
||||
<code className="flex-1 break-all">{sessionName}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{output && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center">
|
||||
<ArrowRight className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
||||
Result
|
||||
</h3>
|
||||
<Badge
|
||||
className={cn(
|
||||
"ml-2",
|
||||
terminationSuccess
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
)}
|
||||
>
|
||||
{terminationSuccess ? 'Success' : 'Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-100 dark:bg-neutral-900 rounded-lg overflow-hidden border border-zinc-200/20">
|
||||
<div className="bg-zinc-300 dark:bg-neutral-800 flex items-center justify-between dark:border-zinc-700/50">
|
||||
<div className="bg-zinc-200 w-full dark:bg-zinc-800 px-4 py-2 flex items-center gap-2">
|
||||
<TerminalIcon className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Termination output</span>
|
||||
</div>
|
||||
{!terminationSuccess && (
|
||||
<Badge variant="outline" className="text-xs h-5 border-red-700/30 text-red-400">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 max-h-96 overflow-auto scrollbar-hide">
|
||||
<pre className="text-xs text-zinc-600 dark:text-zinc-300 font-mono whitespace-pre-wrap break-all overflow-visible">
|
||||
{linesToShow.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"py-0.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{!showFullOutput && hasMoreLines && (
|
||||
<div className="text-zinc-500 mt-2 border-t border-zinc-700/30 pt-2">
|
||||
+ {formattedOutput.length - 10} more lines
|
||||
</div>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!output && !isStreaming && (
|
||||
<div className="bg-black rounded-lg overflow-hidden border border-zinc-700/20 shadow-md p-6 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<CircleDashed className="h-8 w-8 text-zinc-500 mx-auto mb-2" />
|
||||
<p className="text-zinc-400 text-sm">No output received</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-gradient-to-b from-zinc-100 to-zinc-50 shadow-inner dark:from-zinc-800/40 dark:to-zinc-900/60">
|
||||
<StopCircle className="h-10 w-10 text-zinc-400 dark:text-zinc-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
|
||||
No Session Found
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 text-center max-w-md">
|
||||
No session name was detected. Please provide a valid session to terminate.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
|
||||
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{!isStreaming && sessionName && (
|
||||
<Badge variant="outline" className="h-6 py-0.5 bg-zinc-50 dark:bg-zinc-900">
|
||||
<StopCircle className="h-3 w-3 mr-1" />
|
||||
Terminate
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{toolTimestamp && !isStreaming
|
||||
? formatTimestamp(toolTimestamp)
|
||||
: assistantTimestamp
|
||||
? formatTimestamp(assistantTimestamp)
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -34,6 +34,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
function truncateText(text: string, maxLength: number = 40) {
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
}
|
||||
|
||||
export function WebScrapeToolView({
|
||||
name = 'scrape-webpage',
|
||||
assistantContent,
|
||||
|
|
|
@ -32,6 +32,10 @@ import { Progress } from '@/components/ui/progress';
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
function truncateText(text: string, maxLength: number = 70) {
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
}
|
||||
|
||||
export function WebSearchToolView({
|
||||
name = 'web-search',
|
||||
assistantContent,
|
||||
|
@ -278,7 +282,7 @@ export function WebSearchToolView({
|
|||
</a>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 mb-2 flex items-center">
|
||||
<Globe className="h-3 w-3 mr-1.5 flex-shrink-0 opacity-70" />
|
||||
{cleanUrl(result.url)}
|
||||
{truncateText(cleanUrl(result.url))}
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
|
|
|
@ -11,6 +11,7 @@ import { WebCrawlToolView } from '../WebCrawlToolView';
|
|||
import { WebScrapeToolView } from '../WebScrapeToolView';
|
||||
import { WebSearchToolView } from '../WebSearchToolView';
|
||||
import { SeeImageToolView } from '../SeeImageToolView';
|
||||
import { TerminateCommandToolView } from '../TerminateCommandToolView';
|
||||
|
||||
export type ToolViewComponent = React.ComponentType<ToolViewProps>;
|
||||
|
||||
|
@ -34,6 +35,7 @@ const defaultRegistry: ToolViewRegistryType = {
|
|||
'browser-click-coordinates': BrowserToolView,
|
||||
|
||||
'execute-command': CommandToolView,
|
||||
'terminate-command': TerminateCommandToolView,
|
||||
|
||||
'create-file': FileOperationToolView,
|
||||
'delete-file': FileOperationToolView,
|
||||
|
|
|
@ -63,6 +63,9 @@ export const getToolIcon = (toolName: string): ElementType => {
|
|||
// Shell commands
|
||||
case 'execute-command':
|
||||
return Terminal;
|
||||
case 'terminate-command':
|
||||
return Terminal;
|
||||
|
||||
|
||||
// Web operations
|
||||
case 'web-search':
|
||||
|
@ -210,6 +213,7 @@ export const extractPrimaryParam = (
|
|||
|
||||
const TOOL_DISPLAY_NAMES = new Map([
|
||||
['execute-command', 'Executing Command'],
|
||||
['terminate-command', 'Terminating Command'],
|
||||
['create-file', 'Creating File'],
|
||||
['delete-file', 'Deleting File'],
|
||||
['full-file-rewrite', 'Rewriting File'],
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { createQueryKeys } from '@/hooks/use-query';
|
||||
|
||||
export const dashboardKeys = createQueryKeys({
|
||||
all: ['dashboard'] as const,
|
||||
agents: ['dashboard', 'agents'] as const,
|
||||
initiateAgent: () => [...dashboardKeys.agents, 'initiate'] as const,
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import { initiateAgent, InitiateAgentResponse } from "@/lib/api";
|
||||
import { createMutationHook } from "@/hooks/use-query";
|
||||
import { toast } from "sonner";
|
||||
import { dashboardKeys } from "./keys";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useModal } from "@/hooks/use-modal-store";
|
||||
|
||||
export const useInitiateAgentMutation = createMutationHook<
|
||||
InitiateAgentResponse,
|
||||
FormData
|
||||
>(
|
||||
initiateAgent,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success("Agent initiated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Cannot connect to backend server")) {
|
||||
toast.error("Connection error: Please check your internet connection and ensure the backend server is running");
|
||||
} else if (error.message.includes("No access token available")) {
|
||||
toast.error("Authentication error: Please sign in again");
|
||||
} else {
|
||||
toast.error(`Failed to initiate agent: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
toast.error("An unexpected error occurred while initiating the agent");
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const useInitiateAgentWithInvalidation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onOpen } = useModal();
|
||||
return useInitiateAgentMutation({
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.agents });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error && error.message.includes("Payment Required")) {
|
||||
onOpen("paymentRequiredDialog");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
// This file is now empty as the initiateAgent function has been moved to use-initiate-agent.ts
|
|
@ -0,0 +1,16 @@
|
|||
import { create } from "zustand";
|
||||
export type ModalType = "paymentRequiredDialog"
|
||||
|
||||
interface ModalStore {
|
||||
type: ModalType | null;
|
||||
isOpen: boolean;
|
||||
onOpen: (type: ModalType) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const useModal = create<ModalStore>((set) => ({
|
||||
type: null,
|
||||
isOpen: false,
|
||||
onOpen: (type) => set({ type, isOpen: true }),
|
||||
onClose: () => set({ type: null, isOpen: false }),
|
||||
}));
|
|
@ -1437,11 +1437,9 @@ export const initiateAgent = async (
|
|||
const response = await fetch(`${API_URL}/agent/initiate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// Note: Don't set Content-Type for FormData
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: formData,
|
||||
// Add cache: 'no-store' to prevent caching
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { PaymentRequiredDialog } from "@/components/billing/payment-required-dialog"
|
||||
|
||||
export const ModalProviders = () => {
|
||||
return (
|
||||
<>
|
||||
<PaymentRequiredDialog />
|
||||
</>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue