OS Trading Engine
Technical Documentation
Frontend
Component Patterns

Component Patterns

Nexgent uses shadcn/ui as the component library foundation, built on Radix UI primitives with Tailwind CSS styling.

Component Library

shadcn/ui

shadcn/ui provides unstyled, accessible components that are copied into the project:

shared/components/ui/
├── button.tsx
├── card.tsx
├── dialog.tsx
├── dropdown-menu.tsx
├── form.tsx
├── input.tsx
├── select.tsx
├── table.tsx
├── tabs.tsx
├── toast.tsx
└── ...

Unlike npm packages, shadcn/ui components are copied into your project. This gives full control over styling and behavior while maintaining accessibility.

Installation

npx shadcn@latest add button card dialog

Components are installed to shared/components/ui/ and can be customized directly.


Component Categories

UI Components (shared/components/ui/)

Base components from shadcn/ui:

import { Button } from '@/shared/components/ui/button';
import { Card, CardHeader, CardContent } from '@/shared/components/ui/card';
import { Dialog, DialogTrigger, DialogContent } from '@/shared/components/ui/dialog';

Layout Components (shared/components/layout/)

Application layout components:

import { AppSidebar } from '@/shared/components/layout/app-sidebar';
import { NavMain } from '@/shared/components/layout/nav-main';
import { NavUser } from '@/shared/components/layout/nav-user';

Feature Components (features/*/components/)

Domain-specific components:

import { LivePositionsTable } from '@/features/positions';
import { AgentSwitcher } from '@/features/agents';
import { TradingSignalsTable } from '@/features/trading-signals';

Component Structure

Basic Component

// features/positions/components/live-positions-table/live-positions-table.tsx
 
'use client';
 
import { cn } from '@/shared/utils/cn';
import { Table, TableHeader, TableBody, TableRow, TableCell } from '@/shared/components/ui/table';
 
interface LivePositionsTableProps {
  className?: string;
}
 
export function LivePositionsTable({ className }: LivePositionsTableProps) {
  const { selectedAgentId } = useAgentSelection();
  const { positions, isConnected } = useWebSocket(selectedAgentId);
  
  return (
    <Card className={cn('', className)}>
      <CardHeader>
        <CardTitle>Live Positions</CardTitle>
      </CardHeader>
      <CardContent>
        <Table>
          {/* Table content */}
        </Table>
      </CardContent>
    </Card>
  );
}

Dialog Component

// features/positions/components/close-position-dialog/close-position-dialog.tsx
 
'use client';
 
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/shared/components/ui/dialog';
import { Button } from '@/shared/components/ui/button';
 
interface ClosePositionDialogProps {
  position: LivePosition;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}
 
export function ClosePositionDialog({ position, open, onOpenChange }: ClosePositionDialogProps) {
  const closePosition = useClosePosition();
  
  const handleClose = async () => {
    await closePosition.mutateAsync({
      agentId: position.agentId,
      positionId: position.id,
    });
    onOpenChange(false);
  };
  
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Close Position</DialogTitle>
          <DialogDescription>
            Are you sure you want to close your {position.tokenSymbol} position?
          </DialogDescription>
        </DialogHeader>
        
        <div className="py-4">
          <p>Current P/L: {formatCurrency(position.profitLossUsd)}</p>
        </div>
        
        <DialogFooter>
          <Button variant="outline" onClick={() => onOpenChange(false)}>
            Cancel
          </Button>
          <Button 
            variant="destructive" 
            onClick={handleClose}
            disabled={closePosition.isPending}
          >
            {closePosition.isPending ? 'Closing...' : 'Close Position'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Form Components

Forms use React Hook Form with shadcn/ui form components:

// features/agents/components/create-agent-dialog/create-agent-dialog.tsx
 
'use client';
 
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateAgentSchema } from 'nexgent-open-source-trading-engine/shared';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/shared/components/ui/form';
import { Input } from '@/shared/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
 
export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps) {
  const form = useForm({
    resolver: zodResolver(CreateAgentSchema),
    defaultValues: {
      name: '',
      tradingMode: 'simulation',
    },
  });
  
  const createAgent = useCreateAgent();
  
  const onSubmit = async (data: CreateAgentInput) => {
    await createAgent.mutateAsync(data);
    onOpenChange(false);
    form.reset();
  };
  
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Create Agent</DialogTitle>
        </DialogHeader>
        
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Name</FormLabel>
                  <FormControl>
                    <Input placeholder="My Trading Agent" {...field} />
                  </FormControl>
                  <FormDescription>
                    A name to identify this agent.
                  </FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            <FormField
              control={form.control}
              name="tradingMode"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Trading Mode</FormLabel>
                  <Select onValueChange={field.onChange} defaultValue={field.value}>
                    <FormControl>
                      <SelectTrigger>
                        <SelectValue placeholder="Select mode" />
                      </SelectTrigger>
                    </FormControl>
                    <SelectContent>
                      <SelectItem value="simulation">Simulation</SelectItem>
                      <SelectItem value="live">Live</SelectItem>
                    </SelectContent>
                  </Select>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            <DialogFooter>
              <Button type="submit" disabled={createAgent.isPending}>
                {createAgent.isPending ? 'Creating...' : 'Create Agent'}
              </Button>
            </DialogFooter>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  );
}

