diff --git a/CHAT_WITH_PI_FLOW.md b/CHAT_WITH_PI_FLOW.md new file mode 100644 index 00000000..59533358 --- /dev/null +++ b/CHAT_WITH_PI_FLOW.md @@ -0,0 +1,521 @@ +# Chat with Pi - Flow Documentation + +This document describes the full-stack architecture and data flow of the Chat app in Officer.dev, which uses **Pi** (an AI coding agent) as the backend. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │ +│ │ InputArea │───▶│ usePiChat │───▶│ useChatWebSocket│ │ +│ │ (user input) │ │ (hook) │ │ (WS connection)│ │ +│ └──────────────┘ └───────┬────────┘ └───────┬────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────┐ ┌───────────────┐ │ +│ │ MessageList │◀───│ handleMessage │ │ +│ │ (UI updates) │ │ (event parser)│ │ +│ └────────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ BACKEND │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ server.tsx │ │ +│ │ '/api/pi/chat/ws' → upgradeWs() → piWebsocket handler │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ websocket.ts (piWebsocket) │ │ +│ │ │ │ +│ │ message() ──┬──▶ handleChat() ──▶ SessionManager │ │ +│ │ │ │ │ │ +│ │ │ ▼ │ │ +│ │ │ pi-bridge.ts │ │ +│ │ │ (spawn Pi subprocess) │ │ +│ │ │ │ │ │ +│ │ │ ▼ │ │ +│ │ │ Bun.spawn(['pi', '--mode', │ │ +│ │ │ 'rpc', ...]) │ │ +│ │ │ │ │ │ +│ │ │ ▼ │ │ +│ │ │ Parse JSON from stdout │ │ +│ │ │ │ │ │ +│ │ └────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ sendToClient() ──▶ WebSocket JSON │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ storage.ts │ │ +│ │ Save to: ~/.pi-sessions// │ │ +│ │ - meta.json (session metadata) │ │ +│ │ - messages.json (message history) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ + Pi subprocess + (AI Agent) +``` + +--- + +## Directory Structure + +### Frontend + +``` +src/workspaces/officerdev/src/ +├── apps/ +│ ├── Chat/ +│ │ ├── index.ts # Main entry, exports +│ │ ├── types.ts # TypeScript types +│ │ ├── ChatPanelWrapper.tsx # Main wrapper component +│ │ ├── ChatList.tsx # Session list sidebar +│ │ ├── ChatLauncher.tsx # Launch chat +│ │ ├── EmbeddableChat.tsx # Embeddable version +│ │ ├── components/ +│ │ │ ├── InputArea.tsx # User input +│ │ │ ├── MessageList.tsx # Message display +│ │ │ ├── MessageBubble.tsx # Individual message +│ │ │ ├── ToolActivity.tsx # Tool execution UI +│ │ │ ├── QuestionActivity.tsx # Question prompt UI +│ │ │ ├── ModelSelector.tsx # Model dropdown +│ │ │ ├── AttachButton.tsx # File attachment +│ │ │ ├── AttachmentList.tsx # Attached files +│ │ │ └── WebpageDialog.tsx # URL preview +│ │ ├── hooks/ +│ │ │ └── useChat.ts # Main chat hook +│ │ ├── useAttachments.ts # File attachment handling +│ │ ├── useAudioRecording.ts # Voice input +│ │ └── useSlashCommands.ts # /commands handling +│ │ +│ └── ChatHistory/ # Session management UI +│ +├── hooks/ +│ ├── usePiChat.ts # Main chat hook +│ └── ... +│ +└── state/ + └── useChatSessions.ts # Session state management +``` + +### Backend + +``` +src/ +├── server.tsx # Main server entry +│ # - WebSocket upgrade +│ # - Route registration +│ # - Pi installation check +│ +└── servers/ + ├── hono.ts # Hono REST server + │ + └── api/ + ├── pi/ + │ ├── types.ts # TypeScript types + │ ├── websocket.ts # WebSocket handler + │ ├── session-manager.ts # Session lifecycle + │ ├── pi-bridge.ts # Pi subprocess spawner + │ ├── storage.ts # Disk persistence + │ ├── rest.ts # REST API endpoints + │ ├── logger.ts # Logging + │ └── README.md # Original Pi API docs + │ + └── ... (other API routes) +``` + +--- + +## Type Definitions + +### Client Messages (Frontend → Backend) + +```typescript +// Start new chat +type ClientMessage = + | { type: 'chat'; prompt: string; sessionId?: string; model?: string; cwd?: string; groupSlug?: string; attachmentIds?: string[] } + | { type: 'resume'; sessionId: string } // Resume existing session + | { type: 'stop' }; // Stop generation +``` + +### Server Messages (Backend → Frontend) + +```typescript +type ServerMessage = + | { type: 'session:init'; sessionId: string; model: string; cwd: string } + | { type: 'assistant:delta'; text: string } // Streaming token + | { type: 'assistant:text'; text: string } // Complete message + | { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record } + | { type: 'tool:result'; toolCallId: string; output: string; isError: boolean } + | { type: 'result'; sessionId: string; cost: MessageCost } + | { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string } + | { type: 'error'; message: string; errorCode?: string } + | { type: 'stopped' }; +``` + +### Internal Types + +```typescript +type Message = { + id: string; + timestamp: number; + role: 'user' | 'assistant' | 'tool'; + text?: string; + model?: string; + cost?: MessageCost; + toolCallId?: string; + toolName?: string; + toolInput?: Record; + output?: string; + isError?: boolean; +}; + +type SessionMeta = { + id: string; + title: string; + model: string; + cwd: string; + createdAt: number; + updatedAt: number; + messageCount: number; + cost: MessageCost; + groupSlug?: string | null; +}; + +type MessageCost = { + inputTokens: number; + outputTokens: number; + totalUSD: number; +}; +``` + +--- + +## REST API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/pi/models` | List available AI models | +| POST | `/api/pi/sessions` | List all sessions | +| GET | `/api/pi/sessions/:id` | Get session with messages | +| PATCH | `/api/pi/sessions/:id` | Update session (rename) | +| DELETE | `/api/pi/sessions/:id` | Delete session | +| GET | `/api/pi/sessions/search?q=` | Search sessions | +| POST | `/api/pi/groups` | Create group | +| GET | `/api/pi/groups` | List groups | +| PATCH | `/api/pi/groups/:slug` | Update group | +| DELETE | `/api/pi/groups/:slug` | Delete group | +| POST | `/api/pi/sessions/:id/move` | Move session between groups | + +--- + +## Data Flow Details + +### 1. Starting a New Chat + +``` +User enters message + ↓ +usePiChat.sendPrompt() + ↓ +useChatWebSocket.send({ type: 'chat', prompt, ... }) + ↓ +WebSocket connection to /api/pi/chat/ws + ↓ +server.tsx: upgradeWs() - JWT verification + ↓ +websocket.ts: handleChat() + ↓ +SessionManager.getOrCreate(sessionId, email, cwd, model) + ↓ +pi-bridge.ts: spawnPi(cwd, model, onEvent) + ↓ +Bun.spawn(['pi', '--mode', 'rpc', '--no-extensions', ...]) + ↓ +send prompt to Pi via stdin + ↓ +Pi process streams JSON events to stdout + ↓ +pi-bridge parses events → onEvent(PiEvent) + ↓ +websocket.ts createEventHandler() → sendToClient() + ↓ +useChatWebSocket.onMessage() → usePiChat.handleMessage() + ↓ +React state update → UI renders +``` + +### 2. Resuming a Session + +``` +User navigates to /chat/:sessionId + ↓ +usePiChat mounts (with initialSessionId) + ↓ +useEffect triggers: getSession(initialSessionId) + ↓ +REST API: GET /api/pi/sessions/:sessionId + ↓ +storage.ts: loadSession() reads from disk + ↓ +Messages loaded into state + ↓ +WebSocket connects (for new messages) + ↓ +websocket.ts: handleResume() + ↓ +Spawns fresh Pi process + ↓ +Sends 'sync:messages' to client +``` + +### 3. Pi Event Parsing + +``` +Pi stdout JSON line: +{ + "type": "message_update", + "assistantMessageEvent": { + "type": "text_delta", + "delta": "Hello" + } +} + ↓ +pi-bridge.ts: parsePiEvent() + ↓ +Converted to PiEvent: +{ type: 'delta', text: 'Hello' } + ↓ +Event handler: +- 'delta' → append to streamBuffer, send 'assistant:delta' +- 'text' → commit streamBuffer, send 'assistant:text' +- 'tool:start' → send 'tool:start', add tool message +- 'tool:result' → send 'tool:result', update tool message +- 'result' → save session, send 'result' +``` + +--- + +## Session Storage + +### Directory Structure + +``` +~/.pi-sessions/ +├── / +│ ├── meta.json # Session metadata +│ └── messages.json # Message history +│ +└── @/ # Grouped sessions + ├── .group-meta.json + ├── / + │ ├── meta.json + │ └── messages.json + └── / +``` + +### meta.json + +```json +{ + "id": "session-uuid", + "title": "First prompt...", + "model": "opencode/big-pickle", + "cwd": "/home/user", + "createdAt": 1700000000000, + "updatedAt": 1700000000000, + "messageCount": 10, + "cost": { + "inputTokens": 5000, + "outputTokens": 2000, + "totalUSD": 0.05 + }, + "groupSlug": null +} +``` + +### messages.json + +```json +{ + "messages": [ + { "id": "...", "timestamp": 1700000000000, "role": "user", "text": "Hello" }, + { "id": "...", "timestamp": 1700000000001, "role": "assistant", "text": "Hi!" }, + { "id": "...", "timestamp": 1700000000002, "role": "tool", "toolName": "Bash", "toolInput": {...}, "output": "..." } + ] +} +``` + +--- + +## Authentication + +### WebSocket Authentication + +1. Client connects: `ws://host/api/pi/chat/ws?token=` +2. `server.tsx:upgradeWs()` extracts token from URL +3. `jwt.ts:verify(token)` validates JWT +4. Check token blacklist (logout/revocation) +5. Extract user info: `{ userId, email, role }` +6. Attach to WebSocket data: `ws.data = { userId, email, provider: 'pi', ... }` + +### REST Authentication + +- Bearer token in `Authorization` header +- `hono.ts` uses `userMiddleware` to verify +- User object attached to request context: `ctx.get('user')` + +--- + +## Session Management + +### SessionManager (in-memory) + +```typescript +class SessionManager { + private sessions = new Map(); + private userSessions = new Map(); // email → sessionIds + + getOrCreate(sessionId, email, cwd, model, groupSlug): UserSession + getSession(sessionId): UserSession | null + attachWs(sessionId, ws): void + detachWs(sessionId): void + setIdleTimeout(sessionId, timeoutMs): void // Auto-cleanup after 1 hour + deleteSession(sessionId): void +} +``` + +### UserSession Object + +```typescript +type UserSession = { + sessionId: string; + email: string; + cwd: string; + model: string; + piProcess: Subprocess | null; // Bun subprocess + ws: WebSocket | null; // Connected client + lastActivity: number; + idleTimer: Timer | null; + streamBuffer: string; // Uncommitted streaming text + isGenerating: boolean; + systemContextSent: boolean; + messages: Message[]; + meta: SessionMeta; +}; +``` + +--- + +## Pi Subprocess + +### Spawn Command + +```typescript +const args = [ + 'pi', + '--mode', 'rpc', + '--no-extensions', + '--no-skills', + '--no-prompt-templates', + '--no-themes', + '--model', model // e.g., 'opencode/big-pickle' +]; + +Bun.spawn(args, { + cwd, + stdin: 'pipe', + stdout: 'pipe', // JSON event stream + stderr: 'pipe', // Debug logs +}); +``` + +### RPC Protocol + +``` +// Send prompt (stdin): +{ "type": "prompt", "id": "request-uuid", "message": "Hello" } + +{ "type": "abort", "id": "request-uuid" } + +// Receive events (stdout): +{ "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "delta": "..." } } +{ "type": "message_end" } +{ "type": "tool_execution_start", "toolCallId": "...", "toolName": "Bash", "args": {...} } +{ "type": "tool_execution_end", "toolCallId": "...", "result": "...", "isError": false } +{ "type": "agent_end", "cost": {...} } +``` + +--- + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `server.tsx` | Main server, WebSocket upgrade, route setup | +| `servers/api/pi/websocket.ts` | WebSocket message handling | +| `servers/api/pi/session-manager.ts` | Session lifecycle in memory | +| `servers/api/pi/pi-bridge.ts` | Spawn and communicate with Pi | +| `servers/api/pi/storage.ts` | Disk persistence | +| `servers/api/pi/rest.ts` | REST API endpoints | +| `servers/api/pi/types.ts` | Shared types | +| `workspaces/officerdev/src/hooks/usePiChat.ts` | Main frontend hook | +| `workspaces/hooks/src/useChatWebSocket.ts` | WebSocket connection | +| `workspaces/officerdev/src/apps/Chat/types.ts` | Frontend types | + +--- + +## Configuration + +### Environment Variables + +- `PORT` - Server port (default: 5000) +- `JWT_SECRET` - JWT signing secret + +### Constants + +```typescript +// websocket.ts +const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour + +// storage.ts +const PI_SESSIONS_DIR = '.pi-sessions'; +const GROUP_PREFIX = '@'; +``` + +--- + +## Error Handling + +- WebSocket errors → Logged and sent as `{ type: 'error', message: ... }` +- Pi process errors → `{ type: 'error', message: errorMsg }` +- Session not found → HTTP 404 or `SESSION_NOT_FOUND` error code +- Token blacklist check → 401 Unauthorized + +--- + +## Dependencies + +### Backend +- `bun` - Runtime +- `hono` - HTTP framework +- `drizzle-orm` - Database (for user auth) + +### Frontend +- `react` - UI framework +- `@tanstack/react-query` - Data fetching +- `lucide-react` - Icons + +--- + +*Last updated: 2024* diff --git a/HOOKS.md b/HOOKS.md new file mode 100644 index 00000000..b47a2e4f --- /dev/null +++ b/HOOKS.md @@ -0,0 +1,46 @@ +## Authentication + +src/apps/officer-web/Screens/Authentication/ForgotPassword/useResetPassword.ts +src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts + +## Files + +src/apps/officer-web/Screens/Dashboard/Files/state/usePinnedFiles.ts +src/apps/officer-web/Screens/Dashboard/Files/state/useRecentFiles.ts + +## Officer-web State + +src/apps/officer-web/state/useChatGroups.ts +src/apps/officer-web/state/useChatSessions.ts +src/apps/officer-web/state/useInitialData.ts +src/apps/officer-web/state/useLandingPage.ts +src/apps/officer-web/state/useModels.ts +src/apps/officer-web/state/usePlans.ts +src/apps/officer-web/state/useProjectsState.ts +src/apps/officer-web/state/useRecentModels.ts +src/apps/officer-web/state/useResources.ts +src/apps/officer-web/state/useServerSettings.ts +src/apps/officer-web/state/useSettings.ts +src/apps/officer-web/state/useThemeSync.ts +src/apps/officer-web/state/useUserState.ts +src/apps/officer-web/state/useWorkspacesState.ts + +## Chat (apps/Chat) + +src/workspaces/apps/Chat/useChatSessions.ts +src/workspaces/apps/Chat/useChatSession.ts +src/workspaces/apps/Chat/usePi.ts +src/workspaces/apps/Chat/useSlashCommands.ts + +## Other Workspaces + +src/workspaces/apps/CodeEditor/useEditorState.ts +src/workspaces/apps/FileBrowser/useFiles.ts +src/workspaces/apps/FileBrowser/useTasks.ts +src/workspaces/components/DataTable/useFixedHeightPagination.ts +src/workspaces/components/ui/hooks/use-mobile.tsx +src/workspaces/components/ui/hooks/use-toast.ts +src/workspaces/components/ui/use-toast.ts +src/workspaces/i18n/src/useTranslation.ts +src/workspaces/injector/use-client.ts +Done! diff --git a/OFFICERDEV_BACKEND.md b/OFFICERDEV_BACKEND.md new file mode 100644 index 00000000..d62c21a4 --- /dev/null +++ b/OFFICERDEV_BACKEND.md @@ -0,0 +1,1179 @@ +# Officer.dev Backend Architecture + +## Executive Summary + +Officer is a full-stack AI-assisted personal backend and frontend for life management. The backend is a **Bun-based monorepo** using: +- **Hono** framework for REST API (port 5000) +- **PostgreSQL** with Drizzle ORM for persistent data +- **WebSockets** for real-time terminal and AI chat functionality +- **Multi-workspace** shared libraries for code reuse +- **Node.js PTY sidecars** for terminal emulation +- **Pi coding agent integration** for AI-powered development assistance + +This document covers the backend architecture, data flow, and technical patterns. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Technology Stack](#technology-stack) +3. [Directory Structure](#directory-structure) +4. [Core Server](#core-server) +5. [API Architecture](#api-architecture) +6. [Database Design](#database-design) +7. [WebSocket Services](#websocket-services) +8. [Shared Workspaces](#shared-workspaces) +9. [Error Handling](#error-handling) +10. [Development & Deployment](#development--deployment) +11. [Key Features](#key-features) + +--- + +## Architecture Overview + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Web Browser (Frontend) │ +└──────────────┬──────────────────────────────────────────────┘ + │ + ├─ HTTP REST API (port 5000) + ├─ WebSocket: Terminal (ws://api:5000/api/terminal/ws) + ├─ WebSocket: Pi Chat (ws://api:5000/api/pi/chat/ws) + └─ Dev Server Proxy (ws://api:5000/api/dev-server-proxy/*) + │ +┌──────────────▼──────────────────────────────────────────────┐ +│ Bun Server (src/server.tsx + Hono Router) │ +├─────────────────────────────────────────────────────────────┤ +│ Middleware: │ +│ - CORS handler │ +│ - User authentication (JWT + token blacklist) │ +│ - Request body parser │ +│ │ +│ Routes: │ +│ - /api/auth → Authentication (login, signup) │ +│ - /api/plans → Plans management │ +│ - /api/skills → Skills (Pi integration) │ +│ - /api/tasks → Tasks & processes │ +│ - /api/upload → File uploads │ +│ - /api/workspaces → Workspace management │ +│ - /api/settings → User settings │ +│ - /api/dev-server → Local dev server routing │ +│ - WebSocket handlers → Real-time communication │ +└──────────────┬──────────────────────────────────────────────┘ + │ + ┌──────┴──────────────────────┐ + │ │ + ▼ ▼ +┌──────────────────┐ ┌──────────────────────┐ +│ PostgreSQL DB │ │ Node.js PTY Sidecar │ +│ (officer_db) │ │ (Terminal Emulation)│ +│ │ │ │ +│ Users, Plans, │ │ xterm-js │ +│ Skills, Tasks, │ │ node-pty │ +│ Sessions, etc │ │ Docker container │ +└──────────────────┘ └──────────────────────┘ +``` + +### Request/Response Flow + +``` +Client Request + ↓ +Server Router (Bun) + ↓ +Authentication Middleware + ↓ +Protected/Public Handler + ↓ +Database Query (Drizzle ORM) OR External Service + ↓ +Response (JSON/WebSocket) + ↓ +Client +``` + +--- + +## Technology Stack + +### Core Runtime & Framework +- **Bun** (v1.30+) - Fast JavaScript runtime with built-in tooling +- **TypeScript** (v5.9+) - Strict mode enabled +- **Hono** (v4.11+) - Lightweight, type-safe web framework +- **Node.js** - For PTY sidecar processes + +### Database & ORM +- **PostgreSQL** - Primary persistent data store +- **Drizzle ORM** (v0.45+) - Type-safe SQL query builder +- **Drizzle Kit** (v0.31+) - Schema management and migrations + +### Real-Time Communication +- **WebSockets** (native Bun) - Bidirectional communication +- **Redis** (optional) - Session/cache storage + +### Authentication & Security +- **JWT** - Token-based authentication +- **Argon2** - Password hashing (bcrypt alternative) +- **SimpleWebAuthn** - WebAuthn/FIDO2 passkey support +- **Token Blacklist** - Revocation tracking + +### AI & Automation +- **@anthropic-ai/claude-agent-sdk** - Claude integration +- **Pi Coding Agent** - CLI-based AI development assistant +- **Nodemailer** - Email notifications +- **Googleapis** - Google integration for services + +### Utilities +- **node-pty** - Cross-platform pseudoterminal support +- **Cron** (v4.3+) - Scheduled task execution +- **Date-fns** (v4.1+) - Date manipulation +- **Zod** (v4.2+) - Schema validation + +--- + +## Directory Structure + +### Complete Backend Layout + +``` +monorepo/ +├── src/ +│ ├── server.tsx # Main Bun server entry point +│ │ +│ ├── servers/ # Backend core +│ │ ├── bootstrap.ts # Server initialization +│ │ ├── hono.ts # Hono app & router setup +│ │ ├── jwt.ts # JWT token handling +│ │ ├── custom-errors.ts # Custom error classes +│ │ ├── create-router.ts # Router factory with context +│ │ ├── data-path.ts # Path management +│ │ │ +│ │ ├── _middlewares/ # Shared middleware +│ │ │ ├── user-middleware.ts # Auth verification +│ │ │ └── body-parser.ts # Request parsing +│ │ │ +│ │ └── api/ # API routes +│ │ ├── auth/ # Authentication routes +│ │ │ ├── signin.ts # Login endpoint +│ │ │ ├── signup.ts # Registration endpoint +│ │ │ ├── signout.ts # Logout endpoint +│ │ │ ├── verify.ts # Email verification +│ │ │ ├── passkey-router.ts # WebAuthn endpoints +│ │ │ ├── reset-password.ts # Password reset +│ │ │ └── auth.ts # Core auth logic +│ │ │ +│ │ ├── terminal/ # Terminal WebSocket +│ │ │ ├── websocket.ts # WS handler +│ │ │ └── pty-sidecar.mjs # PTY subprocess +│ │ │ +│ │ ├── pi/ # Pi coding agent integration +│ │ │ ├── websocket.ts # Chat WS handler +│ │ │ ├── rest.ts # REST endpoints +│ │ │ ├── pi-bridge.ts # Pi SDK bridge +│ │ │ ├── storage.ts # Chat session storage +│ │ │ └── session-manager.ts # Session management +│ │ │ +│ │ ├── dev-server/ # Local dev server routing +│ │ │ └── router.ts # Dev server proxy +│ │ │ +│ │ ├── plans/ # Plans management +│ │ ├── skills/ # Skills routes +│ │ ├── tasks/ # Tasks & processes +│ │ ├── workspaces/ # Workspace management +│ │ ├── settings/ # User settings +│ │ ├── upload/ # File upload +│ │ ├── file-browser/ # File browser API +│ │ ├── scrape/ # Web scraping +│ │ ├── sessions/ # Chat sessions +│ │ ├── task-logs/ # Task execution logs +│ │ ├── server-settings/ # System configuration +│ │ └── users/ # User management +│ │ +│ ├── databases/ # Data layer +│ │ └── officer_db/ # Main database package +│ │ ├── drizzle.config.ts # Drizzle configuration +│ │ ├── migrations/ # Database migrations +│ │ ├── src/ +│ │ │ ├── schema.ts # Table definitions +│ │ │ ├── types.ts # Inferred types +│ │ │ └── index.ts # Exports +│ │ └── package.json # Workspace manifest +│ │ +│ └── workspaces/ # Shared libraries +│ ├── components/ # React components +│ ├── hooks/ # React hooks +│ ├── helpers/ # Utility functions +│ ├── types/ # Shared types +│ ├── state/ # State management +│ ├── config/ # Configuration +│ ├── definitions/ # Constants +│ ├── emailer/ # Email service +│ ├── injector/ # Dependency injection +│ └── i18n/ # Internationalization +│ +├── package.json # Root workspace config +├── bunfig.toml # Bun configuration +├── tsconfig.json # TypeScript config +└── .env # Environment variables +``` + +--- + +## Core Server + +### Entry Point: `src/server.tsx` + +The main server file initializes the Bun HTTP server and handles: +1. Route registration (static files, API, WebSockets) +2. WebSocket upgrade logic and authentication +3. Dev server proxy for HMR and local dev environments +4. PI coding agent installation/verification + +#### Key Responsibilities + +```typescript +// 1. Static file serving +'/static/*' → public/ directory files + +// 2. HTML fallback for SPA routing +'/' and '/*' → officer-web app + +// 3. API routing +'/api/*' → honoServer (REST endpoints) + +// 4. WebSocket routing with auth +'/api/terminal/ws' → Terminal WebSocket +'/api/pi/chat/ws' → Pi Chat WebSocket + +// 5. Dev server proxy +'/api/dev-server-proxy/*' → Proxied WS for local dev servers +``` + +#### WebSocket Authentication Pattern + +```typescript +async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi') { + // 1. Extract JWT token from query params + const token = new URL(req.url).searchParams.get('token'); + + // 2. Verify token and extract user + const user = await verify(token); + + // 3. Check token blacklist (for revoked tokens) + const blacklisted = await officerdb.query.TokenBlacklist.findFirst({...}); + + // 4. Extract WS-specific parameters + const sessionId = url.searchParams.get('sessionId'); + const cwd = url.searchParams.get('cwd'); + const cols = url.searchParams.get('cols'); // terminal dimensions + + // 5. Upgrade connection with authenticated data + server.upgrade(req, { data: { userId, email, role, provider, ... } }); +} +``` + +### Hono Server: `src/servers/hono.ts` + +Hono is a lightweight web framework perfect for edge computing and Bun. + +#### Server Initialization + +```typescript +const honoServer = new Hono<{ Variables: HonoVariables }>(); + +honoServer.use(cors({ origin: '*', allowMethods: [...] })); + +// Public routes +honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); +honoServer.route('/api/auth', authRouter); +honoServer.route('/api/server-settings', serverSettingsRouter); + +// Protected routes +const protectedRouter = createRouter(); +protectedRouter.use(bodyParser()); +protectedRouter.use(userMiddleware); + +protectedRouter.route('/plans', plansRouter); +protectedRouter.route('/tasks', tasksRouter); +// ... other routes + +honoServer.route('/api', protectedRouter); + +// Error handling +honoServer.onError((error, ctx) => { + if (error instanceof CustomError) { + return ctx.json(error.returnValue, error.statusCode); + } + return ctx.text('Internal Server Error', 500); +}); +``` + +#### Hono Variables Context + +```typescript +type HonoVariables = { + userId: number; + email: string; + role: 'admin' | 'user'; + // Accessible in all routes via ctx.get('userId') +}; +``` + +--- + +## API Architecture + +### Route Organization + +The API is organized by feature domain: + +``` +/api/ +├── auth/ # User authentication (public) +│ ├── POST /signin +│ ├── POST /signup +│ ├── POST /signout +│ ├── GET /verify +│ ├── POST /passkeys (WebAuthn) +│ └── ... +│ +├── plans/ # Plans management (protected) +├── skills/ # Skills endpoints +├── tasks/ # Task operations +├── workspaces/ # Workspace operations +├── settings/ # User preferences +├── upload/ # File uploads +├── file-browser/ # File system access +├── terminal/ws # Terminal emulation (WS) +├── pi/ # AI assistant integration +│ ├── /ws # Chat (WebSocket) +│ └── /rest # REST endpoints +│ +└── dev-server/ # Local dev server routing +``` + +### Router Creation Pattern: `src/servers/create-router.ts` + +```typescript +// Factory function for creating contextualized routers +export function createRouter() { + return new Hono<{ Variables: HonoVariables }>(); +} + +// Usage in route handlers: +const router = createRouter(); +router.get('/endpoint', (ctx) => { + const userId = ctx.get('userId'); + // Route logic +}); +``` + +### Error Handling: `src/servers/custom-errors.ts` + +```typescript +class CustomError extends Error { + constructor( + public statusCode: number, + message: string, + public returnValue?: Record | string, + ) { + super(message); + } +} + +// Usage patterns +throw new CustomError(400, 'Bad request', { error: 'Invalid input' }); +throw new CustomError(401, 'Unauthorized'); +throw new CustomError(500, 'Internal error', { error: 'Database failed' }); +``` + +--- + +## Database Design + +### Primary Database: `officer_db` + +Located in `src/databases/officer_db/`, this is the main persistent data store. + +#### Drizzle Configuration + +```typescript +// drizzle.config.ts +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/schema.ts', + out: './migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); +``` + +#### Schema Structure + +The schema defines all tables and their relationships: + +```typescript +// src/databases/officer_db/src/schema.ts + +// Core tables +export const Users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').unique().notNull(), + password: text('password'), + role: text('role').$type<'admin' | 'user'>().default('user'), + verified: boolean('verified').default(false), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +export const Plans = pgTable('plans', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => Users.id), + title: text('title').notNull(), + description: text('description'), + // ... other fields +}); + +export const Skills = pgTable('skills', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => Users.id), + name: text('name').notNull(), + content: text('content'), + // ... other fields +}); + +// ... many more tables for tasks, sessions, workspaces, etc. + +// Relations +export const usersRelations = relations(Users, ({ many }) => ({ + plans: many(Plans), + skills: many(Skills), +})); +``` + +#### Type Generation + +Types are automatically inferred from schema: + +```typescript +// src/databases/officer_db/src/types.ts + +// Simple table types +export type User = typeof Users.$inferSelect; +export type UserInsert = typeof Users.$inferInsert; + +// Extended types for API responses +export type UserWithRelations = User & { + plans: Plan[]; + skills: Skill[]; +}; +``` + +#### Database Operations in Routes + +```typescript +import { officerdb, eq, Users } from 'officerdb'; + +// Simple query +const user = await officerdb.query.Users.findFirst({ + where: eq(Users.id, userId), +}); + +// Query with relations +const user = await officerdb.query.Users.findFirst({ + where: eq(Users.id, userId), + with: { + plans: true, + skills: true, + }, +}); + +// Insert with returning +const [newUser] = await officerdb.insert(Users).values({ + email: 'user@example.com', + password: hashedPassword, +}).returning(); + +// Update +await officerdb.update(Users).set({ + verified: true, +}).where(eq(Users.id, userId)); +``` + +#### Token Blacklist Table + +```typescript +export const TokenBlacklist = pgTable('token_blacklist', { + id: serial('id').primaryKey(), + jti: text('jti').unique().notNull(), // JWT ID + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow(), +}); + +// Used for token revocation (logout, password change, etc.) +``` + +### Database Migrations + +Migrations are generated by Drizzle and stored in `src/databases/officer_db/migrations/`: + +```bash +# Generate new migration +bun run db:gen + +# Apply migrations +bun run db:push + +# Open Drizzle Studio for visual management +bun run db:studio +``` + +--- + +## WebSocket Services + +### 1. Terminal WebSocket: `src/servers/api/terminal/` + +Provides an interactive terminal emulator in the browser using xterm.js. + +#### Architecture + +``` +Browser (xterm.js) + ↓ WebSocket +Officer Server (websocket.ts) + ↓ IPC/Stdio +PTY Sidecar (pty-sidecar.mjs) + ↓ Shell Process +System Shell (bash/zsh) +``` + +#### Handler: `websocket.ts` + +```typescript +export const terminalWebsocket = { + // Connection established + open(ws: ServerWebSocket) { + const { userId, cwd, cols, rows, sandboxed } = ws.data; + + // 1. Spawn or reuse PTY sidecar + // 2. Set terminal dimensions + // 3. Send initial prompt + }, + + // Client sends data (typing, etc.) + message(ws: ServerWebSocket, raw: string | Buffer) { + // 1. Parse message type (input, resize, etc.) + // 2. Forward to PTY process + }, + + // Connection closed + close(ws: ServerWebSocket) { + // 1. Kill PTY process + // 2. Cleanup resources + }, +}; +``` + +#### PTY Sidecar: `pty-sidecar.mjs` + +A Node.js subprocess that manages the actual pseudoterminal: + +```javascript +// Uses node-pty for cross-platform terminal support +const pty = require('node-pty'); + +const term = pty.spawn('bash', [], { + name: 'xterm-color', + cols: 120, + rows: 40, + cwd: process.env.CWD, +}); + +// stdout → send to client +term.on('data', (data) => { + process.stdout.write(JSON.stringify({ type: 'output', data })); +}); + +// stdin from client → write to terminal +process.stdin.on('data', (chunk) => { + const msg = JSON.parse(chunk); + if (msg.type === 'input') term.write(msg.data); + if (msg.type === 'resize') term.resize(msg.cols, msg.rows); +}); +``` + +#### Sandboxed vs. Real Terminals + +- **Sandboxed**: Limited to specific directories, no system access +- **Real**: Full system access from user's working directory + +### 2. Pi Coding Agent WebSocket: `src/servers/api/pi/` + +Integrates the Pi coding agent for AI-assisted development. + +#### Architecture + +``` +Browser (Chat UI) + ↓ WebSocket +Officer Server (pi/websocket.ts) + ↓ SDK +Pi Coding Agent (pi-bridge.ts) + ↓ RPC/Tools +LLM API (Claude, GPT, etc.) +``` + +#### Components + +**websocket.ts**: WebSocket message handler +- Routes incoming chat messages to Pi +- Streams responses back to client +- Manages session state + +**pi-bridge.ts**: SDK integration layer +```typescript +export async function createPiSession( + cwd: string, + userEmail: string, + sessionId?: string, +) { + // 1. Initialize Pi SDK + const { session } = await createAgentSession({ + cwd, + model: selectedModel, + tools: createCodingTools(cwd), + }); + + // 2. Subscribe to events + session.subscribe((event) => { + // Stream events back to client via WS + }); + + // 3. Return session for messaging + return session; +} +``` + +**rest.ts**: REST endpoints for Pi operations +- Create new sessions +- List sessions +- Get session details +- Export conversations + +**storage.ts**: Chat session persistence +```typescript +// Store conversation history in database +interface PiSession { + id: string; + userId: number; + cwd: string; + model: string; + createdAt: timestamp; + messages: PiMessage[]; +} + +// Retrieve from database when resuming +``` + +**session-manager.ts**: Session lifecycle management + +#### Message Flow + +``` +Client: "ls -la" + → WebSocket to Officer + → Pi process stdin + → Pi executes bash tool + → Tool output + → Stream back to client +``` + +#### Integration with Tools + +Pi can execute: +- `read` - Read files +- `bash` - Run shell commands +- `edit` - Modify files +- `write` - Create files +- Custom tools via extensions + +--- + +## Shared Workspaces + +Shared code is organized in `src/workspaces/` as Bun workspace packages: + +### Key Workspaces + +**types/** - TypeScript type definitions +- Centralized, exported via `'types'` alias +- Shared by frontend and backend +- Database types imported and re-exported + +**helpers/** - Utility functions +- Formatters, validators, converters +- Pure functions with no side effects + +**hooks/** - React hooks +- Frontend-only +- `useAuth`, `useQuery`, `useMutation`, etc. + +**state/** - State management +- Global state stores +- Context providers +- Zustand/React Context usage + +**components/** - React components +- UI components (buttons, inputs, etc.) +- Complex feature components +- Reusable across apps + +**config/** - Configuration +- Constants +- Environment-specific settings + +**definitions/** - Enum and constant definitions +- User roles +- Status values +- Feature flags + +**emailer/** - Email service +```typescript +// Nodemailer-based email sending +export async function sendEmail(to: string, subject: string, html: string) { + const transporter = nodemailer.createTransport({...}); + return transporter.sendMail({ to, subject, html }); +} +``` + +**injector/** - Dependency injection +- Service locator pattern +- Configuration management + +**i18n/** - Internationalization +- Multi-language support +- Locale management + +**sounds/** - Audio assets +- Notification sounds +- UI feedback audio + +**widgets/** - Complex UI widgets +- Feature-rich components +- Composed from basic components + +### Workspace Configuration + +Each workspace has a `package.json`: + +```json +{ + "name": "components", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "main": "./index.ts" +} +``` + +### Import Pattern in Code + +```typescript +// In any app (frontend/backend) +import { User, Plan } from 'types'; +import { formatDate } from 'helpers'; +import { useAuth } from 'hooks'; +import { useGlobalState } from 'state'; +import { Button } from 'components'; +``` + +--- + +## Error Handling + +### Error Classification + +```typescript +// Custom error hierarchy +class CustomError extends Error { + constructor( + public statusCode: number, + message: string, + public returnValue?: any, + ) {} +} + +// Usage patterns +throw new CustomError(400, 'Bad request', { field: 'email', error: 'Invalid' }); +throw new CustomError(401, 'Unauthorized'); +throw new CustomError(403, 'Forbidden'); +throw new CustomError(404, 'Not found', { resource: 'Plan' }); +throw new CustomError(409, 'Conflict', { error: 'Email already exists' }); +throw new CustomError(500, 'Internal server error'); +``` + +### Middleware Error Handling + +```typescript +honoServer.onError((error, ctx) => { + // CustomError → formatted response + if (error instanceof CustomError) { + return ctx.json(error.returnValue || error.message, error.statusCode); + } + + // Unexpected error → 500 + console.error('Unexpected error:', error.message); + return ctx.text('Internal Server Error', 500); +}); +``` + +### Try/Catch Pattern + +```typescript +// In route handlers +router.post('/endpoint', async (ctx) => { + try { + const body = await ctx.req.json(); + + // Validation + if (!body.email) { + throw new CustomError(400, 'Email required', { field: 'email' }); + } + + // Database operation + const user = await officerdb.insert(Users).values({...}).returning(); + if (!user) throw new CustomError(500, 'Failed to create user'); + + return ctx.json({ success: true, user }); + } catch (error) { + // Re-throw CustomErrors, let middleware handle + if (error instanceof CustomError) throw error; + + // Unexpected error + console.error('Route error:', error); + throw new CustomError(500, 'Internal server error'); + } +}); +``` + +--- + +## Authentication System + +### JWT Flow + +``` +User Login + ↓ +Verify credentials (email + password) + ↓ +Generate JWT token + { + "sub": userId, + "email": email, + "role": "user", + "iat": timestamp, + "exp": timestamp, + "jti": unique-token-id + } + ↓ +Return token to client + ↓ +Client stores in localStorage + ↓ +Client sends in Authorization header + ↓ +Server verifies signature & expiration + ↓ +Grant access +``` + +### Token Verification: `src/servers/jwt.ts` + +```typescript +export async function verify(token: string) { + try { + // 1. Verify signature and expiration + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + // 2. Check token blacklist (for revoked tokens) + if (decoded.jti) { + const blacklisted = await officerdb.query.TokenBlacklist.findFirst({ + where: eq(TokenBlacklist.jti, decoded.jti), + }); + if (blacklisted) return null; // Token revoked + } + + return { id: decoded.sub, email: decoded.email, role: decoded.role }; + } catch { + return null; // Invalid token + } +} +``` + +### Logout with Token Blacklist + +```typescript +router.post('/signout', async (ctx) => { + const token = ctx.req.header('Authorization')?.replace('Bearer ', ''); + const decoded = jwt.decode(token) as JWTPayload; + + // Add to blacklist + await officerdb.insert(TokenBlacklist).values({ + jti: decoded.jti, + expiresAt: new Date(decoded.exp * 1000), + }); + + return ctx.json({ success: true }); +}); +``` + +### WebAuthn/Passkey Support + +```typescript +// Registration +router.post('/passkeys/register/options', async (ctx) => { + const user = ctx.get('userId'); + const options = await generateRegistrationOptions({ + rpID: 'officer.dev', + rpName: 'Officer', + userID: Buffer.from(String(user.id)), + userName: user.email, + }); + return ctx.json(options); +}); + +// Verification +router.post('/passkeys/register/verify', async (ctx) => { + const credential = await ctx.req.json(); + const verification = await verifyRegistrationResponse({ + credential, + expectedOrigin: 'https://officer.dev', + expectedRPID: 'officer.dev', + }); + + if (verification.verified) { + // Store passkey + await officerdb.insert(Passkeys).values({...}); + } + return ctx.json({ verified: verification.verified }); +}); +``` + +--- + +## Development & Deployment + +### Development Server + +```bash +# Start with hot reload +bun dev + +# Server runs on http://localhost:5000 +# Frontend available at http://localhost:5000/ +``` + +### Database Operations + +```bash +# Generate migration from schema changes +bun run db:gen + +# Apply pending migrations +bun run db:push + +# Open Drizzle Studio (UI for database) +bun run db:studio +``` + +### Build & Production + +```bash +# Full build +bun run build + +# Production server +NODE_ENV=production bun src/server.tsx + +# Runs on port specified by PORT env var (default 5000) +``` + +### Environment Variables + +```bash +# .env +DATABASE_URL=postgresql://user:pass@localhost:5432/officer +JWT_SECRET=your-secret-key +NODE_ENV=development +PORT=5000 +``` + +--- + +## Key Features + +### 1. Real-Time Terminal + +- Interactive shell in browser +- File operations +- Code execution +- Multi-session support +- Sandboxed mode option + +### 2. AI-Powered Development with Pi + +- Chat interface with Claude +- File reading/writing capabilities +- Shell command execution +- Session persistence +- Model selection + +### 3. Task Management + +- Create and track tasks +- Task dependencies +- Scheduled execution (cron) +- Task logs and history +- Process management + +### 4. Workspace Management + +- Multi-workspace support +- Project organization +- Settings per workspace +- Resource management + +### 5. File Management + +- File browser with preview +- Upload support (50GB max) +- Web scraping +- Export/import + +### 6. Skills System + +- Pi agent skills +- Custom task templates +- Reusable automation + +### 7. Settings & Configuration + +- User preferences +- System configuration +- Integration settings +- Resource limits + +--- + +## Performance Considerations + +### Connection Pooling +PostgreSQL connections are managed by Drizzle ORM with configurable pool size. + +### Caching +- Redis support for session data (optional) +- Database query result caching +- Static asset caching + +### Rate Limiting +- Per-user API limits (todo) +- WebSocket message throttling +- File upload size limits (50GB max) + +### Database Optimization +- Proper indexing on foreign keys +- Query optimization with relations +- Pagination for large datasets + +--- + +## Security + +### CORS +```typescript +honoServer.use(cors({ + origin: '*', + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + allowHeaders: ['Content-Type', 'Authorization'], +})); +``` + +### JWT Security +- Secret stored in env variables +- Token expiration enforced +- Token blacklist for revocation +- jti (JWT ID) for tracking + +### Password Security +- Argon2 hashing (modern, secure) +- No plain-text storage +- Email verification required +- Password reset via email + +### WebAuthn/FIDO2 +- Hardware key support +- Phishing-resistant +- No passwords stored for passkeys + +--- + +## Monitoring & Logging + +### Current Logging +- Console.log for debugging +- Error stack traces printed to console + +### Future Monitoring (Signoz Integration) +- Distributed tracing +- Performance monitoring +- Error tracking +- Log aggregation + +--- + +## Testing + +### Current Test Setup +- `@testing-library/react` for component tests +- `@playwright/test` for E2E tests +- `happy-dom` for DOM testing + +### Test Command +```bash +# Not yet fully configured +bun test +``` + +--- + +## Related Documentation + +- **Frontend Architecture**: See `OFFICERDEV_FRONTEND.md` +- **CONVENTIONS.md**: Detailed code patterns and style guide +- **CLAUDE.md**: Project overview and patterns +- **Each folder CLAUDE.md**: Feature-specific documentation + +--- + +## Summary + +Officer's backend is a modern, type-safe Bun monorepo with: +- RESTful API using Hono framework +- Real-time capabilities via WebSockets +- PostgreSQL persistence with Drizzle ORM +- AI integration through Pi coding agent +- Modular architecture with shared workspaces +- Strong TypeScript support throughout +- Robust authentication with JWT and WebAuthn + +The architecture prioritizes developer experience, type safety, and maintainability while providing powerful real-time and AI-powered features to users. diff --git a/OFFICERDEV_FRONTEND.md b/OFFICERDEV_FRONTEND.md new file mode 100644 index 00000000..b0168bec --- /dev/null +++ b/OFFICERDEV_FRONTEND.md @@ -0,0 +1,1565 @@ +# Officer.dev Frontend Architecture + +## Executive Summary + +Officer's frontend is a modern React 19 SPA (Single Page Application) built with: +- **React 19** with automatic compiler optimizations (no useCallback/useMemo needed) +- **React Router 7** for client-side navigation +- **React Query** for server state management +- **Tailwind CSS 4** with custom components for styling +- **Shadcn/ui** component library base +- **WebSockets** for real-time terminal and AI chat +- **Modular architecture** with shared component and hook libraries + +This document covers the frontend architecture, component patterns, state management, and development practices. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Technology Stack](#technology-stack) +3. [Directory Structure](#directory-structure) +4. [Routing & Navigation](#routing--navigation) +5. [Component Architecture](#component-architecture) +6. [State Management](#state-management) +7. [API Communication](#api-communication) +8. [Real-Time Features](#real-time-features) +9. [Styling & Theming](#styling--theming) +10. [Performance & Optimization](#performance--optimization) +11. [Development Workflow](#development-workflow) + +--- + +## Architecture Overview + +### High-Level Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ Browser Application │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ App.tsx (Router Setup) │ +│ ↓ │ +│ BrowserRouter │ +│ ├─ Authentication Routes (public) │ +│ │ └─ Landing, SignIn, SignUp, ForgotPassword │ +│ │ │ +│ └─ Dashboard Routes (protected) │ +│ ├─ HomeScreen │ +│ ├─ SettingsPages (Profile, System, Resources) │ +│ ├─ Automation │ +│ ├─ SessionListPage (Chat with Pi) │ +│ ├─ PlansScreen │ +│ ├─ FilesScreen │ +│ ├─ CodeEditorScreen │ +│ ├─ SkillsScreen │ +│ ├─ TasksScreen │ +│ ├─ ProcessesScreen │ +│ ├─ TaskLogsScreen │ +│ ├─ WorkspacesScreen │ +│ ├─ ProjectListScreen │ +│ ├─ ProjectScreen │ +│ └─ TerminalScreen │ +│ │ +│ ↓ Shared State/Context │ +│ │ +│ ├─ AuthContext (useAuth) │ +│ ├─ GlobalState (useGlobalState) │ +│ ├─ ServerSettings (useServerSettings) │ +│ └─ ReactQuery (useQuery, useMutation) │ +│ │ +│ ↓ WebSocket Connections │ +│ │ +│ ├─ Terminal WebSocket │ +│ │ └─ Interactive shell in xterm.js │ +│ │ │ +│ └─ Pi Chat WebSocket │ +│ └─ Real-time streaming chat with Claude │ +│ │ +└──────────────────────────────────────────────────────────┘ + ↓ HTTP/REST & WebSocket + ┌────────────────┐ + │ Officer API │ + │ (port 5000) │ + └────────────────┘ +``` + +### Data Flow + +``` +User Interaction (Click, Type, etc.) + ↓ +Component Event Handler + ↓ +State Update (useState/Context/Query) + ↓ +API Call (HTTP or WebSocket) + ↓ +Server Processing + ↓ +Response + ↓ +Update Component State + ↓ +Re-render + ↓ +Updated UI +``` + +--- + +## Technology Stack + +### Core Frontend Framework +- **React 19** - Latest with automatic compiler optimizations +- **React DOM 19** - DOM rendering +- **React Router 7** - Client-side routing (file-based patterns support) +- **TypeScript 5.9** - Strict mode enabled + +### State Management +- **React Context** - Component tree data sharing +- **React Query (TanStack)** - Server state, caching, synchronization +- **Custom hooks** - Encapsulated business logic +- **Zustand** - Optional lightweight state (if used) + +### UI & Styling +- **Tailwind CSS 4** - Utility-first CSS framework +- **Shadcn/ui** - Accessible component library base +- **Radix UI** - Headless components for accessibility +- **Lucide React** - Icon library +- **Tabler Icons** - Additional icons +- **Class Variance Authority (CVA)** - Component style variations + +### Real-Time Communication +- **WebSocket API** - Native browser WebSocket +- **Json RPC** - Message protocol over WebSocket + +### Code Editor & Terminal +- **Monaco Editor** (@monaco-editor/react) - VS Code-like editor +- **xterm.js** (@xterm/xterm) - Terminal emulator +- **@uiw/react-textarea-code-editor** - Simple code editing + +### Data Visualization & Rich Content +- **Recharts** - Chart library +- **React Markdown** - Markdown rendering +- **Shiki** - Syntax highlighting +- **HTML2Canvas** - Screenshot capture +- **React Three Fiber** - 3D rendering (if used) + +### Form & Validation +- **React Hook Form** - Form state management +- **Zod** - Schema validation +- **@hookform/resolvers** - Form validation resolvers + +### UI Components & Utilities +- **Sonner** - Toast notifications +- **Vaul** - Drawer component +- **React Resizable Panels** - Resizable layout panels +- **React Virtual** - Virtual scrolling +- **Input OTP** - OTP input component +- **React Spinners** - Loading animations +- **React CountUp** - Animated numbers + +### Authentication +- **@simplewebauthn/browser** - WebAuthn/passkey support +- **@react-oauth/google** - Google OAuth integration +- **JWT Decode** - Token decoding + +### External Services +- **Googleapis** - Google API integration +- **Nodemailer** (backend) - Email sending + +### Development & Build Tools +- **Bun** - Runtime and package manager +- **Vite** - Build tool and dev server (if used) +- **Playwright** - E2E testing +- **Testing Library** - Component testing utilities +- **Happy DOM** - DOM testing + +--- + +## Directory Structure + +### Frontend Layout + +``` +src/ +├── apps/ +│ └── officer-web/ # Main dashboard app +│ ├── App.tsx # Root component & routing +│ ├── frontend.tsx # Vite entry point (if applicable) +│ ├── index.html # HTML template +│ │ +│ ├── Screens/ # Page-level components +│ │ ├── Authentication/ # Auth screens (public) +│ │ │ ├── LandingPage.tsx +│ │ │ ├── VerifyScreen.tsx +│ │ │ ├── SignInScreen.tsx +│ │ │ ├── SignUpScreen.tsx +│ │ │ ├── ForgotPassword.tsx +│ │ │ ├── ResetPassword.tsx +│ │ │ └── SignoutScreen.tsx +│ │ │ +│ │ └── Dashboard/ # Dashboard screens (protected) +│ │ ├── HomeScreen.tsx +│ │ ├── SettingsPages/ +│ │ │ ├── ProfileSettings.tsx +│ │ │ ├── SystemSettings.tsx +│ │ │ └── ResourceSettings.tsx +│ │ ├── Automation.tsx +│ │ ├── SessionListPage.tsx (Pi Chat) +│ │ ├── PlansScreen.tsx +│ │ ├── FilesScreen.tsx +│ │ ├── CodeEditorScreen.tsx +│ │ ├── SkillsScreen.tsx +│ │ ├── TasksScreen.tsx +│ │ ├── ProcessesScreen.tsx +│ │ ├── TaskLogsScreen.tsx +│ │ ├── WorkspacesScreen.tsx +│ │ ├── WorkspaceScreen.tsx +│ │ ├── ProjectListScreen.tsx +│ │ ├── ProjectScreen.tsx +│ │ └── TerminalScreen.tsx +│ │ +│ ├── state/ # Component state +│ │ ├── useAuth.ts # Authentication state +│ │ ├── useInitialData.ts # Initial data loading +│ │ └── useServerSettings.ts # Server configuration +│ │ +│ ├── lib/ # Client utilities +│ │ ├── api.ts # API client +│ │ └── websocket.ts # WebSocket utilities +│ │ +│ ├── styles/ # Global styles +│ │ ├── global.css +│ │ └── tailwind.css +│ │ +│ └── locales/ # Translations +│ ├── en.json +│ └── ... +│ +└── workspaces/ # Shared libraries + ├── components/ # Reusable React components + │ ├── ui/ # Basic UI components + │ │ ├── button.tsx + │ │ ├── input.tsx + │ │ ├── dialog.tsx + │ │ ├── dropdown-menu.tsx + │ │ ├── select.tsx + │ │ ├── tabs.tsx + │ │ ├── card.tsx + │ │ └── ...more + │ │ + │ ├── Complex/ # Feature components + │ │ ├── DataTable/ + │ │ ├── MarkdownEditor.tsx + │ │ ├── ColorPicker.tsx + │ │ ├── CommandBlock.tsx + │ │ └── ... + │ │ + │ ├── Workspace/ # Workspace-specific + │ ├── Dialogs/ # Dialog components + │ ├── ErrorDialogs/ # Error handling UI + │ ├── Logos/ # Logo variants + │ └── package.json + │ + ├── hooks/ # Custom React hooks + │ ├── useAuth.ts # Auth state hook + │ ├── useClient.ts # API client hook + │ ├── useQuery.ts # Data fetching + │ ├── useMutation.ts # Data mutation + │ └── ... + │ + ├── helpers/ # Utility functions + │ ├── formatters.ts # Date, number formatting + │ ├── validators.ts # Input validation + │ ├── converters.ts # Type conversions + │ └── ... + │ + ├── state/ # Global state + │ ├── useGlobalState.ts # Global state hook + │ └── ... + │ + ├── types/ # Type definitions + │ └── index.ts # Export all types + │ + ├── config/ # Configuration + │ ├── constants.ts + │ └── env.ts + │ + ├── definitions/ # Enums and constants + │ ├── roles.ts + │ ├── statuses.ts + │ └── ... + │ + ├── widgets/ # Complex UI widgets + │ └── ... + │ + └── i18n/ # Internationalization + └── ... +``` + +--- + +## Routing & Navigation + +### React Router Setup: `App.tsx` + +```typescript +import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; +import { useAuth } from 'hooks/useAuth'; +import { useServerSettings } from 'state/useServerSettings'; + +export function App() { + const { isLoading, isAuthenticated } = useAuth(); + const { onboardingComplete, isLoading: isServerSettingsLoading } = useServerSettings(); + + // Show nothing while loading auth state + if (isLoading || isServerSettingsLoading) return null; + + return ( + + {!isAuthenticated && ( + + + } /> + } /> + } /> + } /> + } /> + + + )} + + {isAuthenticated && onboardingComplete && ( + + + {/* Home & Settings */} + } /> + } /> + } /> + } /> + + {/* Features */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Workspaces & Projects */} + } /> + } /> + } /> + } /> + } /> + + {/* Development */} + } /> + + {/* Auth */} + } /> + + {/* Fallback */} + } /> + + + )} + + ); +} +``` + +### Layout Components + +**AuthenticationLayout** - Wrapper for public pages +```typescript +export const AuthenticationLayout = ({ children }: Props) => { + return ( +
+ {children} +
+ ); +}; +``` + +**DashboardLayout** - Wrapper for protected pages +```typescript +export const DashboardLayout = ({ children }: Props) => { + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +}; +``` + +--- + +## Component Architecture + +### Component Patterns + +#### 1. Functional Components with Props + +All components are functional components using React 19: + +```typescript +type ButtonProps = { + children: React.ReactNode; + onClick: () => void; + variant?: 'primary' | 'secondary' | 'outline'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; +}; + +export const Button = ({ + children, + onClick, + variant = 'primary', + size = 'md', + disabled = false, +}: ButtonProps) => { + return ( + + ); +}; +``` + +#### 2. Component Organization + +Complex components follow a structured pattern: + +```typescript +// Directory structure +ComponentName/ +├── index.tsx # Exports the component +├── ComponentName.tsx # Main implementation +├── hooks/ # Local hooks +│ └── useComponentState.ts +├── types.ts # Component types +└── utils.ts # Helper functions + +// index.tsx +export { ComponentName } from './ComponentName'; +export type { ComponentNameProps } from './types'; + +// ComponentName.tsx +import { useComponentState } from './hooks/useComponentState'; +import type { ComponentNameProps } from './types'; + +export const ComponentName = ({ prop1, prop2 }: ComponentNameProps) => { + const { state, actions } = useComponentState(); + + return ( +
+ {/* Render */} +
+ ); +}; +``` + +#### 3. React 19 Patterns + +**NO useCallback - React 19's compiler handles optimization:** + +```typescript +// ❌ BAD - Unnecessary useCallback +export const Form = () => { + const handleSubmit = useCallback((data) => { + api.post('/data', data); + }, []); + + return ; +}; + +// ✅ GOOD - Plain function +export const Form = () => { + const handleSubmit = (data) => { + api.post('/data', data); + }; + + return ; +}; +``` + +**NO useMemo - Compiler handles memoization:** + +```typescript +// ❌ BAD - Unnecessary useMemo +export const List = ({ items, filter }) => { + const filtered = useMemo( + () => items.filter(i => i.type === filter), + [items, filter] + ); + + return
    {filtered.map(i =>
  • {i.name}
  • )}
