Merge pull request #457 from escapade-mckv/main

Payment required dialog on 402 error
This commit is contained in:
Marko Kraemer 2025-05-24 19:29:19 +02:00 committed by GitHub
commit 191239d178
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 752 additions and 126 deletions

View File

@ -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}

View File

@ -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>
</>
);
}

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>
)}

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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>

View File

@ -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,

View File

@ -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'],

View 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,
});

View File

@ -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");
}
}
});
};

View File

@ -0,0 +1 @@
// This file is now empty as the initiateAgent function has been moved to use-initiate-agent.ts

View File

@ -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 }),
}));

View File

@ -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',
});

View File

@ -0,0 +1,9 @@
import { PaymentRequiredDialog } from "@/components/billing/payment-required-dialog"
export const ModalProviders = () => {
return (
<>
<PaymentRequiredDialog />
</>
)
}