AgentSkillsCN

otto-frontend

奥托仪表板开发的前端专家。在使用React组件、页面、Hook,或在client/src/目录下进行任何客户端代码开发时使用此功能。

SKILL.md
--- frontmatter
name: otto-frontend
description: Frontend specialist for Otto dashboard development. Use when working on React components, pages, hooks, or any client-side code in client/src/.
user-invocable: false

Otto Frontend Specialist

Expert context for developing the Otto Agent dashboard (React + Vite).

Tech Stack

  • React 19 with TypeScript 5.9
  • Vite 7 for bundling
  • Tailwind CSS 4 for styling
  • shadcn/ui component library
  • React Router v7 for navigation
  • Clerk for authentication
  • XTerm.js for terminal emulation
  • Monaco Editor for code editing

Architecture Overview

code
client/src/
├── pages/           # Route components (16 pages)
├── components/      # Reusable UI components
│   └── ui/          # shadcn/ui base components
├── hooks/           # Custom React hooks
├── contexts/        # React Context providers
├── layouts/         # Page layout wrappers
├── lib/             # Utilities (api client, utils)
├── providers/       # App-level providers
└── types/           # TypeScript interfaces

Component Patterns

Page Component

Location: client/src/pages/

tsx
import { useParams } from 'react-router-dom';
import { useOrganization } from '../hooks/useOrganization';
import { PageHeader } from '../components/PageHeader';

export function ProjectPage() {
  const { projectId } = useParams<{ projectId: string }>();
  const { organization } = useOrganization();

  const { data: project, isLoading } = useQuery({
    queryKey: ['project', projectId],
    queryFn: () => api.getProject(projectId!),
    enabled: !!projectId,
  });

  if (isLoading) return <LoadingSpinner />;
  if (!project) return <NotFound />;

  return (
    <div className="container mx-auto py-6">
      <PageHeader title={project.name} />
      {/* Page content */}
    </div>
  );
}

Reusable Component

Location: client/src/components/

tsx
import { cn } from '@/lib/utils';

interface StatusBadgeProps {
  status: 'pending' | 'running' | 'completed' | 'failed';
  className?: string;
}

export function StatusBadge({ status, className }: StatusBadgeProps) {
  const variants = {
    pending: 'bg-yellow-100 text-yellow-800',
    running: 'bg-blue-100 text-blue-800',
    completed: 'bg-green-100 text-green-800',
    failed: 'bg-red-100 text-red-800',
  };

  return (
    <span className={cn(
      'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
      variants[status],
      className
    )}>
      {status}
    </span>
  );
}

API Client Pattern

Location: client/src/lib/api.ts

tsx
import { useAuth } from '@clerk/clerk-react';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3005';

export function useApi() {
  const { getToken } = useAuth();
  const organizationId = useOrganizationId();

  const fetchWithAuth = async (path: string, options: RequestInit = {}) => {
    const token = await getToken();

    const response = await fetch(`${API_URL}${path}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
        'X-Organization-Id': organizationId,
        ...options.headers,
      },
    });

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return response.json();
  };

  return {
    get: (path: string) => fetchWithAuth(path),
    post: (path: string, data: unknown) =>
      fetchWithAuth(path, { method: 'POST', body: JSON.stringify(data) }),
    put: (path: string, data: unknown) =>
      fetchWithAuth(path, { method: 'PUT', body: JSON.stringify(data) }),
    delete: (path: string) =>
      fetchWithAuth(path, { method: 'DELETE' }),
  };
}

Hook Patterns

Location: client/src/hooks/

tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useApi } from '@/lib/api';

export function useProjects() {
  const api = useApi();

  return useQuery({
    queryKey: ['projects'],
    queryFn: () => api.get('/api/projects'),
  });
}

export function useCreateProject() {
  const api = useApi();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateProjectInput) =>
      api.post('/api/projects', data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['projects'] });
    },
  });
}

WebSocket Pattern

tsx
import { useEffect, useRef } from 'react';
import { useAuth } from '@clerk/clerk-react';

export function useRunLogs(runId: string, onLog: (log: string) => void) {
  const { getToken } = useAuth();
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    let ws: WebSocket;

    const connect = async () => {
      const token = await getToken();
      ws = new WebSocket(`ws://localhost:3005/ws?token=${token}`);
      wsRef.current = ws;

      ws.onopen = () => {
        ws.send(JSON.stringify({
          type: 'subscribeRun',
          data: { runId },
        }));
      };

      ws.onmessage = (event) => {
        const msg = JSON.parse(event.data);
        if (msg.type === 'run.log') {
          onLog(msg.data.content);
        }
      };
    };

    connect();

    return () => {
      ws?.close();
    };
  }, [runId, getToken, onLog]);

  return wsRef;
}