; +}; + +// ✅ GOOD - Direct calculation +export const List = ({ items, filter }) => { + const filtered = items.filter(i => i.type === filter); + + return
    {filtered.map(i =>
  • {i.name}
  • )}
; +}; +``` + +**Avoid stale closures - access object properties directly:** + +```typescript +// ❌ BAD - Destructuring creates stale references +export const Editor = ({ state }) => { + const { content, save } = state; + + useEffect(() => { + const timer = setTimeout(() => { + save(content); // 'content' is stale! + }, 1000); + return () => clearTimeout(timer); + }, [content, save]); +}; + +// ✅ GOOD - Direct property access +export const Editor = ({ state }) => { + useEffect(() => { + const timer = setTimeout(() => { + state.save(state.content); // Always fresh + }, 1000); + return () => clearTimeout(timer); + }, [state]); +}; +``` + +#### 4. Keyboard Event Handling + +Always prevent default for game/editor controls: + +```typescript +export const CodeEditor = () => { + const handleKeyDown = (ev: KeyboardEvent) => { + // Prevent Space from scrolling page + if (ev.code === 'Space') { + ev.preventDefault(); + // Handle space key + } + + // Prevent Escape from closing dialogs + if (ev.key === 'Escape') { + ev.preventDefault(); + // Handle escape + } + }; + + return
{/* ... */}
; +}; +``` + +#### 5. Button Focus Management + +Buttons retain focus after clicking, interfering with keyboard shortcuts: + +```typescript +export const Toolbar = () => { + return ( + <> + + + ); +}; +``` + +--- + +## State Management + +### Authentication State: `useAuth` + +```typescript +// Location: workspaces/hooks/useAuth.ts + +export const useAuth = () => { + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + const [token, setToken] = useState( + () => localStorage.getItem('token') + ); + + // Load auth state on mount + useEffect(() => { + const load = async () => { + if (!token) { + setIsAuthenticated(false); + setIsLoading(false); + return; + } + + try { + // Verify token validity + const response = await fetch('/api/auth/verify', { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + const user = await response.json(); + setUser(user); + setIsAuthenticated(true); + } else { + setIsAuthenticated(false); + localStorage.removeItem('token'); + } + } catch { + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }; + + load(); + }, [token]); + + const login = async (email: string, password: string) => { + const response = await fetch('/api/auth/signin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (response.ok) { + const { token, user } = await response.json(); + localStorage.setItem('token', token); + setToken(token); + setUser(user); + setIsAuthenticated(true); + return { success: true }; + } + + return { success: false, error: await response.text() }; + }; + + const logout = async () => { + try { + await fetch('/api/auth/signout', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch { + // Ignore errors during logout + } finally { + localStorage.removeItem('token'); + setToken(null); + setUser(null); + setIsAuthenticated(false); + } + }; + + return { + isLoading, + isAuthenticated, + user, + token, + login, + logout, + }; +}; + +// Usage in components +export const Dashboard = () => { + const { user, logout } = useAuth(); + + return ( +
+