Loading States

Loading Components

// shared/components/loading/loading-spinner.tsx
export function LoadingSpinner({ className }: { className?: string }) {
  return (
    <div className={cn('animate-spin rounded-full border-2 border-muted border-t-primary', className)} />
  );
}
 
// shared/components/loading/card-skeleton.tsx
export function CardSkeleton() {
  return (
    <Card>
      <CardHeader>
        <Skeleton className="h-6 w-32" />
      </CardHeader>
      <CardContent>
        <Skeleton className="h-20 w-full" />
      </CardContent>
    </Card>
  );
}
 
// shared/components/loading/table-skeleton.tsx
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          {Array(4).fill(0).map((_, i) => (
            <TableHead key={i}><Skeleton className="h-4 w-20" /></TableHead>
          ))}
        </TableRow>
      </TableHeader>
      <TableBody>
        {Array(rows).fill(0).map((_, i) => (
          <TableRow key={i}>
            {Array(4).fill(0).map((_, j) => (
              <TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell>
            ))}
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Usage Pattern

function PositionsSection() {
  const { positions, isLoading } = usePositions();
  
  if (isLoading) {
    return <TableSkeleton rows={3} />;
  }
  
  if (positions.length === 0) {
    return <EmptyState message="No positions" />;
  }
  
  return <PositionsTable positions={positions} />;
}

Error Handling

Error Boundary

// shared/components/error/error-boundary.tsx
'use client';
 
import { Component, type ReactNode } from 'react';
 
interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}
 
interface State {
  hasError: boolean;
  error?: Error;
}
 
export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };
  
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }
  
  render() {
    if (this.state.hasError) {
      return this.props.fallback || <ErrorState error={this.state.error} />;
    }
    return this.props.children;
  }
}

Error State Component

// shared/components/error/error-state.tsx
export function ErrorState({ error, retry }: { error?: Error; retry?: () => void }) {
  return (
    <Card className="border-destructive">
      <CardContent className="flex flex-col items-center gap-4 py-8">
        <AlertCircle className="h-12 w-12 text-destructive" />
        <p className="text-sm text-muted-foreground">
          {error?.message || 'Something went wrong'}
        </p>
        {retry && (
          <Button variant="outline" onClick={retry}>
            Try Again
          </Button>
        )}
      </CardContent>
    </Card>
  );
}

Toast Notifications

// Using the toast hook
import { useToast } from '@/shared/hooks/use-toast';
 
function SaveButton() {
  const { toast } = useToast();
  const updateConfig = useUpdateConfig();
  
  const handleSave = async () => {
    try {
      await updateConfig.mutateAsync(data);
      toast({
        title: 'Settings saved',
        description: 'Your trading configuration has been updated.',
      });
    } catch (error) {
      toast({
        title: 'Error',
        description: error.message || 'Failed to save settings',
        variant: 'destructive',
      });
    }
  };
  
  return <Button onClick={handleSave}>Save</Button>;
}

Styling Patterns

Tailwind CSS

All styling uses Tailwind CSS utility classes:

<div className="flex flex-col gap-4 p-6">
  <h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
  <p className="text-sm text-muted-foreground">Manage your trading agents</p>
</div>

Class Merging

The cn utility merges Tailwind classes safely:

import { cn } from '@/shared/utils/cn';
 
function Card({ className, ...props }: CardProps) {
  return (
    <div className={cn('rounded-lg border bg-card p-6', className)} {...props} />
  );
}

Dark Mode

The app uses dark mode by default, configured in the root layout:

<html lang="en" className="dark">

CSS variables handle theming:

/* globals.css */
.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --card: 222.2 84% 4.9%;
  --primary: 210 40% 98%;
  --muted: 217.2 32.6% 17.5%;
  /* ... */
}