Terminal Component (XTerm.js)

tsx
import { useEffect, useRef } from 'react';
import { Terminal } from '@xterm/xterm';
import { WebglAddon } from '@xterm/addon-webgl';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';

interface TerminalViewProps {
  logs: string[];
}

export function TerminalView({ logs }: TerminalViewProps) {
  const terminalRef = useRef<HTMLDivElement>(null);
  const xtermRef = useRef<Terminal | null>(null);

  useEffect(() => {
    if (!terminalRef.current) return;

    const term = new Terminal({
      theme: { background: '#1a1a2e' },
      fontSize: 14,
      fontFamily: 'JetBrains Mono, monospace',
    });

    const fitAddon = new FitAddon();
    term.loadAddon(fitAddon);
    term.loadAddon(new WebglAddon());

    term.open(terminalRef.current);
    fitAddon.fit();
    xtermRef.current = term;

    return () => term.dispose();
  }, []);

  useEffect(() => {
    logs.forEach(log => xtermRef.current?.writeln(log));
  }, [logs]);

  return <div ref={terminalRef} className="h-full w-full" />;
}

shadcn/ui Usage

Components are in client/src/components/ui/. Import and use directly:

tsx
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';

Add new components: npx shadcn-ui@latest add <component>

Tailwind CSS Patterns

tsx
// Conditional classes with cn()
import { cn } from '@/lib/utils';

<div className={cn(
  'p-4 rounded-lg border',
  isActive && 'border-blue-500 bg-blue-50',
  isError && 'border-red-500 bg-red-50'
)} />

// Common patterns
<div className="container mx-auto py-6" />           // Page container
<div className="flex items-center gap-4" />          // Horizontal flex
<div className="grid grid-cols-3 gap-4" />           // Grid layout
<div className="space-y-4" />                        // Vertical spacing

Routing

Location: client/src/App.tsx or router config

tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { MainLayout } from './layouts/MainLayout';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<MainLayout />}>
          <Route path="/" element={<DashboardPage />} />
          <Route path="/projects" element={<ProjectsPage />} />
          <Route path="/projects/:projectId" element={<ProjectPage />} />
          <Route path="/settings" element={<SettingsPage />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

Key Pages Reference

PagePathPurpose
DashboardPage/Org overview, stats, activity
ProjectsPage/projectsProject list
ProjectPage/projects/:idProject details, runs, stories
StoriesPage/projects/:id/storiesKanban board
RunPage/projects/:id/runs/:runIdRun logs, terminal
AgentsPage/agentsAgent pool management
SettingsPage/settingsOrg settings, Claude auth
BillingPage/billingUsage, costs

Key Components Reference

ComponentPurpose
KanbanDrag-and-drop story board
TerminalViewXTerm.js log viewer
PRDEditorMonaco-based PRD editing
RunCardRun status display
StoryCardStory with status, assignee
AgentCardAgent status, workload

Context Providers

tsx
// Organization context
import { useOrganization } from '@/contexts/OrganizationContext';
const { organization, setOrganization } = useOrganization();

// Theme context (if exists)
import { useTheme } from '@/contexts/ThemeContext';
const { theme, setTheme } = useTheme();

Environment Variables

Access in components via import.meta.env:

tsx
const apiUrl = import.meta.env.VITE_API_URL;
const clerkKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;

Define in client/.env:

code
VITE_API_URL=http://localhost:3005
VITE_CLERK_PUBLISHABLE_KEY=pk_test_xxx

Path Aliases

@ maps to client/src/:

tsx
import { Button } from '@/components/ui/button';
import { useProjects } from '@/hooks/useProjects';
import { cn } from '@/lib/utils';

Commands

bash
npm run client:dev      # Start dev server (port 5173)
npm run client:build    # Production build
npm run client:preview  # Preview production build

Vite Proxy

API calls to /api/* and /ws/* are proxied to localhost:3005 in development. No CORS issues.

Testing

bash
npm run test:dashboard  # Run dashboard tests

Test files: *.test.tsx next to components.