Welcome, {user?.email}

+ +
+ ); +}; +``` + +### Server Settings State: `useServerSettings` + +```typescript +export const useServerSettings = () => { + const [isLoading, setIsLoading] = useState(true); + const [onboardingComplete, setOnboardingComplete] = useState(false); + const [plugins, setPlugins] = useState([]); + const [settings, setSettings] = useState(null); + + useEffect(() => { + const load = async () => { + try { + const response = await fetch('/api/server-settings'); + if (response.ok) { + const data = await response.json(); + setOnboardingComplete(data.onboardingComplete); + setPlugins(data.plugins); + setSettings(data.settings); + } + } catch { + console.error('Failed to load server settings'); + } finally { + setIsLoading(false); + } + }; + + load(); + }, []); + + return { + isLoading, + onboardingComplete, + plugins, + settings, + }; +}; +``` + +### Global State: `useGlobalState` + +For component-tree-wide state, use Context: + +```typescript +type GlobalContextType = { + theme: 'light' | 'dark'; + setTheme: (theme: 'light' | 'dark') => void; + sidebarOpen: boolean; + setSidebarOpen: (open: boolean) => void; +}; + +const GlobalContext = createContext(null); + +export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { + const [theme, setTheme] = useState<'light' | 'dark'>(() => { + const saved = localStorage.getItem('theme'); + return (saved as 'light' | 'dark') || 'dark'; + }); + + const [sidebarOpen, setSidebarOpen] = useState(true); + + useEffect(() => { + localStorage.setItem('theme', theme); + document.documentElement.classList.toggle('dark', theme === 'dark'); + }, [theme]); + + return ( + + {children} + + ); +}; + +export const useGlobalState = () => { + const context = useContext(GlobalContext); + if (!context) { + throw new Error('useGlobalState must be used within GlobalProvider'); + } + return context; +}; +``` + +### React Query for Server State + +```typescript +// useQueryPlan.ts +export const useQueryPlan = (id: number) => { + return useQuery({ + queryKey: ['plans', id], + queryFn: async () => { + const response = await fetch(`/api/plans/${id}`, { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, + }); + if (!response.ok) throw new Error('Failed to load plan'); + return response.json(); + }, + }); +}; + +// useMutationCreatePlan.ts +export const useMutationCreatePlan = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: CreatePlanInput) => { + const response = await fetch('/api/plans', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + body: JSON.stringify(data), + }); + if (!response.ok) throw new Error('Failed to create plan'); + return response.json(); + }, + onSuccess: () => { + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: ['plans'] }); + }, + }); +}; + +// In component +export const CreatePlanForm = () => { + const { mutate, isPending } = useMutationCreatePlan(); + + const handleSubmit = (data: CreatePlanInput) => { + mutate(data); + }; + + return ( +
+ {/* Form fields */} + +
+ ); +}; +``` + +--- + +## API Communication + +### Client Initialization: `src/apps/officer-web/lib/api.ts` + +```typescript +// Centralized API client +const API_BASE = import.meta.env.VITE_API_URL || '/api'; + +export const apiClient = { + async fetch( + endpoint: string, + options: RequestInit = {}, + ): Promise { + const token = localStorage.getItem('token'); + + return fetch(`${API_BASE}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + ...options.headers, + }, + }); + }, + + async get(endpoint: string): Promise { + const response = await this.fetch(endpoint); + if (!response.ok) throw new Error(`GET ${endpoint} failed`); + return response.json(); + }, + + async post(endpoint: string, body?: any): Promise { + const response = await this.fetch(endpoint, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) throw new Error(`POST ${endpoint} failed`); + return response.json(); + }, + + async put(endpoint: string, body?: any): Promise { + const response = await this.fetch(endpoint, { + method: 'PUT', + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) throw new Error(`PUT ${endpoint} failed`); + return response.json(); + }, + + async delete(endpoint: string): Promise { + const response = await this.fetch(endpoint, { method: 'DELETE' }); + if (!response.ok) throw new Error(`DELETE ${endpoint} failed`); + return response.json(); + }, +}; + +// Usage +const user = await apiClient.get('/users/me'); +await apiClient.post('/plans', { title: 'New Plan' }); +``` + +--- + +## Real-Time Features + +### Terminal WebSocket: `TerminalScreen.tsx` + +```typescript +import { XTerm } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; + +export const TerminalScreen = () => { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const wsRef = useRef(null); + + useEffect(() => { + const term = new XTerm({ + cols: 120, + rows: 40, + theme: { background: '#0f172a', foreground: '#e2e8f0' }, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + + // Mount terminal + if (terminalRef.current) { + term.open(terminalRef.current); + fitAddon.fit(); + } + + xtermRef.current = term; + + // Connect WebSocket + const token = localStorage.getItem('token'); + const cwd = '/home/user'; // Or from settings + + const ws = new WebSocket( + `ws://localhost:5000/api/terminal/ws?token=${token}&cwd=${encodeURIComponent(cwd)}&cols=120&rows=40` + ); + + ws.onopen = () => { + console.log('Terminal connected'); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'output') { + term.write(message.data); // Display output + } else if (message.type === 'error') { + console.error('Terminal error:', message.error); + } + }; + + ws.onerror = () => { + term.write('\r\nConnection error\r\n'); + }; + + ws.onclose = () => { + term.write('\r\nDisconnected\r\n'); + }; + + // Send input from terminal + term.onData((data) => { + ws.send(JSON.stringify({ type: 'input', data })); + }); + + wsRef.current = ws; + + // Handle resize + const handleResize = () => { + fitAddon.fit(); + const { cols, rows } = term; + ws.send(JSON.stringify({ type: 'resize', cols, rows })); + }; + + window.addEventListener('resize', handleResize); + + // Cleanup + return () => { + window.removeEventListener('resize', handleResize); + ws.close(); + term.dispose(); + }; + }, []); + + return
; +}; +``` + +### Pi Chat WebSocket: `SessionListPage.tsx` + +```typescript +export const SessionListPage = ({ sessionId, isNew }: Props) => { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const wsRef = useRef(null); + + // Connect to Pi chat WebSocket + useEffect(() => { + const token = localStorage.getItem('token'); + const url = `ws://localhost:5000/api/pi/chat/ws?token=${token}&sessionId=${sessionId}`; + + const ws = new WebSocket(url); + + ws.onopen = () => { + console.log('Pi chat connected'); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + switch (message.type) { + case 'message_update': + // Stream response text + if (message.event.type === 'text_delta') { + setMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant') { + return [ + ...prev.slice(0, -1), + { ...last, content: last.content + message.event.delta }, + ]; + } + return prev; + }); + } + break; + + case 'tool_execution_start': + setMessages(prev => [ + ...prev, + { role: 'tool', toolName: message.toolName, content: 'Executing...' }, + ]); + break; + + case 'agent_end': + setIsLoading(false); + break; + } + }; + + ws.onerror = (error) => { + console.error('Pi chat error:', error); + setIsLoading(false); + }; + + wsRef.current = ws; + + return () => ws.close(); + }, [sessionId]); + + // Send message + const handleSendMessage = () => { + if (!input.trim() || !wsRef.current) return; + + const userMessage = input; + setInput(''); + setIsLoading(true); + + // Add user message to UI + setMessages(prev => [ + ...prev, + { role: 'user', content: userMessage }, + ]); + + // Send to Pi + wsRef.current.send(JSON.stringify({ + type: 'prompt', + text: userMessage, + })); + }; + + return ( +
+
+ {messages.map((msg, i) => ( +
+
+ {msg.content} +
+
+ ))} + {isLoading &&
Loading...
} +
+ +
+
+ setInput(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()} + placeholder="Ask Pi anything..." + disabled={isLoading} + className="flex-1 px-4 py-2 border rounded" + /> + +
+
+
+ ); +}; +``` + +--- + +## Styling & Theming + +### Tailwind CSS Integration + +Officer uses Tailwind CSS 4 with a custom configuration: + +```javascript +// tailwind.config.js +export default { + content: [ + './src/**/*.{ts,tsx}', + ], + theme: { + extend: { + colors: { + // Custom color palette if needed + }, + spacing: { + // Custom spacing + }, + animation: { + // Custom animations + }, + }, + }, + plugins: [ + require('tailwindcss-animate'), + ], +}; +``` + +### Shadcn/ui Components + +Reusable UI components from shadcn/ui: + +```typescript +// Button component wrapper +import { Button as ShadcnButton } from '@/components/ui/button'; + +export const Button = (props) => ( + +); + +// Usage + +``` + +### Custom Theming + +Support for light/dark themes: + +```typescript +// Global theme management +export const useTheme = () => { + const { theme, setTheme } = useGlobalState(); + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + localStorage.setItem('theme', theme); + }, [theme]); + + return { theme, setTheme }; +}; + +// In component +export const ThemeToggle = () => { + const { theme, setTheme } = useTheme(); + + return ( + + ); +}; +``` + +--- + +## Performance & Optimization + +### Image Optimization + +Use Next Image component or lazy loading: + +```typescript +import { lazy, Suspense } from 'react'; + +// Lazy load heavy components +const CodeEditor = lazy(() => import('./CodeEditor')); + +export const FeaturePage = () => { + return ( + Loading...
}> + + + ); +}; +``` + +### Virtual Scrolling for Large Lists + +Use React Virtual for efficient rendering: + +```typescript +import { useVirtualizer } from '@tanstack/react-virtual'; + +export const VirtualList = ({ items }: { items: Item[] }) => { + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + }); + + return ( +
+
+ {virtualizer.getVirtualItems().map(virtualItem => ( +
+ {items[virtualItem.index]?.name} +
+ ))} +
+
+ ); +}; +``` + +### Code Splitting with React Router + +Routes are automatically code-split: + +```typescript +// Lazy load screen components +const HomeScreen = lazy(() => import('./HomeScreen')); +const SettingsScreen = lazy(() => import('./SettingsScreen')); + +}> + + } /> + } /> + + +``` + +### React Query Caching + +Automatic caching and background updates: + +```typescript +// Data is cached per queryKey +const { data: plans } = useQuery({ + queryKey: ['plans'], // Cached + queryFn: fetchPlans, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes (was cacheTime) +}); + +// Background refetch when window regains focus +useQuery({ + queryKey: ['plans'], + queryFn: fetchPlans, + refetchOnWindowFocus: true, +}); +``` + +--- + +## Development Workflow + +### Development Server + +```bash +# Start Bun dev server with hot reload +bun dev + +# Runs on http://localhost:5000/ +# Frontend and backend both reload on file changes +``` + +### Environment Variables + +```bash +# .env (client-side, public) +VITE_API_URL=http://localhost:5000/api +``` + +### TypeScript Type Checking + +```bash +# Run TypeScript compiler +tsc --noEmit + +# Included in build process +bun run build +``` + +### Code Formatting + +```bash +# Format all files +bun format + +# Format specific files +bun format:check + +# Prettier config in .prettierrc +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120 +} +``` + +### Component Development + +When creating new features: + +1. **Create component structure** +``` +src/apps/officer-web/Screens/MyFeature/ +├── index.tsx # Exports +├── MyFeatureScreen.tsx # Component +├── hooks/ +│ └── useMyFeature.ts +├── types.ts +└── utils.ts +``` + +2. **Define types** +```typescript +// types.ts +export type MyFeatureProps = { + onSubmit: (data: FormData) => void; + disabled?: boolean; +}; +``` + +3. **Implement component** +```typescript +// MyFeatureScreen.tsx +import { useMyFeature } from './hooks/useMyFeature'; +import type { MyFeatureProps } from './types'; + +export const MyFeatureScreen = ({ onSubmit, disabled }: MyFeatureProps) => { + const { state, actions } = useMyFeature(); + + return (/* JSX */); +}; +``` + +4. **Export** +```typescript +// index.tsx +export { MyFeatureScreen } from './MyFeatureScreen'; +export type { MyFeatureProps } from './types'; +``` + +--- + +## Integration Points + +### API Endpoints Used + +- **Auth**: `/api/auth/*` (signin, signup, verify, etc.) +- **Plans**: `/api/plans` (GET, POST, PUT, DELETE) +- **Skills**: `/api/skills` (GET, POST) +- **Tasks**: `/api/tasks` (CRUD operations) +- **Files**: `/api/file-browser` (navigation, upload) +- **Settings**: `/api/user` (preferences) +- **Terminal WS**: `/api/terminal/ws` (real-time shell) +- **Pi Chat WS**: `/api/pi/chat/ws` (real-time AI assistant) + +### State Flow from Server to UI + +``` +Database + ↓ +API Response + ↓ +React Query Cache + ↓ +Component State + ↓ +Rendered UI +``` + +--- + +## Debugging + +### Browser DevTools + +```javascript +// Log component props +console.log('Props:', props); + +// Log state updates +console.log('State changed:', newState); + +// React DevTools browser extension +// Inspect component tree and props +``` + +### React Query Devtools + +```typescript +// Installed: @tanstack/react-query-devtools + +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; + +export function App() { + return ( + <> + {/* App content */} + + + ); +} +``` + +--- + +## Common Patterns + +### Loading States + +```typescript +export const DataComponent = () => { + const { data, isLoading, error } = useQuery({ + queryKey: ['data'], + queryFn: fetchData, + }); + + if (isLoading) return ; + if (error) return ; + if (!data) return ; + + return ; +}; +``` + +### Form Handling with React Hook Form + +```typescript +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; + +const schema = z.object({ + email: z.string().email('Invalid email'), + password: z.string().min(8, 'Min 8 chars'), +}); + +type FormData = z.infer; + +export const LoginForm = () => { + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormData) => { + await apiClient.post('/auth/signin', data); + }; + + return ( +
+ + {errors.email && {errors.email.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +}; +``` + +### Toast Notifications + +```typescript +import { toast } from 'sonner'; + +// Success +toast.success('Operation completed!'); + +// Error +toast.error('Something went wrong', { + description: 'Please try again later', +}); + +// Custom +toast.custom((t) => ( +
Custom notification
+)); +``` + +--- + +## Related Documentation + +- **Backend Architecture**: See `OFFICERDEV_BACKEND.md` +- **CONVENTIONS.md**: Detailed code patterns and style +- **CLAUDE.md**: Project overview +- **apps/officer-web/CLAUDE.md**: Frontend-specific patterns + +--- + +## Summary + +Officer's frontend is a modern React 19 SPA featuring: +- Type-safe component architecture +- Real-time WebSocket communication +- Server state management with React Query +- Beautiful UI with Tailwind CSS and shadcn/ui +- Strong React patterns without useCallback/useMemo +- Comprehensive authentication and authorization +- Responsive design for all devices +- AI-powered development assistance via Pi +- Interactive terminal emulation + +The architecture prioritizes developer experience, type safety, and performance while providing a rich, interactive user experience for life management and AI-assisted development. diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 13d3bf1c..578e8c33 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -4,9 +4,26 @@ import type { ClientMessage, ServerMessage, Message, PiEvent } from './types'; import { sessionManager } from './session-manager'; import * as storage from './storage'; import * as piBridge from './pi-bridge'; -import { getHomeDir } from '../../../servers/data-path'; +import { getHomeDir, getUserSettingsFile } from '../../../servers/data-path'; import { logger } from './logger'; +// Default model when no user preference is set +const DEFAULT_MODEL = 'opencode/big-pickle'; + +async function getUserDefaultModel(email: string): Promise { + try { + const settingsPath = getUserSettingsFile(email); + const file = Bun.file(settingsPath); + if (await file.exists()) { + const settings = await file.json(); + return settings?.chat?.defaultModel || null; + } + } catch (err) { + logger.error('Failed to read user settings for default model', { email, error: String(err) }); + } + return null; +} + type WSData = { userId: number; email: string; @@ -25,7 +42,7 @@ function sendToClient(ws: ServerWebSocket | null, msg: ServerMessage): v } export async function open(ws: ServerWebSocket): Promise { - logger.info('WebSocket connection opened', { email: ws.data.email }); + // logger.info('WebSocket connection opened', { email: ws.data.email }); } export function message(ws: ServerWebSocket, raw: string | Buffer): void { @@ -50,7 +67,7 @@ export function message(ws: ServerWebSocket, raw: string | Buffer): void } export function close(ws: ServerWebSocket): void { - logger.info('WebSocket connection closed', { email: ws.data.email }); + // logger.info('WebSocket connection closed', { email: ws.data.email }); const sessionId = wsToSessionMap.get(ws); if (sessionId) { @@ -207,7 +224,30 @@ async function handleChat( ): Promise { const { email } = ws.data; const sessionId = msg.sessionId || randomUUID(); - const model = msg.model || 'opencode/big-pickle'; + + // Use provided model, or fall back to user default, or use system default + let model = msg.model; + let modelSource = 'client-provided'; + let userDefault = null; + if (!model) { + userDefault = await getUserDefaultModel(email); + if (userDefault) { + model = userDefault; + modelSource = 'user-settings'; + } else { + model = DEFAULT_MODEL; + modelSource = 'system-default'; + } + } + + logger.info('Model selected for chat', { + sessionId, + model, + modelSource, + clientModel: msg.model || null, + userDefault, + }); + const cwd = msg.cwd || getHomeDir(email); const groupSlug = msg.groupSlug || null; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatLauncher/ChatLauncher.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatLauncher/ChatLauncher.tsx index 741f7882..830ed6c1 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatLauncher/ChatLauncher.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatLauncher/ChatLauncher.tsx @@ -112,6 +112,7 @@ export function ChatLauncher({ model={selectedModel} isConnected={true} isGenerating={false} + hasStarted={false} /> diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 5f306501..080418d7 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -35,6 +35,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) { sessionId, model, selectedModel, + hasStarted, setSelectedModel, sendPrompt, stopGeneration, @@ -170,6 +171,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) { sessionId, model, selectedModel, + hasStarted, setSelectedModel, sendPrompt, stopGeneration, diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index ddca62a4..88d01779 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -28,6 +28,7 @@ export const InputArea = ({ manager }: InputAreaProps) => { selectedModel, setSelectedModel, model, + hasStarted, attachments, attachWebpage, attachImage, @@ -94,6 +95,7 @@ export const InputArea = ({ manager }: InputAreaProps) => { model={model} isConnected={isConnected} isGenerating={isGenerating} + hasStarted={hasStarted} /> diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index cc8e1d15..f87c6a31 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -27,6 +27,7 @@ type ModelSelectorProps = { model: string | null; isConnected: boolean; isGenerating: boolean; + hasStarted: boolean; }; export function ModelSelector({ @@ -37,23 +38,40 @@ export function ModelSelector({ model, isConnected, isGenerating, + hasStarted, }: ModelSelectorProps) { const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[]; - const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0]; + // Determine which model to display: selectedModel takes precedence, then model (from server), then fallback + const displayModel = selectedModel || model; + + const activeProvider = availableModels.find((m) => m.id === displayModel)?.provider ?? providers[0]; const providerModels = availableModels.filter((m) => m.provider === activeProvider); const fallbackModelId = providerModels[0]?.id ?? null; + // Lock after session has started + const isLocked = hasStarted || isGenerating || !isConnected; + const handleProviderClick = (provider: string) => { + if (isLocked) return; const firstModel = availableModels.find((m) => m.provider === provider); if (firstModel) onModelChange(firstModel.id); }; const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider; + // Get display text for the model + const getModelDisplayText = () => { + if (displayModel) { + const found = availableModels.find((m) => m.id === displayModel); + return found?.name || displayModel; + } + return 'Select model'; + }; + return (
- {messages.length > 0 ? ( + {isLocked ? ( {activeProvider ? displayName(activeProvider) : 'Pi'} @@ -77,9 +95,9 @@ export function ModelSelector({
{providerModels.length > 0 ? ( ) : ( - {model ?? 'Pi'} + {getModelDisplayText()} )}
diff --git a/src/workspaces/officerdev/src/hooks/usePiChat.ts b/src/workspaces/officerdev/src/hooks/usePiChat.ts index 5870c0f3..75f704cb 100644 --- a/src/workspaces/officerdev/src/hooks/usePiChat.ts +++ b/src/workspaces/officerdev/src/hooks/usePiChat.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { useChatWebSocket } from 'hooks/useChatWebSocket'; import { useChatSessions } from 'state/useChatSessions'; +import { useSettings } from 'state/useSettings'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; const SAVE_DEBOUNCE_MS = 1000; @@ -27,6 +28,19 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul const [selectedModel, setSelectedModel] = useState(initialModel ?? null); const [cwd, setCwd] = useState(null); + // Track if session has started (first message sent) + const [hasStarted, setHasStarted] = useState(false); + + // Get user settings for default model + const { settings } = useSettings(); + + // Set default model from settings when starting a new chat (no initialSessionId, no initialModel) + useEffect(() => { + if (!initialSessionId && !initialModel && settings?.chat?.defaultModel) { + setSelectedModel(settings.chat.defaultModel); + } + }, [initialSessionId, initialModel, settings]); + const streamingRef = useRef(''); const rafRef = useRef(null); const sessionIdRef = useRef(initialSessionId ?? null); @@ -238,6 +252,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul cwdParam?: { root?: string; path: string }, groupSlug?: string | null, ) { + // Mark session as started on first message + if (!hasStarted) { + setHasStarted(true); + } + setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]); setIsGenerating(true); streamingRef.current = ''; @@ -277,6 +296,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul sessionId, model, selectedModel, + hasStarted, cwd, setSelectedModel, sendPrompt,