chore(ui): add error toasts

This commit is contained in:
Soumyadas15 2025-05-27 00:53:26 +05:30
parent cf13ca8c1f
commit af416571df
14 changed files with 1213 additions and 106 deletions

View File

@ -112,6 +112,7 @@ export function ThreadLayout({
renderAssistantMessage={renderAssistantMessage}
renderToolResult={renderToolResult}
isLoading={!initialLoadCompleted || isLoading}
onFileClick={onViewFiles}
/>
{sandboxId && (

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,52 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/button';
import { handleApiError, handleApiSuccess, handleApiWarning } from '@/lib/error-handler';
import { projectsApi } from '@/lib/api-enhanced';
export const TestErrorHandling: React.FC = () => {
const testError = () => {
// Simulate a 404 error
const error = new Error('Resource not found');
(error as any).status = 404;
handleApiError(error, { operation: 'test operation', resource: 'test data' });
};
const testSuccess = () => {
handleApiSuccess('Test successful!', 'This is a success message');
};
const testWarning = () => {
handleApiWarning('Test warning', 'This is a warning message');
};
const testEnhancedApi = async () => {
try {
// This will trigger error handling automatically
await projectsApi.getById('non-existent-id');
} catch (error) {
console.log('Error caught:', error);
}
};
return (
<div className="p-4 space-y-4">
<h3 className="text-lg font-semibold">Test Error Handling</h3>
<div className="space-x-2">
<Button onClick={testError} variant="destructive">
Test Error Toast
</Button>
<Button onClick={testSuccess} variant="default">
Test Success Toast
</Button>
<Button onClick={testWarning} variant="secondary">
Test Warning Toast
</Button>
<Button onClick={testEnhancedApi} variant="outline">
Test Enhanced API Error
</Button>
</div>
</div>
);
};

View File

@ -20,6 +20,7 @@ import { cn, truncateString } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileAttachment } from '../file-attachment';
interface AskContent {
attachments?: string[];
@ -38,8 +39,18 @@ export function AskToolView({
isSuccess = true,
isStreaming = false,
onFileClick,
project,
}: AskToolViewProps) {
const [askData, setAskData] = useState<AskContent>({});
const isImageFile = (filePath: string): boolean => {
const filename = filePath.split('/').pop() || '';
return filename.match(/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i) !== null;
};
const isPreviewableFile = (filePath: string): boolean => {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
return ext === 'html' || ext === 'htm' || ext === 'md' || ext === 'markdown' || ext === 'csv' || ext === 'tsv';
};
// Extract attachments from assistant content
useEffect(() => {
@ -113,47 +124,79 @@ export function AskToolView({
<ScrollArea className="h-full w-full">
<div className="p-4 space-y-6">
{askData.attachments && askData.attachments.length > 0 ? (
<div className="space-y-3">
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Paperclip className="h-4 w-4" />
Files ({askData.attachments.length})
</div>
<div className="grid grid-cols-2 gap-2">
{askData.attachments.map((attachment, index) => {
const { icon: FileIcon, color, bgColor } = getFileIconAndColor(attachment);
const fileName = attachment.split('/').pop() || attachment;
const filePath = attachment.includes('/') ? attachment.substring(0, attachment.lastIndexOf('/')) : '';
return (
<button
key={index}
onClick={() => handleFileClick(attachment)}
className="flex flex-col items-center justify-center gap-3 p-3 h-[15rem] w-full bg-muted/30 rounded-lg border border-border/50 hover:bg-muted/50 transition-colors group cursor-pointer text-left"
>
<div className="flex-shrink-0">
<div className={cn(
"w-20 h-20 rounded-lg bg-gradient-to-br flex items-center justify-center",
bgColor
)}>
<FileIcon className={cn("h-10 w-10", color)} />
</div>
</div>
<div className="flex flex-col items-center gap-2 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{truncateString(fileName, 30)}
</p>
{filePath && (
<p className="text-xs text-muted-foreground truncate">
{filePath}
</p>
<div className={cn(
"grid gap-3",
askData.attachments.length === 1 ? "grid-cols-1" :
askData.attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
"grid-cols-1 sm:grid-cols-2"
)}>
{askData.attachments
.sort((a, b) => {
const aIsImage = isImageFile(a);
const bIsImage = isImageFile(b);
const aIsPreviewable = isPreviewableFile(a);
const bIsPreviewable = isPreviewableFile(b);
if (aIsImage && !bIsImage) return -1;
if (!aIsImage && bIsImage) return 1;
if (aIsPreviewable && !bIsPreviewable) return -1;
if (!aIsPreviewable && bIsPreviewable) return 1;
return 0;
})
.map((attachment, index) => {
const isImage = isImageFile(attachment);
const isPreviewable = isPreviewableFile(attachment);
const shouldSpanFull = (askData.attachments!.length % 2 === 1 &&
askData.attachments!.length > 1 &&
index === askData.attachments!.length - 1);
return (
<div
key={index}
className={cn(
"relative group",
isImage ? "flex items-center justify-center h-full" : "",
isPreviewable ? "w-full" : ""
)}
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</div>
style={(shouldSpanFull || isPreviewable) ? { gridColumn: '1 / -1' } : undefined}
>
<FileAttachment
filepath={attachment}
onClick={handleFileClick}
sandboxId={project?.sandbox?.id}
showPreview={true}
className={cn(
"w-full",
isImage ? "h-auto min-h-[54px]" :
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
)}
customStyle={
isImage ? {
width: '100%',
height: 'auto',
'--attachment-height': shouldSpanFull ? '240px' : '180px'
} as React.CSSProperties :
isPreviewable ? {
gridColumn: '1 / -1'
} :
shouldSpanFull ? {
gridColumn: '1 / -1'
} : {
width: '100%'
}
}
collapsed={false}
project={project}
/>
</div>
</button>
);
})}
);
})}
</div>
{assistantTimestamp && (

View File

@ -474,10 +474,27 @@ export function FileOperationToolView({
</div>
</CardHeader>
<CardContent className="p-0 -my-2 flex-1 overflow-hidden relative">
<CardContent className="p-0 -my-2 h-full flex-1 overflow-hidden relative">
<TabsContent value="code" className="flex-1 h-full mt-0 p-0 overflow-hidden">
<ScrollArea className="h-full w-full">
{operation === 'delete' ? (
<ScrollArea className="h-full w-full min-h-0">
{isStreaming && !fileContent ? (
<div className="flex flex-col items-center justify-center min-h-[400px] h-full py-12 px-6">
<div className={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
<Loader2 className={cn("h-10 w-10 animate-spin", config.color)} />
</div>
<h3 className="text-xl font-semibold mb-6 text-zinc-900 dark:text-zinc-100">
{config.progressMessage}
</h3>
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4 shadow-sm">
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
{processedFilePath || 'Processing file...'}
</code>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Please wait while the file is being processed
</p>
</div>
) : operation === 'delete' ? (
<div className="flex flex-col items-center justify-center h-full py-12 px-6">
<div className={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
<Trash2 className={cn("h-10 w-10", config.color)} />
@ -542,8 +559,25 @@ export function FileOperationToolView({
</TabsContent>
<TabsContent value="preview" className="w-full flex-1 h-full mt-0 p-0 overflow-hidden">
<ScrollArea className="h-full w-full">
{operation === 'delete' ? (
<ScrollArea className="h-full w-full min-h-0">
{isStreaming && !fileContent ? (
<div className="flex flex-col items-center justify-center min-h-[400px] h-full py-12 px-6 ">
<div className={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
<Loader2 className={cn("h-10 w-10 animate-spin", config.color)} />
</div>
<h3 className="text-xl font-semibold mb-6 text-zinc-900 dark:text-zinc-100">
{config.progressMessage}
</h3>
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4 shadow-sm">
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
{processedFilePath || 'Processing file...'}
</code>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Please wait while the file is being processed
</p>
</div>
) : operation === 'delete' ? (
<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={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
<Trash2 className={cn("h-10 w-10", config.color)} />

View File

@ -28,7 +28,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// Define types for diffing
type DiffType = 'unchanged' | 'added' | 'removed';
interface LineDiff {
@ -48,7 +47,6 @@ interface DiffStats {
deletions: number;
}
// Component to display unified diff view
const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -mt-2">
<table className="w-full border-collapse">
@ -83,7 +81,6 @@ const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
</div>
);
// Component to display split diff view
const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -my-2">
<table className="w-full border-collapse">
@ -145,26 +142,34 @@ const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
</div>
);
// Loading state component
const LoadingState: React.FC<{ filePath: string | null; progress: number }> = ({ filePath, progress }) => (
<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-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20">
<Loader2 className="h-8 w-8 animate-spin text-purple-500 dark:text-purple-400" />
<div className="text-center w-full max-w-sm">
<div className="w-20 h-20 rounded-full mx-auto mb-6 flex items-center justify-center bg-gradient-to-b from-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20">
<Loader2 className="h-10 w-10 animate-spin text-purple-500 dark:text-purple-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-2">
Processing replacement
<h3 className="text-xl font-semibold mb-4 text-zinc-900 dark:text-zinc-100">
Processing String Replacement
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
<span className="font-mono text-xs break-all">Replacing text in {filePath || 'file'}</span>
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full text-center mb-6 shadow-sm">
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
{filePath || 'Processing file...'}
</code>
</div>
<div className="space-y-3">
<Progress value={Math.min(progress, 100)} className="w-full h-3" />
<div className="flex justify-between items-center text-xs text-zinc-500 dark:text-zinc-400">
<span>Analyzing text patterns</span>
<span className="font-mono">{Math.round(Math.min(progress, 100))}%</span>
</div>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-4">
Please wait while the replacement is being processed
</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>
);
// Error state component
const ErrorState: React.FC = () => (
<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">
@ -197,18 +202,18 @@ export function StrReplaceToolView({
const { oldStr, newStr } = extractStrReplaceContent(assistantContent);
const toolTitle = getToolTitle(name);
// Simulate progress when streaming
useEffect(() => {
if (isStreaming) {
setProgress(0);
const timer = setInterval(() => {
setProgress((prevProgress) => {
if (prevProgress >= 95) {
clearInterval(timer);
return prevProgress;
}
return prevProgress + 5;
return prevProgress + Math.random() * 10 + 5;
});
}, 300);
}, 500);
return () => clearInterval(timer);
} else {
setProgress(100);
@ -372,6 +377,13 @@ export function StrReplaceToolView({
{isSuccess ? 'Replacement completed' : 'Replacement failed'}
</Badge>
)}
{isStreaming && (
<Badge className="bg-gradient-to-b from-blue-200 to-blue-100 text-blue-700 dark:from-blue-800/50 dark:to-blue-900/60 dark:text-blue-300">
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
Processing replacement
</Badge>
)}
</div>
</CardHeader>

View File

@ -178,7 +178,7 @@ export function WebSearchToolView({
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
<span className="font-mono text-xs break-all">{query}</span>
</p>
<Progress value={progress} className="w-full h-2" />
<Progress value={progress} className="w-full h-1" />
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">{progress}%</p>
</div>
</div>

View File

@ -2,7 +2,7 @@
import { initiateAgent, InitiateAgentResponse } from "@/lib/api";
import { createMutationHook } from "@/hooks/use-query";
import { toast } from "sonner";
import { handleApiSuccess, handleApiError } from "@/lib/error-handler";
import { dashboardKeys } from "./keys";
import { useQueryClient } from "@tanstack/react-query";
import { useModal } from "@/hooks/use-modal-store";
@ -14,25 +14,15 @@ export const useInitiateAgentMutation = createMutationHook<
>(
initiateAgent,
{
errorContext: { operation: 'initiate agent', resource: 'AI assistant' },
onSuccess: (data) => {
toast.success("Agent initiated successfully");
handleApiSuccess("Agent initiated successfully", "Your AI assistant is ready to help");
},
onError: (error) => {
if (error instanceof Error) {
const errorMessage = error.message;
if (errorMessage.toLowerCase().includes("payment required")) {
return;
}
if (errorMessage.includes("Cannot connect to backend server")) {
toast.error("Connection error: Please check your internet connection and ensure the backend server is running");
} else if (errorMessage.includes("No access token available")) {
toast.error("Authentication error: Please sign in again");
} else {
toast.error(`Failed to initiate agent: ${errorMessage}`);
}
} else {
toast.error("An unexpected error occurred while initiating the agent");
if (error instanceof Error && error.message.toLowerCase().includes("payment required")) {
return;
}
handleApiError(error, { operation: 'initiate agent', resource: 'AI assistant' });
}
}
);

View File

@ -7,7 +7,7 @@ import {
UseMutationOptions,
QueryKey,
} from '@tanstack/react-query';
import { toast } from 'sonner';
import { handleApiError, ErrorContext } from '@/lib/error-handler';
type QueryKeyValue = readonly unknown[];
type QueryKeyFunction = (...args: any[]) => QueryKeyValue;
@ -54,25 +54,33 @@ export function createMutationHook<
options?: Omit<
UseMutationOptions<TData, TError, TVariables, TContext>,
'mutationFn'
>,
> & {
errorContext?: ErrorContext;
},
) {
return (
customOptions?: Omit<
UseMutationOptions<TData, TError, TVariables, TContext>,
'mutationFn'
>,
> & {
errorContext?: ErrorContext;
},
) => {
const { errorContext: baseErrorContext, ...baseOptions } = options || {};
const { errorContext: customErrorContext, ...customMutationOptions } = customOptions || {};
return useMutation<TData, TError, TVariables, TContext>({
mutationFn,
onError: (error, variables, context) => {
toast.error(
`An error occurred: ${error instanceof Error ? error.message : String(error)}`,
);
options?.onError?.(error, variables, context);
customOptions?.onError?.(error, variables, context);
const errorContext = customErrorContext || baseErrorContext;
if (!customMutationOptions?.onError && !baseOptions?.onError) {
handleApiError(error, errorContext);
}
baseOptions?.onError?.(error, variables, context);
customMutationOptions?.onError?.(error, variables, context);
},
...options,
...customOptions,
...baseOptions,
...customMutationOptions,
});
};
}

View File

@ -0,0 +1,243 @@
import { createClient } from '@/lib/supabase/client';
import { handleApiError, handleNetworkError, ErrorContext, ApiError } from './error-handler';
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
export interface ApiClientOptions {
showErrors?: boolean;
errorContext?: ErrorContext;
timeout?: number;
}
export interface ApiResponse<T = any> {
data?: T;
error?: ApiError;
success: boolean;
}
export const apiClient = {
async request<T = any>(
url: string,
options: RequestInit & ApiClientOptions = {}
): Promise<ApiResponse<T>> {
const {
showErrors = true,
errorContext,
timeout = 30000,
...fetchOptions
} = options;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...fetchOptions.headers as Record<string, string>,
};
if (session?.access_token) {
headers['Authorization'] = `Bearer ${session.access_token}`;
}
const response = await fetch(url, {
...fetchOptions,
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const error: ApiError = new Error(`HTTP ${response.status}: ${response.statusText}`);
error.status = response.status;
error.response = response;
try {
const errorData = await response.json();
error.details = errorData;
if (errorData.message) {
error.message = errorData.message;
}
} catch {
}
if (showErrors) {
handleApiError(error, errorContext);
}
return {
error,
success: false,
};
}
let data: T;
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
data = await response.json();
} else if (contentType?.includes('text/')) {
data = await response.text() as T;
} else {
data = await response.blob() as T;
}
return {
data,
success: true,
};
} catch (error: any) {
const apiError: ApiError = error instanceof Error ? error : new Error(String(error));
if (error.name === 'AbortError') {
apiError.message = 'Request timeout';
apiError.code = 'TIMEOUT';
}
if (showErrors) {
handleNetworkError(apiError, errorContext);
}
return {
error: apiError,
success: false,
};
}
},
get: async <T = any>(
url: string,
options: Omit<RequestInit & ApiClientOptions, 'method' | 'body'> = {}
): Promise<ApiResponse<T>> => {
return apiClient.request<T>(url, {
...options,
method: 'GET',
});
},
post: async <T = any>(
url: string,
data?: any,
options: Omit<RequestInit & ApiClientOptions, 'method'> = {}
): Promise<ApiResponse<T>> => {
return apiClient.request<T>(url, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
},
put: async <T = any>(
url: string,
data?: any,
options: Omit<RequestInit & ApiClientOptions, 'method'> = {}
): Promise<ApiResponse<T>> => {
return apiClient.request<T>(url, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
},
patch: async <T = any>(
url: string,
data?: any,
options: Omit<RequestInit & ApiClientOptions, 'method'> = {}
): Promise<ApiResponse<T>> => {
return apiClient.request<T>(url, {
...options,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
},
delete: async <T = any>(
url: string,
options: Omit<RequestInit & ApiClientOptions, 'method' | 'body'> = {}
): Promise<ApiResponse<T>> => {
return apiClient.request<T>(url, {
...options,
method: 'DELETE',
});
},
upload: async <T = any>(
url: string,
formData: FormData,
options: Omit<RequestInit & ApiClientOptions, 'method' | 'body'> = {}
): Promise<ApiResponse<T>> => {
const { headers, ...restOptions } = options;
const uploadHeaders = { ...headers as Record<string, string> };
delete uploadHeaders['Content-Type'];
return apiClient.request<T>(url, {
...restOptions,
method: 'POST',
body: formData,
headers: uploadHeaders,
});
},
};
export const supabaseClient = {
async execute<T = any>(
queryFn: () => Promise<{ data: T | null; error: any }>,
errorContext?: ErrorContext
): Promise<ApiResponse<T>> {
try {
const { data, error } = await queryFn();
if (error) {
const apiError: ApiError = new Error(error.message || 'Database error');
apiError.code = error.code;
apiError.details = error;
handleApiError(apiError, errorContext);
return {
error: apiError,
success: false,
};
}
return {
data: data as T,
success: true,
};
} catch (error: any) {
const apiError: ApiError = error instanceof Error ? error : new Error(String(error));
handleApiError(apiError, errorContext);
return {
error: apiError,
success: false,
};
}
},
};
export const backendApi = {
get: <T = any>(endpoint: string, options?: Omit<RequestInit & ApiClientOptions, 'method' | 'body'>) =>
apiClient.get<T>(`${API_URL}${endpoint}`, options),
post: <T = any>(endpoint: string, data?: any, options?: Omit<RequestInit & ApiClientOptions, 'method'>) =>
apiClient.post<T>(`${API_URL}${endpoint}`, data, options),
put: <T = any>(endpoint: string, data?: any, options?: Omit<RequestInit & ApiClientOptions, 'method'>) =>
apiClient.put<T>(`${API_URL}${endpoint}`, data, options),
patch: <T = any>(endpoint: string, data?: any, options?: Omit<RequestInit & ApiClientOptions, 'method'>) =>
apiClient.patch<T>(`${API_URL}${endpoint}`, data, options),
delete: <T = any>(endpoint: string, options?: Omit<RequestInit & ApiClientOptions, 'method' | 'body'>) =>
apiClient.delete<T>(`${API_URL}${endpoint}`, options),
upload: <T = any>(endpoint: string, formData: FormData, options?: Omit<RequestInit & ApiClientOptions, 'method' | 'body'>) =>
apiClient.upload<T>(`${API_URL}${endpoint}`, formData, options),
};

View File

@ -0,0 +1,470 @@
import { createClient } from '@/lib/supabase/client';
import { backendApi, supabaseClient } from './api-client';
import { handleApiSuccess } from './error-handler';
import {
Project,
Thread,
Message,
AgentRun,
InitiateAgentResponse,
HealthCheckResponse,
FileInfo,
CreateCheckoutSessionRequest,
CreateCheckoutSessionResponse,
CreatePortalSessionRequest,
SubscriptionStatus,
AvailableModelsResponse,
BillingStatusResponse,
BillingError
} from './api';
export * from './api';
export const projectsApi = {
async getAll(): Promise<Project[]> {
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError) {
return { data: null, error: userError };
}
if (!userData.user) {
return { data: [], error: null };
}
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('account_id', userData.user.id);
if (error) {
if (error.code === '42501' && error.message.includes('has_role_on_account')) {
return { data: [], error: null };
}
return { data: null, error };
}
const mappedProjects: Project[] = (data || []).map((project) => ({
id: project.project_id,
name: project.name || '',
description: project.description || '',
account_id: project.account_id,
created_at: project.created_at,
updated_at: project.updated_at,
sandbox: project.sandbox || {
id: '',
pass: '',
vnc_preview: '',
sandbox_url: '',
},
}));
return { data: mappedProjects, error: null };
},
{ operation: 'load projects', resource: 'projects' }
);
return result.data || [];
},
async getById(projectId: string): Promise<Project | null> {
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('project_id', projectId)
.single();
if (error) {
if (error.code === 'PGRST116') {
return { data: null, error: new Error(`Project not found: ${projectId}`) };
}
return { data: null, error };
}
// Ensure sandbox is active if it exists
if (data.sandbox?.id) {
backendApi.post(`/project/${projectId}/sandbox/ensure-active`, undefined, {
showErrors: false,
errorContext: { silent: true }
});
}
const mappedProject: Project = {
id: data.project_id,
name: data.name || '',
description: data.description || '',
account_id: data.account_id,
is_public: data.is_public || false,
created_at: data.created_at,
sandbox: data.sandbox || {
id: '',
pass: '',
vnc_preview: '',
sandbox_url: '',
},
};
return { data: mappedProject, error: null };
},
{ operation: 'load project', resource: `project ${projectId}` }
);
return result.data || null;
},
async create(projectData: { name: string; description: string }, accountId?: string): Promise<Project | null> {
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
if (!accountId) {
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError) return { data: null, error: userError };
if (!userData.user) return { data: null, error: new Error('You must be logged in to create a project') };
accountId = userData.user.id;
}
const { data, error } = await supabase
.from('projects')
.insert({
name: projectData.name,
description: projectData.description || null,
account_id: accountId,
})
.select()
.single();
if (error) return { data: null, error };
const project: Project = {
id: data.project_id,
name: data.name,
description: data.description || '',
account_id: data.account_id,
created_at: data.created_at,
sandbox: { id: '', pass: '', vnc_preview: '' },
};
return { data: project, error: null };
},
{ operation: 'create project', resource: 'project' }
);
if (result.success && result.data) {
handleApiSuccess('Project created successfully', `"${result.data.name}" is ready to use`);
}
return result.data || null;
},
async update(projectId: string, data: Partial<Project>): Promise<Project | null> {
if (!projectId || projectId === '') {
throw new Error('Cannot update project: Invalid project ID');
}
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
const { data: updatedData, error } = await supabase
.from('projects')
.update(data)
.eq('project_id', projectId)
.select()
.single();
if (error) return { data: null, error };
if (!updatedData) return { data: null, error: new Error('No data returned from update') };
// Dispatch custom event for project updates
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('project-updated', {
detail: {
projectId,
updatedData: {
id: updatedData.project_id,
name: updatedData.name,
description: updatedData.description,
},
},
}),
);
}
const project: Project = {
id: updatedData.project_id,
name: updatedData.name,
description: updatedData.description || '',
account_id: updatedData.account_id,
created_at: updatedData.created_at,
sandbox: updatedData.sandbox || {
id: '',
pass: '',
vnc_preview: '',
sandbox_url: '',
},
};
return { data: project, error: null };
},
{ operation: 'update project', resource: `project ${projectId}` }
);
if (result.success && result.data) {
handleApiSuccess('Project updated successfully');
}
return result.data || null;
},
async delete(projectId: string): Promise<boolean> {
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
const { error } = await supabase
.from('projects')
.delete()
.eq('project_id', projectId);
return { data: !error, error };
},
{ operation: 'delete project', resource: `project ${projectId}` }
);
if (result.success) {
handleApiSuccess('Project deleted successfully');
}
return result.success;
},
};
export const threadsApi = {
async getAll(projectId?: string): Promise<Thread[]> {
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError) return { data: null, error: userError };
if (!userData.user) return { data: [], error: null };
let query = supabase.from('threads').select('*').eq('account_id', userData.user.id);
if (projectId) {
query = query.eq('project_id', projectId);
}
const { data, error } = await query;
if (error) return { data: null, error };
const mappedThreads: Thread[] = (data || []).map((thread) => ({
thread_id: thread.thread_id,
account_id: thread.account_id,
project_id: thread.project_id,
created_at: thread.created_at,
updated_at: thread.updated_at,
}));
return { data: mappedThreads, error: null };
},
{ operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' }
);
return result.data || [];
},
async getById(threadId: string): Promise<Thread | null> {
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
const { data, error } = await supabase
.from('threads')
.select('*')
.eq('thread_id', threadId)
.single();
return { data, error };
},
{ operation: 'load thread', resource: `thread ${threadId}` }
);
return result.data || null;
},
async create(projectId: string): Promise<Thread | null> {
const result = await supabaseClient.execute(
async () => {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { data: null, error: new Error('You must be logged in to create a thread') };
}
const { data, error } = await supabase
.from('threads')
.insert({
project_id: projectId,
account_id: user.id,
})
.select()
.single();
return { data, error };
},
{ operation: 'create thread', resource: 'thread' }
);
if (result.success && result.data) {
handleApiSuccess('New conversation started');
}
return result.data || null;
},
};
export const agentApi = {
async start(
threadId: string,
options?: {
model_name?: string;
enable_thinking?: boolean;
reasoning_effort?: string;
stream?: boolean;
}
): Promise<{ agent_run_id: string } | null> {
const result = await backendApi.post(
`/thread/${threadId}/agent/start`,
options,
{
errorContext: { operation: 'start agent', resource: 'AI assistant' },
timeout: 60000,
}
);
if (result.success && result.data) {
handleApiSuccess('AI assistant started', 'Your request is being processed');
}
return result.data || null;
},
async stop(agentRunId: string): Promise<boolean> {
const result = await backendApi.post(
`/agent/${agentRunId}/stop`,
undefined,
{
errorContext: { operation: 'stop agent', resource: 'AI assistant' },
}
);
if (result.success) {
handleApiSuccess('AI assistant stopped');
}
return result.success;
},
async getStatus(agentRunId: string): Promise<AgentRun | null> {
const result = await backendApi.get(
`/agent/${agentRunId}/status`,
{
errorContext: { operation: 'get agent status', resource: 'AI assistant status' },
showErrors: false,
}
);
return result.data || null;
},
async getRuns(threadId: string): Promise<AgentRun[]> {
const result = await backendApi.get(
`/thread/${threadId}/agent/runs`,
{
errorContext: { operation: 'load agent runs', resource: 'conversation history' },
}
);
return result.data || [];
},
};
export const billingApi = {
async getSubscription(): Promise<SubscriptionStatus | null> {
const result = await backendApi.get(
'/billing/subscription',
{
errorContext: { operation: 'load subscription', resource: 'billing information' },
}
);
return result.data || null;
},
async checkStatus(): Promise<BillingStatusResponse | null> {
const result = await backendApi.get(
'/billing/status',
{
errorContext: { operation: 'check billing status', resource: 'account status' },
}
);
return result.data || null;
},
async createCheckoutSession(request: CreateCheckoutSessionRequest): Promise<CreateCheckoutSessionResponse | null> {
const result = await backendApi.post(
'/billing/create-checkout-session',
request,
{
errorContext: { operation: 'create checkout session', resource: 'billing' },
}
);
return result.data || null;
},
async createPortalSession(request: CreatePortalSessionRequest): Promise<{ url: string } | null> {
const result = await backendApi.post(
'/billing/create-portal-session',
request,
{
errorContext: { operation: 'create portal session', resource: 'billing portal' },
}
);
return result.data || null;
},
async getAvailableModels(): Promise<AvailableModelsResponse | null> {
const result = await backendApi.get(
'/billing/available-models',
{
errorContext: { operation: 'load available models', resource: 'AI models' },
}
);
return result.data || null;
},
};
export const healthApi = {
async check(): Promise<HealthCheckResponse | null> {
const result = await backendApi.get(
'/health',
{
errorContext: { operation: 'check system health', resource: 'system status' },
timeout: 10000,
}
);
return result.data || null;
},
};

View File

@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/client';
import { handleApiError } from './error-handler';
// Get backend URL from environment variables
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
@ -158,6 +159,7 @@ export const getProjects = async (): Promise<Project[]> => {
return mappedProjects;
} catch (err) {
console.error('Error fetching projects:', err);
handleApiError(err, { operation: 'load projects', resource: 'projects' });
// Return empty array for permission errors to avoid crashing the UI
return [];
}
@ -251,6 +253,7 @@ export const getProject = async (projectId: string): Promise<Project> => {
return mappedProject;
} catch (error) {
console.error(`Error fetching project ${projectId}:`, error);
handleApiError(error, { operation: 'load project', resource: `project ${projectId}` });
throw error;
}
};
@ -283,10 +286,12 @@ export const createProject = async (
.select()
.single();
if (error) throw error;
if (error) {
handleApiError(error, { operation: 'create project', resource: 'project' });
throw error;
}
// Map the database response to our Project type
return {
const project = {
id: data.project_id,
name: data.name,
description: data.description || '',
@ -294,6 +299,7 @@ export const createProject = async (
created_at: data.created_at,
sandbox: { id: '', pass: '', vnc_preview: '' },
};
return project;
};
export const updateProject = async (
@ -320,11 +326,14 @@ export const updateProject = async (
if (error) {
console.error('Error updating project:', error);
handleApiError(error, { operation: 'update project', resource: `project ${projectId}` });
throw error;
}
if (!updatedData) {
throw new Error('No data returned from update');
const noDataError = new Error('No data returned from update');
handleApiError(noDataError, { operation: 'update project', resource: `project ${projectId}` });
throw noDataError;
}
// Dispatch a custom event to notify components about the project change
@ -344,7 +353,7 @@ export const updateProject = async (
}
// Return formatted project data - use same mapping as getProject
return {
const project = {
id: updatedData.project_id,
name: updatedData.name,
description: updatedData.description || '',
@ -357,6 +366,7 @@ export const updateProject = async (
sandbox_url: '',
},
};
return project;
};
export const deleteProject = async (projectId: string): Promise<void> => {
@ -366,7 +376,10 @@ export const deleteProject = async (projectId: string): Promise<void> => {
.delete()
.eq('project_id', projectId);
if (error) throw error;
if (error) {
handleApiError(error, { operation: 'delete project', resource: `project ${projectId}` });
throw error;
}
};
// Thread APIs
@ -400,6 +413,7 @@ export const getThreads = async (projectId?: string): Promise<Thread[]> => {
if (error) {
console.error('[API] Error fetching threads:', error);
handleApiError(error, { operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' });
throw error;
}
@ -425,7 +439,10 @@ export const getThread = async (threadId: string): Promise<Thread> => {
.eq('thread_id', threadId)
.single();
if (error) throw error;
if (error) {
handleApiError(error, { operation: 'load thread', resource: `thread ${threadId}` });
throw error;
}
return data;
};
@ -450,8 +467,10 @@ export const createThread = async (projectId: string): Promise<Thread> => {
.select()
.single();
if (error) throw error;
if (error) {
handleApiError(error, { operation: 'create thread', resource: 'thread' });
throw error;
}
return data;
};
@ -477,6 +496,7 @@ export const addUserMessage = async (
if (error) {
console.error('Error adding user message:', error);
handleApiError(error, { operation: 'add message', resource: 'message' });
throw new Error(`Error adding message: ${error.message}`);
}
};
@ -494,6 +514,7 @@ export const getMessages = async (threadId: string): Promise<Message[]> => {
if (error) {
console.error('Error fetching messages:', error);
handleApiError(error, { operation: 'load messages', resource: `messages for thread ${threadId}` });
throw new Error(`Error getting messages: ${error.message}`);
}
@ -584,7 +605,8 @@ export const startAgent = async (
);
}
return response.json();
const result = await response.json();
return result;
} catch (error) {
// Rethrow BillingError instances directly
if (error instanceof BillingError) {
@ -592,18 +614,21 @@ export const startAgent = async (
}
console.error('[API] Failed to start agent:', error);
// Provide clearer error message for network errors
// Handle different error types with appropriate user messages
if (
error instanceof TypeError &&
error.message.includes('Failed to fetch')
) {
throw new Error(
const networkError = new Error(
`Cannot connect to backend server. Please check your internet connection and make sure the backend is running.`,
);
handleApiError(networkError, { operation: 'start agent', resource: 'AI assistant' });
throw networkError;
}
// Rethrow other caught errors
// For other errors, add context and rethrow
handleApiError(error, { operation: 'start agent', resource: 'AI assistant' });
throw error;
}
};
@ -628,7 +653,9 @@ export const stopAgent = async (agentRunId: string): Promise<void> => {
} = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
const authError = new Error('No access token available');
handleApiError(authError, { operation: 'stop agent', resource: 'AI assistant' });
throw authError;
}
const response = await fetch(`${API_URL}/agent-run/${agentRunId}/stop`, {
@ -642,7 +669,9 @@ export const stopAgent = async (agentRunId: string): Promise<void> => {
});
if (!response.ok) {
throw new Error(`Error stopping agent: ${response.statusText}`);
const stopError = new Error(`Error stopping agent: ${response.statusText}`);
handleApiError(stopError, { operation: 'stop agent', resource: 'AI assistant' });
throw stopError;
}
};
@ -709,6 +738,7 @@ export const getAgentStatus = async (agentRunId: string): Promise<AgentRun> => {
return data;
} catch (error) {
console.error('[API] Failed to get agent status:', error);
handleApiError(error, { operation: 'get agent status', resource: 'AI assistant status', silent: true });
throw error;
}
};
@ -740,6 +770,7 @@ export const getAgentRuns = async (threadId: string): Promise<AgentRun[]> => {
return data.agent_runs || [];
} catch (error) {
console.error('Failed to get agent runs:', error);
handleApiError(error, { operation: 'load agent runs', resource: 'conversation history' });
throw error;
}
};
@ -1076,9 +1107,11 @@ export const createSandboxFile = async (
);
}
return response.json();
const result = await response.json();
return result;
} catch (error) {
console.error('Failed to create sandbox file:', error);
handleApiError(error, { operation: 'create file', resource: `file ${filePath}` });
throw error;
}
};
@ -1128,9 +1161,11 @@ export const createSandboxFileJson = async (
);
}
return response.json();
const result = await response.json();
return result;
} catch (error) {
console.error('Failed to create sandbox file with JSON:', error);
handleApiError(error, { operation: 'create file', resource: `file ${filePath}` });
throw error;
}
};
@ -1192,6 +1227,7 @@ export const listSandboxFiles = async (
return data.files || [];
} catch (error) {
console.error('Failed to list sandbox files:', error);
handleApiError(error, { operation: 'list files', resource: `directory ${path}` });
throw error;
}
};
@ -1248,6 +1284,7 @@ export const getSandboxFileContent = async (
}
} catch (error) {
console.error('Failed to get sandbox file content:', error);
handleApiError(error, { operation: 'load file content', resource: `file ${path}` });
throw error;
}
};
@ -1325,6 +1362,7 @@ export const getPublicProjects = async (): Promise<Project[]> => {
return mappedProjects;
} catch (err) {
console.error('Error fetching public projects:', err);
handleApiError(err, { operation: 'load public projects', resource: 'public projects' });
return [];
}
};
@ -1385,7 +1423,8 @@ export const initiateAgent = async (
);
}
return response.json();
const result = await response.json();
return result;
} catch (error) {
console.error('[API] Failed to initiate agent:', error);
@ -1393,11 +1432,13 @@ export const initiateAgent = async (
error instanceof TypeError &&
error.message.includes('Failed to fetch')
) {
throw new Error(
const networkError = new Error(
`Cannot connect to backend server. Please check your internet connection and make sure the backend is running.`,
);
handleApiError(networkError, { operation: 'initiate agent', resource: 'AI assistant' });
throw networkError;
}
handleApiError(error, { operation: 'initiate agent' });
throw error;
}
};
@ -1415,6 +1456,7 @@ export const checkApiHealth = async (): Promise<HealthCheckResponse> => {
return response.json();
} catch (error) {
console.error('API health check failed:', error);
handleApiError(error, { operation: 'check system health', resource: 'system status' });
throw error;
}
};
@ -1561,6 +1603,7 @@ export const createCheckoutSession = async (
}
} catch (error) {
console.error('Failed to create checkout session:', error);
handleApiError(error, { operation: 'create checkout session', resource: 'billing' });
throw error;
}
};
@ -1604,6 +1647,7 @@ export const createPortalSession = async (
return response.json();
} catch (error) {
console.error('Failed to create portal session:', error);
handleApiError(error, { operation: 'create portal session', resource: 'billing portal' });
throw error;
}
};
@ -1642,6 +1686,7 @@ export const getSubscription = async (): Promise<SubscriptionStatus> => {
return response.json();
} catch (error) {
console.error('Failed to get subscription:', error);
handleApiError(error, { operation: 'load subscription', resource: 'billing information' });
throw error;
}
};
@ -1679,6 +1724,7 @@ export const getAvailableModels = async (): Promise<AvailableModelsResponse> =>
return response.json();
} catch (error) {
console.error('Failed to get available models:', error);
handleApiError(error, { operation: 'load available models', resource: 'AI models' });
throw error;
}
};
@ -1717,6 +1763,7 @@ export const checkBillingStatus = async (): Promise<BillingStatusResponse> => {
return response.json();
} catch (error) {
console.error('Failed to check billing status:', error);
handleApiError(error, { operation: 'check billing status', resource: 'account status' });
throw error;
}
};

View File

@ -0,0 +1,195 @@
import { toast } from 'sonner';
import { BillingError } from './api';
export interface ApiError extends Error {
status?: number;
code?: string;
details?: any;
response?: Response;
}
export interface ErrorContext {
operation?: string;
resource?: string;
silent?: boolean;
}
const getStatusMessage = (status: number): string => {
switch (status) {
case 400:
return 'Invalid request. Please check your input and try again.';
case 401:
return 'Authentication required. Please sign in again.';
case 403:
return 'Access denied. You don\'t have permission to perform this action.';
case 404:
return 'The requested resource was not found.';
case 408:
return 'Request timeout. Please try again.';
case 409:
return 'Conflict detected. The resource may have been modified by another user.';
case 422:
return 'Invalid data provided. Please check your input.';
case 429:
return 'Too many requests. Please wait a moment and try again.';
case 500:
return 'Server error. Our team has been notified.';
case 502:
return 'Service temporarily unavailable. Please try again in a moment.';
case 503:
return 'Service maintenance in progress. Please try again later.';
case 504:
return 'Request timeout. The server took too long to respond.';
default:
return 'An unexpected error occurred. Please try again.';
}
};
const extractErrorMessage = (error: any): string => {
if (error instanceof BillingError) {
return error.detail?.message || error.message || 'Billing issue detected';
}
if (error instanceof Error) {
return error.message;
}
if (error?.response) {
const status = error.response.status;
return getStatusMessage(status);
}
if (error?.status) {
return getStatusMessage(error.status);
}
if (typeof error === 'string') {
return error;
}
if (error?.message) {
return error.message;
}
if (error?.error) {
return typeof error.error === 'string' ? error.error : error.error.message || 'Unknown error';
}
return 'An unexpected error occurred';
};
const shouldShowError = (error: any, context?: ErrorContext): boolean => {
if (context?.silent) {
return false;
}
if (error instanceof BillingError) {
return false;
}
if (error?.status === 404 && context?.resource) {
return false;
}
return true;
};
const formatErrorMessage = (message: string, context?: ErrorContext): string => {
if (!context?.operation && !context?.resource) {
return message;
}
const parts = [];
if (context.operation) {
parts.push(`Failed to ${context.operation}`);
}
if (context.resource) {
parts.push(context.resource);
}
const prefix = parts.join(' ');
if (message.toLowerCase().includes(context.operation?.toLowerCase() || '')) {
return message;
}
return `${prefix}: ${message}`;
};
export const handleApiError = (error: any, context?: ErrorContext): void => {
console.error('API Error:', error, context);
if (!shouldShowError(error, context)) {
return;
}
const rawMessage = extractErrorMessage(error);
const formattedMessage = formatErrorMessage(rawMessage, context);
if (error?.status >= 500) {
toast.error(formattedMessage, {
description: 'Our team has been notified and is working on a fix.',
duration: 6000,
});
} else if (error?.status === 401) {
toast.error(formattedMessage, {
description: 'Please refresh the page and sign in again.',
duration: 8000,
});
} else if (error?.status === 403) {
toast.error(formattedMessage, {
description: 'Contact support if you believe this is an error.',
duration: 6000,
});
} else if (error?.status === 429) {
toast.warning(formattedMessage, {
description: 'Please wait a moment before trying again.',
duration: 5000,
});
} else {
toast.error(formattedMessage, {
duration: 5000,
});
}
};
export const handleNetworkError = (error: any, context?: ErrorContext): void => {
const isNetworkError =
error?.message?.includes('fetch') ||
error?.message?.includes('network') ||
error?.message?.includes('connection') ||
error?.code === 'NETWORK_ERROR' ||
!navigator.onLine;
if (isNetworkError) {
toast.error('Connection error', {
description: 'Please check your internet connection and try again.',
duration: 6000,
});
} else {
handleApiError(error, context);
}
};
export const handleApiSuccess = (message: string, description?: string): void => {
toast.success(message, {
description,
duration: 3000,
});
};
export const handleApiWarning = (message: string, description?: string): void => {
toast.warning(message, {
description,
duration: 4000,
});
};
export const handleApiInfo = (message: string, description?: string): void => {
toast.info(message, {
description,
duration: 3000,
});
};

View File

@ -7,6 +7,7 @@ import {
QueryClientProvider,
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { handleApiError } from '@/lib/error-handler';
export function ReactQueryProvider({
children,
@ -23,6 +24,7 @@ export function ReactQueryProvider({
staleTime: 20 * 1000,
gcTime: 5 * 60 * 1000,
retry: (failureCount, error: any) => {
if (error?.status >= 400 && error?.status < 500) return false;
if (error?.status === 404) return false;
return failureCount < 3;
},
@ -31,7 +33,16 @@ export function ReactQueryProvider({
refetchOnReconnect: 'always',
},
mutations: {
retry: 1,
retry: (failureCount, error: any) => {
if (error?.status >= 400 && error?.status < 500) return false;
return failureCount < 1;
},
onError: (error: any, variables: any, context: any) => {
handleApiError(error, {
operation: 'perform action',
silent: false,
});
},
},
},
}),