```
├── .eslintrc.json
├── .gitignore (200 tokens)
├── LICENSE (omitted)
├── README.md (2.3k tokens)
├── app/
├── [project_id]/
├── chat/
├── page.tsx (27k tokens)
├── api/
├── assets/
├── [project_id]/
├── [filename]/
├── route.ts (700 tokens)
├── logo/
├── route.ts (300 tokens)
├── upload/
├── route.ts (800 tokens)
├── chat/
├── [project_id]/
├── act/
├── route.ts (3.2k tokens)
├── active-session/
├── route.ts (200 tokens)
├── cli-preference/
├── route.ts (400 tokens)
├── messages/
├── route.ts (1000 tokens)
├── requests/
├── active/
├── route.ts (200 tokens)
├── sessions/
├── [session_id]/
├── status/
├── route.ts (200 tokens)
├── stream/
├── route.ts (500 tokens)
├── env/
├── [project_id]/
├── [key]/
├── route.ts (400 tokens)
├── conflicts/
├── route.ts (200 tokens)
├── route.ts (400 tokens)
├── sync/
├── db-to-file/
├── route.ts (200 tokens)
├── file-to-db/
├── route.ts (200 tokens)
├── upsert/
├── route.ts (300 tokens)
├── github/
├── check-repo/
├── [repo_name]/
├── route.ts (200 tokens)
├── create-repo/
├── route.ts (300 tokens)
├── projects/
├── [project_id]/
├── files/
├── content/
├── route.ts (500 tokens)
├── route.ts (200 tokens)
├── github/
├── connect/
├── route.ts (400 tokens)
├── push/
├── route.ts (200 tokens)
├── install-dependencies/
├── route.ts (200 tokens)
├── preview/
├── start/
├── route.ts (200 tokens)
├── status/
├── route.ts (200 tokens)
├── stop/
├── route.ts (200 tokens)
├── route.ts (700 tokens)
├── services/
├── [service_id]/
├── route.ts (200 tokens)
├── route.ts (200 tokens)
├── supabase/
├── connect/
├── route.ts (300 tokens)
├── vercel/
├── connect/
├── route.ts (300 tokens)
├── deploy/
├── route.ts (200 tokens)
├── deployment/
├── current/
├── route.ts (200 tokens)
├── route.ts (400 tokens)
├── repo/
├── [project_id]/
├── file/
├── route.ts (500 tokens)
├── tree/
├── route.ts (300 tokens)
├── settings/
├── cli-status/
├── route.ts (1000 tokens)
├── global/
├── route.ts (300 tokens)
├── route.ts
├── supabase/
├── create-project/
├── route.ts (400 tokens)
├── organizations/
├── route.ts (200 tokens)
├── projects/
├── [supabase_project_id]/
├── api-keys/
├── route.ts (200 tokens)
├── route.ts (200 tokens)
├── tokens/
├── [...segments]/
├── route.ts (400 tokens)
├── route.ts (200 tokens)
├── vercel/
├── check-project/
├── [name]/
├── route.ts (200 tokens)
├── globals.css (1400 tokens)
├── layout.tsx (200 tokens)
├── page.tsx (12k tokens)
├── claude_code_zai_env.sh (1100 tokens)
├── components/
├── ErrorBoundary.tsx (800 tokens)
├── chat/
├── ChatInput.tsx (4.6k tokens)
├── ChatLog.tsx (21.7k tokens)
├── ThinkingSection.tsx (500 tokens)
├── ToolResultItem.tsx (1400 tokens)
├── layout/
├── Header.tsx (800 tokens)
├── modals/
├── CreateProjectModal.tsx (8.3k tokens)
├── DeleteProjectModal.tsx (800 tokens)
├── GitHubRepoModal.tsx (3.6k tokens)
├── ServiceConnectionModal.tsx (4.6k tokens)
├── SupabaseModal.tsx (3.9k tokens)
├── VercelProjectModal.tsx (1800 tokens)
├── settings/
├── AIAssistantSettings.tsx (600 tokens)
├── EnvironmentSettings.tsx (1900 tokens)
├── GeneralSettings.tsx (1500 tokens)
├── GlobalSettings.tsx (9.9k tokens)
├── ProjectSettings.tsx (1200 tokens)
├── ServiceSettings.tsx (3.3k tokens)
├── SettingsModal.tsx (400 tokens)
├── contexts/
├── AuthContext.tsx (100 tokens)
├── GlobalSettingsContext.tsx (400 tokens)
├── electron/
├── main.js (1500 tokens)
├── preload.js (100 tokens)
├── hooks/
├── useCLI.ts (1600 tokens)
├── useUserRequests.ts (1300 tokens)
├── useWebSocket.ts (2.3k tokens)
├── index.js
├── lib/
├── config/
├── constants.ts (300 tokens)
├── constants/
├── claudeModels.ts (700 tokens)
├── cliModels.ts (600 tokens)
├── codexModels.ts (500 tokens)
├── cursorModels.ts (600 tokens)
├── glmModels.ts (400 tokens)
├── qwenModels.ts (500 tokens)
├── crypto.ts (300 tokens)
├── db/
├── client.ts (100 tokens)
├── motion.ts (100 tokens)
├── serializers/
├── chat.ts (400 tokens)
├── client/
├── chat.ts (1200 tokens)
├── project.ts (200 tokens)
├── server/
├── websocket-manager.ts (700 tokens)
├── services/
├── chat-sessions.ts (100 tokens)
├── cli/
├── claude.ts (7.8k tokens)
├── codex.ts (6.3k tokens)
├── cursor.ts (5.3k tokens)
├── glm.ts (5.5k tokens)
├── qwen.ts (2.3k tokens)
├── env.ts (2000 tokens)
├── file-browser.ts (1300 tokens)
├── git.ts (1000 tokens)
├── github.ts (1800 tokens)
├── message.ts (1000 tokens)
├── preview.ts (5.5k tokens)
├── project-services.ts (600 tokens)
├── project.ts (1300 tokens)
├── service-integration.ts (400 tokens)
├── settings.ts (700 tokens)
├── stream.ts (800 tokens)
├── supabase.ts (1100 tokens)
├── tokens.ts (500 tokens)
├── user-requests.ts (600 tokens)
├── vercel.ts (2.7k tokens)
├── utils/
├── api-response.ts (400 tokens)
├── cliOptions.ts (700 tokens)
├── index.ts (100 tokens)
├── path.ts (900 tokens)
├── ports.ts (600 tokens)
├── scaffold.ts (1600 tokens)
├── next.config.js (200 tokens)
├── package-lock.json (omitted)
├── package.json (700 tokens)
├── pages/
├── api/
├── ws/
├── [projectId].ts (600 tokens)
├── postcss.config.js
├── prisma/
├── schema.prisma (1700 tokens)
├── public/
├── Claudable_Icon.png
├── Claudable_logo.svg (1400 tokens)
├── Symbol_white.png
├── claude.png
├── cursor.png
├── favicon-16.png
├── favicon-32.png
├── favicon.png
├── gemini.png
├── glm.svg (2.2k tokens)
├── oai.png
├── qwen.png
├── uploads/
├── 733216f6-71de-4e75-944f-4955be4ec401.png
├── bb9dcded-362a-497b-bbbb-6221b931f964.png
├── c24d48d4-1b8d-41df-86f2-4d57ec566855.png
├── d388f1f1-bb62-48e2-8a9f-fe7430a00ee9.png
├── ee3c1ff1-68cf-4100-8d67-8e3ce1a00ae6.png
├── f4a207bd-bb9f-465a-937f-149a60e66e71.png
├── scripts/
├── check-claude-cli.js (400 tokens)
├── run-desktop.js (800 tokens)
├── run-web.js (1100 tokens)
├── setup-env.js (1900 tokens)
├── stubs/
├── react-icons-fa.tsx (400 tokens)
├── react-icons-si.tsx (100 tokens)
├── react-icons-vsc.tsx
├── styles/
├── scrollbar.css (400 tokens)
├── tailwind.config.ts (200 tokens)
├── tsconfig.json (300 tokens)
├── types/
├── backend/
├── chat.ts (400 tokens)
├── cli.ts (300 tokens)
├── files.ts
├── index.ts
├── project.ts (300 tokens)
├── chat.ts (300 tokens)
├── cli.ts (900 tokens)
├── client/
├── index.ts
├── modal.ts (200 tokens)
├── project.ts (100 tokens)
├── fernet.d.ts (omitted)
├── index.ts
├── project.ts (200 tokens)
├── realtime.ts (300 tokens)
├── server/
├── index.ts
├── project.ts (200 tokens)
├── shared/
├── chat.ts
├── cli.ts (100 tokens)
├── github.ts (100 tokens)
├── index.ts
├── project.ts (200 tokens)
├── service.ts (200 tokens)
├── vercel.ts (200 tokens)
```
## /.eslintrc.json
```json path="/.eslintrc.json"
{
"extends": ["next/core-web-vitals"]
}
```
## /.gitignore
```gitignore path="/.gitignore"
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# ============================================================
# Claudable Specific
# ============================================================
# SQLite database
prisma/*.db
prisma/*.db-journal
prisma/*.db-wal
# Generated projects
data/
/data/projects/
# Logs
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
*.tmp
# YoYo AI version control directory
.yoyo/
# Electron build artifacts (too large for GitHub)
dist/
release/
```
## /README.md
# Claudable
<img src="https://storage.googleapis.com/claudable-assets/Claudable.png" alt="Claudable" style="width: 100%;" />
<div align="center">
<h3>Connect CLI Agent • Build what you want • Deploy instantly</h3>
<p>Available as a web service at <a href="https://clink.new">clink.new</a></p>
</div>
<p align="center">
<a href="https://github.com/hesreallyhim/awesome-claude-code">
<img src="https://awesome.re/mentioned-badge.svg" alt="Mentioned in Awesome Claude Code">
</a>
<a href="https://twitter.com/aaron_xong">
<img src="https://img.shields.io/badge/Follow-@aaron__xong-000000?style=flat&logo=x&logoColor=white" alt="Follow Aaron">
</a>
<a href="https://discord.gg/NJNbafHNQC">
<img src="https://img.shields.io/badge/Discord-Join%20Community-7289da?style=flat&logo=discord&logoColor=white" alt="Join Discord Community">
</a>
<a href="https://github.com/opactorai/Claudable">
<img src="https://img.shields.io/github/stars/opactorai/Claudable?style=flat&logo=github&logoColor=white&labelColor=181717&color=f9d71c" alt="GitHub Stars">
</a>
<a href="https://github.com/opactorai/Claudable">
<img src="https://img.shields.io/github/forks/opactorai/Claudable?style=flat&logo=github&logoColor=white&labelColor=181717&color=181717" alt="GitHub Forks">
</a>
<a href="https://clink.new">
<img src="https://img.shields.io/badge/Clink-Web%20Service-000000?style=flat&logo=web&logoColor=white" alt="Clink Web Service">
</a>
<a href="https://github.com/opactorai/Claudable/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/opactorai/Claudable?style=flat&logo=github&logoColor=white&labelColor=181717&color=181717" alt="License">
</a>
</p>
## What is Claudable?
Claudable is a powerful Next.js-based web app builder that combines **C**laude Code's (Cursor CLI also supported!) advanced AI agent capabilities with **Lovable**'s simple and intuitive app building experience. Just describe your app idea - "I want a task management app with dark mode" - and watch as Claudable instantly generates the code and shows you a live preview of your working app. You can deploy your app to Vercel and integrate database with Supabase for free.
This open-source project empowers you to build and deploy professional web applications easily for **free**.
How to start? Simply login to Claude Code (or Cursor CLI), start Claudable, and describe what you want to build. That's it. There is no additional subscription cost for app builder.
## Try Clink - Web Service
<div align="center">
<a href="https://clink.new">
<img src="https://storage.googleapis.com/claudable-assets/clink.png" alt="Clink - Link, Click, Ship" style="width: 100%; max-width: 800px;">
</a>
<p>Don't want to set up locally? Try <a href="https://clink.new"><strong>Clink</strong></a> - the web-based version with instant access!</p>
</div>
## Features
- **Powerful Agent Performance**: Leverage the full power of Claude Code and Cursor CLI Agent capabilities
- **Natural Language to Code**: Simply describe what you want to build, and Claudable generates production-ready Next.js code
- **Instant Preview**: See your changes immediately with hot-reload as AI builds your app
- **Zero Setup, Instant Launch**: No complex sandboxes, no API key, no database headaches - just start building immediately
- **Beautiful UI**: Generate beautiful UI with Tailwind CSS and shadcn/ui
- **Deploy to Vercel**: Push your app live with a single click, no configuration needed
- **GitHub Integration**: Automatic version control and continuous deployment setup
- **Supabase Database**: Connect production PostgreSQL with authentication ready to use
- **Desktop App**: Available as Electron desktop application for Mac, Windows, and Linux
## Supported AI Coding Agents
Claudable supports multiple AI coding agents, giving you the flexibility to choose the best tool for your needs:
- **Claude Code** - Anthropic's advanced AI coding agent
- **Codex CLI** - OpenAI's powerful coding agent
- **Cursor CLI** - Powerful multi-model AI agent
- **Qwen Code** - Alibaba's open-source coding CLI
- **Z.AI GLM-4.6** - Zhipu AI's coding agent
### Claude Code (Recommended)
**[Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup)** - Anthropic's advanced AI coding agent with Claude Opus 4.1
- **Features**: Deep codebase awareness, Unix philosophy, direct terminal integration
- **Context**: Native 200k tokens
- **Pricing**: Requires Anthropic API key or Claude subscription
- **Installation**:
```bash
npm install -g @anthropic-ai/claude-code
claude # then > /login
```
### Codex CLI
**[Codex CLI](https://github.com/openai/codex)** - OpenAI's powerful coding agent with GPT-5 support
- **Features**: High reasoning capabilities, local execution, multiple operating modes (interactive, auto-edit, full-auto)
- **Context**: Varies by model
- **Pricing**: Included with ChatGPT Plus/Pro/Business/Edu/Enterprise plans
- **Installation**:
```bash
npm install -g @openai/codex
codex # login with ChatGPT account
```
### Cursor CLI
**[Cursor CLI](https://cursor.com/en/cli)** - Powerful AI agent with access to cutting-edge models
- **Features**: Multi-model support (Anthropic, OpenAI), AGENTS.md support
- **Context**: Model dependent
- **Pricing**: Starting from $20/month Pro plan
- **Installation**:
```bash
curl https://cursor.com/install -fsS | bash
cursor-agent login
```
### Qwen Code
**[Qwen Code](https://github.com/QwenLM/qwen-code)** - Alibaba's open-source CLI for Qwen3-Coder models
- **Features**: 256K-1M token context, multiple model sizes (0.5B to 480B), Apache 2.0 license
- **Context**: 256K native, 1M with extrapolation
- **Pricing**: Completely free and open-source
- **Installation**:
```bash
npm install -g @qwen-code/qwen-code@latest
qwen --version
```
### Z.AI GLM-4.6
**[Z.AI GLM-4.6](https://z.ai/subscribe)** - Zhipu AI's coding agent powered by GLM-4.6
- **Features**: Strong reasoning capabilities and cost-efficient, code generation and understanding
- **Context**: 200K tokens
- **Pricing**: Starting from $3/month (GLM Coding Lite) to $30/month (GLM Coding Max), with 50% off first month
- **Installation**: See [Quick Start Guide](https://docs.z.ai/devpack/quick-start)
## Technology Stack
**Database & Deployment:**
- **[Supabase](https://supabase.com/)**: Connect production-ready PostgreSQL database directly to your project.
- **[Vercel](https://vercel.com/)**: Publish your work immediately with one-click deployment
**There is no additional subscription cost and built just for YOU.**
## Prerequisites
Before you begin, ensure you have the following installed:
- Node.js 18+
- Claude Code or Cursor CLI (already logged in)
- Git
## Quick Start
Get Claudable running on your local machine in minutes:
```bash
# Clone the repository
git clone https://github.com/opactorai/Claudable.git
cd Claudable
# Install all dependencies
npm install
# Start development server
npm run dev
```
Your application will be available at http://localhost:3000
**Note**: Ports are automatically detected. If the default port is in use, the next available port will be assigned.
## Troubleshooting
- **Database migration conflicts**: If you upgraded from a previous Claudable version and run into database errors, reset the Prisma database so it matches the latest schema:
```bash
npm run prisma:reset
```
The command drops and recreates the local database, so back up any data you need before running it.
## Setup
The `npm install` command automatically handles the complete setup:
1. **Port Configuration**: Detects available ports and creates `.env` files
2. **Dependencies**: Installs all required Node.js packages
3. **Database Setup**: SQLite database auto-creates at `data/cc.db` on first run
### Desktop App (Electron)
Build and run Claudable as a desktop application:
```bash
# Development mode
npm run dev:desktop
# Build desktop app
npm run build:desktop
# Package for specific platforms
npm run package:mac # macOS
npm run package:win # Windows
npm run package:linux # Linux
```
### Additional Commands
```bash
npm run db:backup # Create a backup of your SQLite database
# Use when: Before major changes or deployments
# Creates: data/backups/cc_backup_[timestamp].db
npm run db:reset # Reset database to initial state
# Use when: Need fresh start or corrupted data
# Warning: This will delete all your data!
npm run clean # Remove all dependencies
# Use when: Dependencies conflict or need fresh install
# Removes: node_modules/, package-lock.json
# After running: npm install to reinstall everything
```
## Usage
### Getting Started with Development
1. **Connect Claude Code**: Link your Claude Code CLI to enable AI assistance
2. **Describe Your Project**: Use natural language to describe what you want to build
3. **AI Generation**: Watch as the AI generates your project structure and code
4. **Live Preview**: See changes instantly with hot reload functionality
5. **Deploy**: Push to production with Vercel integration
### Database Operations
Claudable uses SQLite for local development. The database automatically initializes on first run.
## Troubleshooting
### Port Already in Use
The application automatically finds available ports. Check the `.env` file to see which ports were assigned.
### Installation Failures
```bash
# Clean all dependencies and retry
npm run clean
npm install
```
### Claude Code Permission Issues (Windows/WSL)
If you encounter the error: `Error output dangerously skip permissions cannot be used which is root sudo privileges for security reasons`
**Solution:**
1. Do not run Claude Code with `sudo` or as root user
2. Ensure proper file ownership in WSL:
```bash
# Check current user
whoami
# Change ownership of project directory to current user
sudo chown -R $(whoami):$(whoami) ~/Claudable
```
3. If using WSL, make sure you're running Claude Code from your user account, not root
4. Verify Claude Code installation permissions:
```bash
# Reinstall Claude Code without sudo
npm install -g @anthropic-ai/claude-code --unsafe-perm=false
```
## Integration Guide
### GitHub
**Get Token:** [GitHub Personal Access Tokens](https://github.com/settings/tokens) → Generate new token (classic) → Select `repo` scope
**Connect:** Settings → Service Integrations → GitHub → Enter token → Create or connect repository
### Vercel
**Get Token:** [Vercel Account Settings](https://vercel.com/account/tokens) → Create Token
**Connect:** Settings → Service Integrations → Vercel → Enter token → Create new project for deployment
### Supabase
**Get Credentials:** [Supabase Dashboard](https://supabase.com/dashboard) → Your Project → Settings → API
- Project URL: `https://xxxxx.supabase.co`
- Anon Key: Public key for client-side
- Service Role Key: Secret key for server-side
## License
MIT License.
## Upcoming Features
These features are in development and will be opened soon.
- **Native MCP Support** - Model Context Protocol integration for enhanced agent capabilities
- **Checkpoints for Chat** - Save and restore conversation/codebase states
- **Enhanced Agent System** - Subagents, AGENTS.md integration
- **Website Cloning** - You can start a project from a reference URL.
- Various bug fixes and community PR merges
We're working hard to deliver the features you've been asking for. Stay tuned!
## Star History
[](https://www.star-history.com/#opactorai/Claudable&Date)
## /app/[project_id]/chat/page.tsx
```tsx path="/app/[project_id]/chat/page.tsx"
"use client";
import { useEffect, useState, useRef, useCallback, useMemo, type ChangeEvent, type KeyboardEvent, type UIEvent } from 'react';
import { AnimatePresence } from 'framer-motion';
import { MotionDiv, MotionH3, MotionP, MotionButton } from '@/lib/motion';
import { useRouter, useSearchParams, useParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { FaCode, FaDesktop, FaMobileAlt, FaPlay, FaStop, FaSync, FaCog, FaRocket, FaFolder, FaFolderOpen, FaFile, FaFileCode, FaCss3Alt, FaHtml5, FaJs, FaReact, FaPython, FaDocker, FaGitAlt, FaMarkdown, FaDatabase, FaPhp, FaJava, FaRust, FaVuejs, FaLock, FaHome, FaChevronUp, FaChevronRight, FaChevronDown, FaArrowLeft, FaArrowRight, FaRedo } from 'react-icons/fa';
import { SiTypescript, SiGo, SiRuby, SiSvelte, SiJson, SiYaml, SiCplusplus } from 'react-icons/si';
import { VscJson } from 'react-icons/vsc';
import ChatLog from '@/components/chat/ChatLog';
import { ProjectSettings } from '@/components/settings/ProjectSettings';
import ChatInput from '@/components/chat/ChatInput';
import { ChatErrorBoundary } from '@/components/ErrorBoundary';
import { useUserRequests } from '@/hooks/useUserRequests';
import { useGlobalSettings } from '@/contexts/GlobalSettingsContext';
import { getDefaultModelForCli, getModelDisplayName } from '@/lib/constants/cliModels';
import {
ACTIVE_CLI_BRAND_COLORS,
ACTIVE_CLI_IDS,
ACTIVE_CLI_MODEL_OPTIONS,
ACTIVE_CLI_NAME_MAP,
DEFAULT_ACTIVE_CLI,
buildActiveModelOptions,
normalizeModelForCli,
sanitizeActiveCli,
type ActiveCliId,
type ActiveModelOption,
} from '@/lib/utils/cliOptions';
// No longer loading ProjectSettings (managed by global settings on main page)
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
const assistantBrandColors = ACTIVE_CLI_BRAND_COLORS;
const CLI_LABELS = ACTIVE_CLI_NAME_MAP;
const CLI_ORDER = ACTIVE_CLI_IDS;
const sanitizeCli = (cli?: string | null) => sanitizeActiveCli(cli, DEFAULT_ACTIVE_CLI);
const sanitizeModel = (cli: string, model?: string | null) => normalizeModelForCli(cli, model, DEFAULT_ACTIVE_CLI);
// Function to convert hex to CSS filter for tinting white images
// Since the original image is white (#FFFFFF), we can apply filters more accurately
const hexToFilter = (hex: string): string => {
// For white source images, we need to invert and adjust
const filters: { [key: string]: string } = {
'#DE7356': 'brightness(0) saturate(100%) invert(52%) sepia(73%) saturate(562%) hue-rotate(336deg) brightness(95%) contrast(91%)',
'#000000': 'brightness(0) saturate(100%)',
'#11A97D': 'brightness(0) saturate(100%) invert(57%) sepia(30%) saturate(747%) hue-rotate(109deg) brightness(90%) contrast(92%)',
'#1677FF': 'brightness(0) saturate(100%) invert(40%) sepia(86%) saturate(1806%) hue-rotate(201deg) brightness(98%) contrast(98%)',
};
return filters[hex] || filters['#DE7356'];
};
type Entry = { path: string; type: 'file'|'dir'; size?: number };
type ProjectStatus = 'initializing' | 'active' | 'failed';
type CliStatusSnapshot = {
available?: boolean;
configured?: boolean;
models?: string[];
};
type ModelOption = Omit<ActiveModelOption, 'cli'> & { cli: string };
const buildModelOptions = (statuses: Record<string, CliStatusSnapshot>): ModelOption[] =>
buildActiveModelOptions(statuses).map(option => ({
...option,
cli: option.cli,
}));
// TreeView component for VSCode-style file explorer
interface TreeViewProps {
entries: Entry[];
selectedFile: string;
expandedFolders: Set<string>;
folderContents: Map<string, Entry[]>;
onToggleFolder: (path: string) => void;
onSelectFile: (path: string) => void;
onLoadFolder: (path: string) => Promise<void>;
level: number;
parentPath?: string;
getFileIcon: (entry: Entry) => React.ReactElement;
}
function TreeView({ entries, selectedFile, expandedFolders, folderContents, onToggleFolder, onSelectFile, onLoadFolder, level, parentPath = '', getFileIcon }: TreeViewProps) {
// Ensure entries is an array
if (!entries || !Array.isArray(entries)) {
return null;
}
// Group entries by directory
const sortedEntries = [...entries].sort((a, b) => {
// Directories first
if (a.type === 'dir' && b.type === 'file') return -1;
if (a.type === 'file' && b.type === 'dir') return 1;
// Then alphabetical
return a.path.localeCompare(b.path);
});
return (
<>
{sortedEntries.map((entry, index) => {
// entry.path should already be the full path from API
const fullPath = entry.path;
let entryKey =
fullPath && typeof fullPath === 'string' && fullPath.trim().length > 0
? fullPath.trim()
: (entry as any)?.name && typeof (entry as any).name === 'string' && (entry as any).name.trim().length > 0
? `${parentPath || 'root'}::__named_${(entry as any).name.trim()}`
: '';
if (!entryKey || entryKey.trim().length === 0) {
entryKey = `${parentPath || 'root'}::__entry_${level}_${index}_${entry.type}`;
}
const isExpanded = expandedFolders.has(fullPath);
const indent = level * 8;
return (
<div key={entryKey}>
<div
className={`group flex items-center h-[22px] px-2 cursor-pointer ${
selectedFile === fullPath
? 'bg-blue-100 '
: 'hover:bg-gray-100 '
}`}
style={{ paddingLeft: `${8 + indent}px` }}
onClick={async () => {
if (entry.type === 'dir') {
// Load folder contents if not already loaded
if (!folderContents.has(fullPath)) {
await onLoadFolder(fullPath);
}
onToggleFolder(fullPath);
} else {
onSelectFile(fullPath);
}
}}
>
{/* Chevron for folders */}
<div className="w-4 flex items-center justify-center mr-0.5">
{entry.type === 'dir' && (
isExpanded ?
<span className="w-2.5 h-2.5 text-gray-600 flex items-center justify-center"><FaChevronDown size={10} /></span> :
<span className="w-2.5 h-2.5 text-gray-600 flex items-center justify-center"><FaChevronRight size={10} /></span>
)}
</div>
{/* Icon */}
<span className="w-4 h-4 flex items-center justify-center mr-1.5">
{entry.type === 'dir' ? (
isExpanded ?
<span className="text-amber-600 w-4 h-4 flex items-center justify-center"><FaFolderOpen size={16} /></span> :
<span className="text-amber-600 w-4 h-4 flex items-center justify-center"><FaFolder size={16} /></span>
) : (
getFileIcon(entry)
)}
</span>
{/* File/Folder name */}
<span className={`text-[13px] leading-[22px] ${
selectedFile === fullPath ? 'text-blue-700 ' : 'text-gray-700 '
}`} style={{ fontFamily: "'Segoe UI', Tahoma, sans-serif" }}>
{level === 0 ? (entry.path.split('/').pop() || entry.path) : (entry.path.split('/').pop() || entry.path)}
</span>
</div>
{/* Render children if expanded */}
{entry.type === 'dir' && isExpanded && folderContents.has(fullPath) && (
<TreeView
entries={folderContents.get(fullPath) || []}
selectedFile={selectedFile}
expandedFolders={expandedFolders}
folderContents={folderContents}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
onLoadFolder={onLoadFolder}
level={level + 1}
parentPath={fullPath}
getFileIcon={getFileIcon}
/>
)}
</div>
);
})}
</>
);
}
export default function ChatPage() {
const params = useParams<{ project_id: string }>();
const projectId = params?.project_id ?? '';
const router = useRouter();
const searchParams = useSearchParams();
// NEW: UserRequests state management
const {
hasActiveRequests,
createRequest,
startRequest,
completeRequest
} = useUserRequests({ projectId });
const [projectName, setProjectName] = useState<string>('');
const [projectDescription, setProjectDescription] = useState<string>('');
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [tree, setTree] = useState<Entry[]>([]);
const [content, setContent] = useState<string>('');
const [editedContent, setEditedContent] = useState<string>('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isSavingFile, setIsSavingFile] = useState(false);
const [saveFeedback, setSaveFeedback] = useState<'idle' | 'success' | 'error'>('idle');
const [saveError, setSaveError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<string>('');
const [currentPath, setCurrentPath] = useState<string>('.');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['']));
const [folderContents, setFolderContents] = useState<Map<string, Entry[]>>(new Map());
const [prompt, setPrompt] = useState('');
// Ref to store add/remove message handlers from ChatLog
const messageHandlersRef = useRef<{
add: (message: any) => void;
remove: (messageId: string) => void;
} | null>(null);
// Ref to track pending requests for deduplication
const pendingRequestsRef = useRef<Set<string>>(new Set());
// Stable message handlers to prevent reassignment issues
const stableMessageHandlers = useRef<{
add: (message: any) => void;
remove: (messageId: string) => void;
} | null>(null);
// Track active optimistic messages by requestId
const optimisticMessagesRef = useRef<Map<string, any>>(new Map());
const [mode, setMode] = useState<'act' | 'chat'>('act');
const [isRunning, setIsRunning] = useState(false);
const [isSseFallbackActive, setIsSseFallbackActive] = useState(false);
const [showPreview, setShowPreview] = useState(true);
const [deviceMode, setDeviceMode] = useState<'desktop'|'mobile'>('desktop');
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
const [uploadedImages, setUploadedImages] = useState<{name: string; url: string; base64?: string; path?: string}[]>([]);
const [isInitializing, setIsInitializing] = useState(true);
// Initialize states with default values, will be loaded from localStorage in useEffect
const [hasInitialPrompt, setHasInitialPrompt] = useState<boolean>(false);
const [agentWorkComplete, setAgentWorkComplete] = useState<boolean>(false);
const [projectStatus, setProjectStatus] = useState<ProjectStatus>('initializing');
const [initializationMessage, setInitializationMessage] = useState('Starting project initialization...');
const [initialPromptSent, setInitialPromptSent] = useState(false);
const initialPromptSentRef = useRef(false);
const [showPublishPanel, setShowPublishPanel] = useState(false);
const [publishLoading, setPublishLoading] = useState(false);
const [githubConnected, setGithubConnected] = useState<boolean | null>(null);
const [vercelConnected, setVercelConnected] = useState<boolean | null>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const [deploymentId, setDeploymentId] = useState<string | null>(null);
const [deploymentStatus, setDeploymentStatus] = useState<'idle' | 'deploying' | 'ready' | 'error'>('idle');
const deployPollRef = useRef<NodeJS.Timeout | null>(null);
const [isStartingPreview, setIsStartingPreview] = useState(false);
const [previewInitializationMessage, setPreviewInitializationMessage] = useState('Starting development server...');
const [cliStatuses, setCliStatuses] = useState<Record<string, CliStatusSnapshot>>({});
const [conversationId, setConversationId] = useState<string>(() => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return '';
});
const [preferredCli, setPreferredCli] = useState<ActiveCliId>(DEFAULT_ACTIVE_CLI);
const [selectedModel, setSelectedModel] = useState<string>(getDefaultModelForCli(DEFAULT_ACTIVE_CLI));
const [usingGlobalDefaults, setUsingGlobalDefaults] = useState<boolean>(true);
const [thinkingMode, setThinkingMode] = useState<boolean>(false);
const [isUpdatingModel, setIsUpdatingModel] = useState<boolean>(false);
const [currentRoute, setCurrentRoute] = useState<string>('/');
const iframeRef = useRef<HTMLIFrameElement>(null);
const editorRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLPreElement>(null);
const lineNumberRef = useRef<HTMLDivElement>(null);
const editedContentRef = useRef<string>('');
const [isFileUpdating, setIsFileUpdating] = useState(false);
const activeBrandColor =
assistantBrandColors[preferredCli] || assistantBrandColors[DEFAULT_ACTIVE_CLI];
const modelOptions = useMemo(() => buildModelOptions(cliStatuses), [cliStatuses]);
const cliOptions = useMemo(
() => CLI_ORDER.map(cli => ({
id: cli,
name: CLI_LABELS[cli] || cli,
available: Boolean(cliStatuses[cli]?.available && cliStatuses[cli]?.configured)
})),
[cliStatuses]
);
const updatePreferredCli = useCallback((cli: string) => {
const sanitized = sanitizeCli(cli);
setPreferredCli(sanitized);
if (typeof window !== 'undefined') {
sessionStorage.setItem('selectedAssistant', sanitized);
}
}, []);
const updateSelectedModel = useCallback((model: string, cliOverride?: string) => {
const effectiveCli = cliOverride ? sanitizeCli(cliOverride) : preferredCli;
const sanitized = sanitizeModel(effectiveCli, model);
setSelectedModel(sanitized);
if (typeof window !== 'undefined') {
sessionStorage.setItem('selectedModel', sanitized);
}
}, [preferredCli]);
useEffect(() => {
previewUrlRef.current = previewUrl;
}, [previewUrl]);
const sendInitialPrompt = useCallback(async (initialPrompt: string) => {
if (initialPromptSent) {
return;
}
setAgentWorkComplete(false);
localStorage.setItem(`project_${projectId}_taskComplete`, 'false');
const requestId = crypto.randomUUID();
try {
setIsRunning(true);
setInitialPromptSent(true);
const requestBody = {
instruction: initialPrompt,
images: [],
isInitialPrompt: true,
cliPreference: preferredCli,
conversationId: conversationId || undefined,
requestId,
selectedModel,
};
const r = await fetch(`${API_BASE}/api/chat/${projectId}/act`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!r.ok) {
const errorText = await r.text();
console.error('❌ API Error:', errorText);
setInitialPromptSent(false);
return;
}
const result = await r.json();
const returnedConversationId =
typeof result?.conversationId === 'string'
? result.conversationId
: typeof result?.conversation_id === 'string'
? result.conversation_id
: undefined;
if (returnedConversationId) {
setConversationId(returnedConversationId);
}
const resolvedRequestId =
typeof result?.requestId === 'string'
? result.requestId
: typeof result?.request_id === 'string'
? result.request_id
: requestId;
const userMessageId =
typeof result?.userMessageId === 'string'
? result.userMessageId
: typeof result?.user_message_id === 'string'
? result.user_message_id
: '';
createRequest(resolvedRequestId, userMessageId, initialPrompt, 'act');
setPrompt('');
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('initial_prompt');
window.history.replaceState({}, '', newUrl.toString());
} catch (error) {
console.error('Error sending initial prompt:', error);
setInitialPromptSent(false);
} finally {
setIsRunning(false);
}
}, [initialPromptSent, preferredCli, conversationId, projectId, selectedModel, createRequest]);
// Guarded trigger that can be called from multiple places safely
const triggerInitialPromptIfNeeded = useCallback(() => {
const initialPromptFromUrl = searchParams?.get('initial_prompt');
if (!initialPromptFromUrl) return;
if (initialPromptSentRef.current) return;
// Synchronously guard to prevent double ACT calls
initialPromptSentRef.current = true;
setInitialPromptSent(true);
// Store the selected model and assistant in sessionStorage when returning
const cliFromUrl = searchParams?.get('cli');
const modelFromUrl = searchParams?.get('model');
if (cliFromUrl) {
const sanitizedCli = sanitizeCli(cliFromUrl);
sessionStorage.setItem('selectedAssistant', sanitizedCli);
if (modelFromUrl) {
sessionStorage.setItem('selectedModel', sanitizeModel(sanitizedCli, modelFromUrl));
}
} else if (modelFromUrl) {
sessionStorage.setItem('selectedModel', sanitizeModel(preferredCli, modelFromUrl));
}
// Don't show the initial prompt in the input field
// setPrompt(initialPromptFromUrl);
setTimeout(() => {
sendInitialPrompt(initialPromptFromUrl);
}, 300);
}, [searchParams, sendInitialPrompt, preferredCli]);
const loadCliStatuses = useCallback(() => {
const snapshot: Record<string, CliStatusSnapshot> = {};
ACTIVE_CLI_IDS.forEach(id => {
const models = ACTIVE_CLI_MODEL_OPTIONS[id]?.map(model => model.id) ?? [];
snapshot[id] = {
available: true,
configured: true,
models,
};
});
setCliStatuses(snapshot);
}, []);
const persistProjectPreferences = useCallback(
async (changes: { preferredCli?: string; selectedModel?: string }) => {
if (!projectId) return;
const payload: Record<string, unknown> = {};
if (changes.preferredCli) {
const sanitizedPreferredCli = sanitizeCli(changes.preferredCli);
payload.preferredCli = sanitizedPreferredCli;
payload.preferred_cli = sanitizedPreferredCli;
}
if (changes.selectedModel) {
const targetCli = sanitizeCli(changes.preferredCli ?? preferredCli);
const normalized = sanitizeModel(targetCli, changes.selectedModel);
payload.selectedModel = normalized;
payload.selected_model = normalized;
}
if (Object.keys(payload).length === 0) return;
const response = await fetch(`${API_BASE}/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to update project preferences');
}
const result = await response.json().catch(() => null);
return result?.data ?? result;
},
[projectId, preferredCli]
);
const handleModelChange = useCallback(
async (option: ModelOption, opts?: { skipCliUpdate?: boolean; overrideCli?: string }) => {
if (!projectId || !option) return;
const { skipCliUpdate = false, overrideCli } = opts || {};
const targetCli = sanitizeCli(overrideCli ?? option.cli);
const sanitizedModelId = sanitizeModel(targetCli, option.id);
const previousCli = preferredCli;
const previousModel = selectedModel;
if (targetCli === previousCli && sanitizedModelId === previousModel) {
return;
}
setUsingGlobalDefaults(false);
updatePreferredCli(targetCli);
updateSelectedModel(option.id, targetCli);
setIsUpdatingModel(true);
try {
const preferenceChanges: { preferredCli?: string; selectedModel?: string } = {
selectedModel: sanitizedModelId,
};
if (!skipCliUpdate && targetCli !== previousCli) {
preferenceChanges.preferredCli = targetCli;
}
await persistProjectPreferences(preferenceChanges);
const cliLabel = CLI_LABELS[targetCli] || targetCli;
const modelLabel = getModelDisplayName(targetCli, sanitizedModelId);
try {
await fetch(`${API_BASE}/api/chat/${projectId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: `Switched to ${cliLabel} (${modelLabel})`,
role: 'system',
message_type: 'info',
cli_source: targetCli,
conversation_id: conversationId || undefined,
}),
});
} catch (messageError) {
console.warn('Failed to record model switch message:', messageError);
}
loadCliStatuses();
} catch (error) {
console.error('Failed to update model preference:', error);
updatePreferredCli(previousCli);
updateSelectedModel(previousModel, previousCli);
alert('Failed to update model. Please try again.');
} finally {
setIsUpdatingModel(false);
}
},
[projectId, preferredCli, selectedModel, conversationId, loadCliStatuses, persistProjectPreferences, updatePreferredCli, updateSelectedModel]
);
useEffect(() => {
loadCliStatuses();
}, [loadCliStatuses]);
const handleCliChange = useCallback(
async (cliId: string) => {
if (!projectId) return;
if (cliId === preferredCli) return;
setUsingGlobalDefaults(false);
const candidateModels = modelOptions.filter(option => option.cli === cliId);
const fallbackOption =
candidateModels.find(option => option.id === selectedModel && option.available) ||
candidateModels.find(option => option.available) ||
candidateModels[0];
if (fallbackOption) {
await handleModelChange(fallbackOption, { overrideCli: cliId });
return;
}
const previousCli = preferredCli;
const previousModel = selectedModel;
setIsUpdatingModel(true);
try {
updatePreferredCli(cliId);
const defaultModel = getDefaultModelForCli(cliId);
updateSelectedModel(defaultModel, cliId);
await persistProjectPreferences({ preferredCli: cliId, selectedModel: defaultModel });
loadCliStatuses();
} catch (error) {
console.error('Failed to update CLI preference:', error);
updatePreferredCli(previousCli);
updateSelectedModel(previousModel, previousCli);
alert('Failed to update CLI. Please try again.');
} finally {
setIsUpdatingModel(false);
}
},
[projectId, preferredCli, selectedModel, modelOptions, handleModelChange, loadCliStatuses, persistProjectPreferences, updatePreferredCli, updateSelectedModel]
);
useEffect(() => {
if (!modelOptions.length) return;
const hasSelected = modelOptions.some(option => option.cli === preferredCli && option.id === selectedModel);
if (!hasSelected) {
const fallbackOption = modelOptions.find(option => option.cli === preferredCli && option.available)
|| modelOptions.find(option => option.cli === preferredCli)
|| modelOptions.find(option => option.available)
|| modelOptions[0];
if (fallbackOption) {
void handleModelChange(fallbackOption);
}
}
}, [modelOptions, preferredCli, selectedModel, handleModelChange]);
const loadDeployStatus = useCallback(async () => {
try {
// Use the same API as ServiceSettings to check actual project service connections
const response = await fetch(`${API_BASE}/api/projects/${projectId}/services`);
if (response.status === 404) {
setGithubConnected(false);
setVercelConnected(false);
setPublishedUrl(null);
setDeploymentStatus('idle');
return;
}
if (response.ok) {
const connections = await response.json();
const githubConnection = connections.find((conn: any) => conn.provider === 'github');
const vercelConnection = connections.find((conn: any) => conn.provider === 'vercel');
// Check actual project connections (not just token existence)
setGithubConnected(!!githubConnection);
setVercelConnected(!!vercelConnection);
// Set published URL only if actually deployed
if (vercelConnection && vercelConnection.service_data) {
const sd = vercelConnection.service_data;
// Only use actual deployment URLs, not predicted ones
const rawUrl = sd.last_deployment_url || null;
const url = rawUrl ? (String(rawUrl).startsWith('http') ? String(rawUrl) : `https://${rawUrl}`) : null;
setPublishedUrl(url || null);
if (url) {
setDeploymentStatus('ready');
} else {
setDeploymentStatus('idle');
}
} else {
setPublishedUrl(null);
setDeploymentStatus('idle');
}
} else {
setGithubConnected(false);
setVercelConnected(false);
setPublishedUrl(null);
setDeploymentStatus('idle');
}
} catch (e) {
console.warn('Failed to load deploy status', e);
setGithubConnected(false);
setVercelConnected(false);
setPublishedUrl(null);
setDeploymentStatus('idle');
}
}, [projectId]);
const startDeploymentPolling = useCallback((depId: string) => {
if (deployPollRef.current) clearInterval(deployPollRef.current);
setDeploymentStatus('deploying');
setDeploymentId(depId);
console.log('🔍 Monitoring deployment:', depId);
deployPollRef.current = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/api/projects/${projectId}/vercel/deployment/current`);
if (r.status === 404) {
setDeploymentStatus('idle');
setDeploymentId(null);
setPublishLoading(false);
if (deployPollRef.current) {
clearInterval(deployPollRef.current);
deployPollRef.current = null;
}
return;
}
if (!r.ok) return;
const data = await r.json();
// Stop polling if no active deployment (completed)
if (!data.has_deployment) {
console.log('🔍 Deployment completed - no active deployment');
// Set final deployment URL
if (data.last_deployment_url) {
const url = String(data.last_deployment_url).startsWith('http') ? data.last_deployment_url : `https://${data.last_deployment_url}`;
console.log('🔍 Deployment complete! URL:', url);
setPublishedUrl(url);
setDeploymentStatus('ready');
} else {
setDeploymentStatus('idle');
}
// End publish loading state (important: release loading even if no deployment)
setPublishLoading(false);
if (deployPollRef.current) {
clearInterval(deployPollRef.current);
deployPollRef.current = null;
}
return;
}
// If there is an active deployment
const status = data.status;
// Log only status changes
if (status && status !== 'QUEUED') {
console.log('🔍 Deployment status:', status);
}
// Check if deployment is ready or failed
const isReady = status === 'READY';
const isBuilding = status === 'BUILDING' || status === 'QUEUED';
const isError = status === 'ERROR';
if (isError) {
console.error('🔍 Deployment failed:', status);
setDeploymentStatus('error');
// End publish loading state
setPublishLoading(false);
// Close publish panel after error (with delay to show error message)
setTimeout(() => {
setShowPublishPanel(false);
}, 3000); // Show error for 3 seconds before closing
if (deployPollRef.current) {
clearInterval(deployPollRef.current);
deployPollRef.current = null;
}
return;
}
if (isReady && data.deployment_url) {
const url = String(data.deployment_url).startsWith('http') ? data.deployment_url : `https://${data.deployment_url}`;
console.log('🔍 Deployment complete! URL:', url);
setPublishedUrl(url);
setDeploymentStatus('ready');
// End publish loading state
setPublishLoading(false);
// Keep panel open to show the published URL
if (deployPollRef.current) {
clearInterval(deployPollRef.current);
deployPollRef.current = null;
}
} else if (isBuilding) {
setDeploymentStatus('deploying');
}
} catch (error) {
console.error('🔍 Polling error:', error);
}
}, 1000); // Changed to 1 second interval
}, [projectId]);
const checkCurrentDeployment = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/api/projects/${projectId}/vercel/deployment/current`);
if (response.status === 404) {
return;
}
if (response.ok) {
const data = await response.json();
if (data.has_deployment) {
setDeploymentId(data.deployment_id);
setDeploymentStatus('deploying');
setPublishLoading(false);
setShowPublishPanel(true);
startDeploymentPolling(data.deployment_id);
console.log('🔍 Resuming deployment monitoring:', data.deployment_id);
}
}
} catch (e) {
console.warn('Failed to check current deployment', e);
}
}, [projectId, startDeploymentPolling]);
const start = useCallback(async () => {
try {
setIsStartingPreview(true);
setPreviewInitializationMessage('Starting development server...');
// Simulate progress updates
setTimeout(() => setPreviewInitializationMessage('Installing dependencies...'), 1000);
setTimeout(() => setPreviewInitializationMessage('Building your application...'), 2500);
const r = await fetch(`${API_BASE}/api/projects/${projectId}/preview/start`, { method: 'POST' });
if (!r.ok) {
console.error('Failed to start preview:', r.statusText);
setPreviewInitializationMessage('Failed to start preview');
setTimeout(() => setIsStartingPreview(false), 2000);
return;
}
const payload = await r.json();
const data = payload?.data ?? payload ?? {};
setPreviewInitializationMessage('Preview ready!');
setTimeout(() => {
setPreviewUrl(typeof data.url === 'string' ? data.url : null);
setIsStartingPreview(false);
setCurrentRoute('/'); // Reset to root route when starting
}, 1000);
} catch (error) {
console.error('Error starting preview:', error);
setPreviewInitializationMessage('An error occurred');
setTimeout(() => setIsStartingPreview(false), 2000);
}
}, [projectId]);
// Navigate to specific route in iframe
const navigateToRoute = (route: string) => {
if (previewUrl && iframeRef.current) {
const baseUrl = previewUrl.split('?')[0]; // Remove any query params
// Ensure route starts with /
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
const newUrl = `${baseUrl}${normalizedRoute}`;
iframeRef.current.src = newUrl;
setCurrentRoute(normalizedRoute);
}
};
const refreshPreview = useCallback(() => {
if (!previewUrl || !iframeRef.current) {
return;
}
try {
const normalizedRoute =
currentRoute && currentRoute.startsWith('/')
? currentRoute
: `/${currentRoute || ''}`;
const baseUrl = previewUrl.split('?')[0] || previewUrl;
const url = new URL(baseUrl + normalizedRoute);
url.searchParams.set('_ts', Date.now().toString());
iframeRef.current.src = url.toString();
} catch (error) {
console.warn('Failed to refresh preview iframe:', error);
}
}, [previewUrl, currentRoute]);
const stop = useCallback(async () => {
try {
await fetch(`${API_BASE}/api/projects/${projectId}/preview/stop`, { method: 'POST' });
setPreviewUrl(null);
} catch (error) {
console.error('Error stopping preview:', error);
}
}, [projectId]);
const loadSubdirectory = useCallback(async (dir: string): Promise<Entry[]> => {
try {
const r = await fetch(`${API_BASE}/api/repo/${projectId}/tree?dir=${encodeURIComponent(dir)}`);
const data = await r.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error('Failed to load subdirectory:', error);
return [];
}
}, [projectId]);
const loadTree = useCallback(async (dir = '.') => {
try {
const r = await fetch(`${API_BASE}/api/repo/${projectId}/tree?dir=${encodeURIComponent(dir)}`);
const data = await r.json();
// Ensure data is an array
if (Array.isArray(data)) {
setTree(data);
// Load contents for all directories in the root
const newFolderContents = new Map();
// Process each directory
for (const entry of data) {
if (entry.type === 'dir') {
try {
const subContents = await loadSubdirectory(entry.path);
newFolderContents.set(entry.path, subContents);
} catch (err) {
console.error(`Failed to load contents for ${entry.path}:`, err);
}
}
}
setFolderContents(newFolderContents);
} else {
console.error('Tree data is not an array:', data);
setTree([]);
}
setCurrentPath(dir);
} catch (error) {
console.error('Failed to load tree:', error);
setTree([]);
}
}, [projectId, loadSubdirectory]);
// Load subdirectory contents
// Load folder contents
const handleLoadFolder = useCallback(async (path: string) => {
const contents = await loadSubdirectory(path);
setFolderContents(prev => {
const newMap = new Map(prev);
newMap.set(path, contents);
// Also load nested directories
for (const entry of contents) {
if (entry.type === 'dir') {
const fullPath = `${path}/${entry.path}`;
// Don't load if already loaded
if (!newMap.has(fullPath)) {
loadSubdirectory(fullPath).then(subContents => {
setFolderContents(prev2 => new Map(prev2).set(fullPath, subContents));
});
}
}
}
return newMap;
});
}, [loadSubdirectory]);
// Toggle folder expansion
function toggleFolder(path: string) {
setExpandedFolders(prev => {
const newSet = new Set(prev);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return newSet;
});
}
// Build tree structure from flat list
function buildTreeStructure(entries: Entry[]): Map<string, Entry[]> {
const structure = new Map<string, Entry[]>();
// Initialize with root
structure.set('', []);
entries.forEach(entry => {
const parts = entry.path.split('/');
const parentPath = parts.slice(0, -1).join('/');
if (!structure.has(parentPath)) {
structure.set(parentPath, []);
}
structure.get(parentPath)?.push(entry);
// If it's a directory, ensure it exists in the structure
if (entry.type === 'dir') {
if (!structure.has(entry.path)) {
structure.set(entry.path, []);
}
}
});
return structure;
}
const openFile = useCallback(async (path: string) => {
try {
if (hasUnsavedChanges && path !== selectedFile) {
const shouldDiscard =
typeof window !== 'undefined'
? window.confirm('You have unsaved changes. Discard them and open the new file?')
: true;
if (!shouldDiscard) {
return;
}
}
setSaveFeedback('idle');
setSaveError(null);
const r = await fetch(`${API_BASE}/api/repo/${projectId}/file?path=${encodeURIComponent(path)}`);
if (!r.ok) {
console.error('Failed to load file:', r.status, r.statusText);
const fallback = '// Failed to load file content';
setContent(fallback);
setEditedContent(fallback);
editedContentRef.current = fallback;
setHasUnsavedChanges(false);
setSelectedFile(path);
return;
}
const data = await r.json();
const fileContent = typeof data?.content === 'string' ? data.content : '';
setContent(fileContent);
setEditedContent(fileContent);
editedContentRef.current = fileContent;
setHasUnsavedChanges(false);
setSelectedFile(path);
setIsFileUpdating(false);
requestAnimationFrame(() => {
if (editorRef.current) {
editorRef.current.scrollTop = 0;
editorRef.current.scrollLeft = 0;
}
if (highlightRef.current) {
highlightRef.current.scrollTop = 0;
highlightRef.current.scrollLeft = 0;
}
if (lineNumberRef.current) {
lineNumberRef.current.scrollTop = 0;
}
});
} catch (error) {
console.error('Error opening file:', error);
const fallback = '// Error loading file';
setContent(fallback);
setEditedContent(fallback);
editedContentRef.current = fallback;
setHasUnsavedChanges(false);
setSelectedFile(path);
}
}, [projectId, hasUnsavedChanges, selectedFile]);
// Reload currently selected file
const reloadCurrentFile = useCallback(async () => {
if (selectedFile && !showPreview && !hasUnsavedChanges) {
try {
const r = await fetch(`${API_BASE}/api/repo/${projectId}/file?path=${encodeURIComponent(selectedFile)}`);
if (r.ok) {
const data = await r.json();
const newContent = data.content || '';
if (newContent !== content) {
setIsFileUpdating(true);
setContent(newContent);
setEditedContent(newContent);
editedContentRef.current = newContent;
setHasUnsavedChanges(false);
setSaveFeedback('idle');
setSaveError(null);
setTimeout(() => setIsFileUpdating(false), 500);
}
}
} catch (error) {
// Silently fail - this is a background refresh
}
}
}, [projectId, selectedFile, showPreview, hasUnsavedChanges, content]);
// Lazy load highlight.js only when needed
const [hljs, setHljs] = useState<any>(null);
useEffect(() => {
if (selectedFile && !hljs) {
import('highlight.js/lib/common').then(mod => {
setHljs(mod.default);
// Load highlight.js CSS dynamically
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css';
document.head.appendChild(link);
});
}
}, [selectedFile, hljs]);
const highlightedCode = useMemo(() => {
const code = editedContent ?? '';
if (!code) {
return ' ';
}
if (!hljs) {
return escapeHtml(code);
}
const language = getFileLanguage(selectedFile);
try {
if (!language || language === 'plaintext') {
return escapeHtml(code);
}
return hljs.highlight(code, { language }).value;
} catch {
try {
return hljs.highlightAuto(code).value;
} catch {
return escapeHtml(code);
}
}
}, [hljs, editedContent, selectedFile]);
const onEditorChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
setEditedContent(value);
editedContentRef.current = value;
setHasUnsavedChanges(value !== content);
setSaveFeedback('idle');
setSaveError(null);
if (isFileUpdating) {
setIsFileUpdating(false);
}
}, [content, isFileUpdating]);
const handleEditorScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
const { scrollTop, scrollLeft } = event.currentTarget;
if (highlightRef.current) {
highlightRef.current.scrollTop = scrollTop;
highlightRef.current.scrollLeft = scrollLeft;
}
if (lineNumberRef.current) {
lineNumberRef.current.scrollTop = scrollTop;
}
}, []);
const handleSaveFile = useCallback(async () => {
if (!selectedFile || isSavingFile || !hasUnsavedChanges) {
return;
}
const contentToSave = editedContentRef.current;
setIsSavingFile(true);
setSaveFeedback('idle');
setSaveError(null);
try {
const response = await fetch(`${API_BASE}/api/repo/${projectId}/file`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: selectedFile, content: contentToSave }),
});
if (!response.ok) {
let errorMessage = 'Failed to save file';
try {
const data = await response.clone().json();
errorMessage = data?.error || data?.message || errorMessage;
} catch {
const text = await response.text().catch(() => '');
if (text) {
errorMessage = text;
}
}
throw new Error(errorMessage);
}
setContent(contentToSave);
setSaveFeedback('success');
if (editedContentRef.current === contentToSave) {
setHasUnsavedChanges(false);
setIsFileUpdating(true);
setTimeout(() => setIsFileUpdating(false), 800);
}
refreshPreview();
} catch (error) {
console.error('Failed to save file:', error);
setSaveFeedback('error');
setSaveError(error instanceof Error ? error.message : 'Failed to save file');
} finally {
setIsSavingFile(false);
}
}, [selectedFile, isSavingFile, hasUnsavedChanges, projectId, refreshPreview]);
const handleEditorKeyDown = useCallback((event: KeyboardEvent<HTMLTextAreaElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
handleSaveFile();
return;
}
if (event.key === 'Tab') {
event.preventDefault();
const el = event.currentTarget;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const indent = ' ';
const value = editedContent;
const newValue = value.slice(0, start) + indent + value.slice(end);
setEditedContent(newValue);
editedContentRef.current = newValue;
setHasUnsavedChanges(newValue !== content);
setSaveFeedback('idle');
setSaveError(null);
if (isFileUpdating) {
setIsFileUpdating(false);
}
requestAnimationFrame(() => {
const position = start + indent.length;
el.selectionStart = position;
el.selectionEnd = position;
if (highlightRef.current) {
highlightRef.current.scrollTop = el.scrollTop;
highlightRef.current.scrollLeft = el.scrollLeft;
}
if (lineNumberRef.current) {
lineNumberRef.current.scrollTop = el.scrollTop;
}
});
}
}, [handleSaveFile, editedContent, content, isFileUpdating]);
useEffect(() => {
if (saveFeedback === 'success') {
const timer = setTimeout(() => setSaveFeedback('idle'), 1800);
return () => clearTimeout(timer);
}
return undefined;
}, [saveFeedback]);
useEffect(() => {
if (editorRef.current && highlightRef.current && lineNumberRef.current) {
const { scrollTop, scrollLeft } = editorRef.current;
highlightRef.current.scrollTop = scrollTop;
highlightRef.current.scrollLeft = scrollLeft;
lineNumberRef.current.scrollTop = scrollTop;
}
}, [editedContent]);
// Get file extension for syntax highlighting
function getFileLanguage(path: string): string {
const ext = path.split('.').pop()?.toLowerCase();
switch (ext) {
case 'tsx':
case 'ts':
return 'typescript';
case 'jsx':
case 'js':
case 'mjs':
return 'javascript';
case 'css':
return 'css';
case 'scss':
case 'sass':
return 'scss';
case 'html':
case 'htm':
return 'html';
case 'json':
return 'json';
case 'md':
case 'markdown':
return 'markdown';
case 'py':
return 'python';
case 'sh':
case 'bash':
return 'bash';
case 'yaml':
case 'yml':
return 'yaml';
case 'xml':
return 'xml';
case 'sql':
return 'sql';
case 'php':
return 'php';
case 'java':
return 'java';
case 'c':
return 'c';
case 'cpp':
case 'cc':
case 'cxx':
return 'cpp';
case 'rs':
return 'rust';
case 'go':
return 'go';
case 'rb':
return 'ruby';
case 'vue':
return 'vue';
case 'svelte':
return 'svelte';
case 'dockerfile':
return 'dockerfile';
case 'toml':
return 'toml';
case 'ini':
return 'ini';
case 'conf':
case 'config':
return 'nginx';
default:
return 'plaintext';
}
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Get file icon based on type
function getFileIcon(entry: Entry): React.ReactElement {
if (entry.type === 'dir') {
return <span className="text-blue-500"><FaFolder size={16} /></span>;
}
const ext = entry.path.split('.').pop()?.toLowerCase();
const filename = entry.path.split('/').pop()?.toLowerCase();
// Special files
if (filename === 'package.json') return <span className="text-green-600"><VscJson size={16} /></span>;
if (filename === 'dockerfile') return <span className="text-blue-400"><FaDocker size={16} /></span>;
if (filename?.startsWith('.env')) return <span className="text-yellow-500"><FaLock size={16} /></span>;
if (filename === 'readme.md') return <span className="text-gray-600"><FaMarkdown size={16} /></span>;
if (filename?.includes('config')) return <span className="text-gray-500"><FaCog size={16} /></span>;
switch (ext) {
case 'tsx':
return <span className="text-cyan-400"><FaReact size={16} /></span>;
case 'ts':
return <span className="text-blue-600"><SiTypescript size={16} /></span>;
case 'jsx':
return <span className="text-cyan-400"><FaReact size={16} /></span>;
case 'js':
case 'mjs':
return <span className="text-yellow-400"><FaJs size={16} /></span>;
case 'css':
return <span className="text-blue-500"><FaCss3Alt size={16} /></span>;
case 'scss':
case 'sass':
return <span className="text-pink-500"><FaCss3Alt size={16} /></span>;
case 'html':
case 'htm':
return <span className="text-orange-500"><FaHtml5 size={16} /></span>;
case 'json':
return <span className="text-yellow-600"><VscJson size={16} /></span>;
case 'md':
case 'markdown':
return <span className="text-gray-600"><FaMarkdown size={16} /></span>;
case 'py':
return <span className="text-blue-400"><FaPython size={16} /></span>;
case 'sh':
case 'bash':
return <span className="text-green-500"><FaFileCode size={16} /></span>;
case 'yaml':
case 'yml':
return <span className="text-red-500"><SiYaml size={16} /></span>;
case 'xml':
return <span className="text-orange-600"><FaFileCode size={16} /></span>;
case 'sql':
return <span className="text-blue-600"><FaDatabase size={16} /></span>;
case 'php':
return <span className="text-indigo-500"><FaPhp size={16} /></span>;
case 'java':
return <span className="text-red-600"><FaJava size={16} /></span>;
case 'c':
return <span className="text-blue-700"><FaFileCode size={16} /></span>;
case 'cpp':
case 'cc':
case 'cxx':
return <span className="text-blue-600"><SiCplusplus size={16} /></span>;
case 'rs':
return <span className="text-orange-700"><FaRust size={16} /></span>;
case 'go':
return <span className="text-cyan-500"><SiGo size={16} /></span>;
case 'rb':
return <span className="text-red-500"><SiRuby size={16} /></span>;
case 'vue':
return <span className="text-green-500"><FaVuejs size={16} /></span>;
case 'svelte':
return <span className="text-orange-600"><SiSvelte size={16} /></span>;
case 'dockerfile':
return <span className="text-blue-400"><FaDocker size={16} /></span>;
case 'toml':
case 'ini':
case 'conf':
case 'config':
return <span className="text-gray-500"><FaCog size={16} /></span>;
default:
return <span className="text-gray-400"><FaFile size={16} /></span>;
}
}
// Ensure we only trigger dependency installation once per page lifecycle
const installTriggeredRef = useRef(false);
const startDependencyInstallation = useCallback(async () => {
if (installTriggeredRef.current) {
return;
}
installTriggeredRef.current = true;
try {
const response = await fetch(`${API_BASE}/api/projects/${projectId}/install-dependencies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
const errorText = await response.text();
console.warn('⚠️ Failed to start dependency installation:', errorText);
// allow retry on next attempt if initial trigger failed
installTriggeredRef.current = false;
}
} catch (error) {
console.error('❌ Error starting dependency installation:', error);
// allow retry if network error
installTriggeredRef.current = false;
}
}, [projectId]);
const loadSettings = useCallback(async (projectSettings?: { cli?: string; model?: string }) => {
try {
console.log('🔧 loadSettings called with project settings:', projectSettings);
const hasCliSet = projectSettings?.cli || preferredCli;
const hasModelSet = projectSettings?.model || selectedModel;
if (!hasCliSet || !hasModelSet) {
console.log('⚠️ Missing CLI or model, loading global settings');
const globalResponse = await fetch(`${API_BASE}/api/settings/global`);
if (globalResponse.ok) {
const globalSettings = await globalResponse.json();
const defaultCli = sanitizeCli(globalSettings.default_cli || globalSettings.defaultCli);
const cliToUse = sanitizeCli(hasCliSet || defaultCli);
if (!hasCliSet) {
console.log('🔄 Setting CLI from global:', cliToUse);
updatePreferredCli(cliToUse);
}
if (!hasModelSet) {
const cliSettings = globalSettings.cli_settings?.[cliToUse] || globalSettings.cliSettings?.[cliToUse];
if (cliSettings?.model) {
updateSelectedModel(cliSettings.model, cliToUse);
} else {
updateSelectedModel(getDefaultModelForCli(cliToUse), cliToUse);
}
}
} else {
const response = await fetch(`${API_BASE}/api/settings`);
if (response.ok) {
const settings = await response.json();
if (!hasCliSet) updatePreferredCli(settings.preferred_cli || settings.default_cli || DEFAULT_ACTIVE_CLI);
if (!hasModelSet) {
const cli = sanitizeCli(settings.preferred_cli || settings.default_cli || preferredCli || DEFAULT_ACTIVE_CLI);
updateSelectedModel(getDefaultModelForCli(cli), cli);
}
}
}
}
} catch (error) {
console.error('Failed to load settings:', error);
const hasCliSet = projectSettings?.cli || preferredCli;
const hasModelSet = projectSettings?.model || selectedModel;
if (!hasCliSet) updatePreferredCli(DEFAULT_ACTIVE_CLI);
if (!hasModelSet) updateSelectedModel(getDefaultModelForCli(DEFAULT_ACTIVE_CLI), DEFAULT_ACTIVE_CLI);
}
}, [preferredCli, selectedModel, updatePreferredCli, updateSelectedModel]);
const loadProjectInfo = useCallback(async (): Promise<{ cli?: string; model?: string; status?: ProjectStatus }> => {
try {
const r = await fetch(`${API_BASE}/api/projects/${projectId}`);
if (!r.ok) {
setProjectName(`Project ${projectId.slice(0, 8)}`);
setProjectDescription('');
setHasInitialPrompt(false);
localStorage.setItem(`project_${projectId}_hasInitialPrompt`, 'false');
setProjectStatus('active');
setIsInitializing(false);
setUsingGlobalDefaults(true);
return {};
}
const payload = await r.json();
const project = payload?.data ?? payload;
const rawPreferredCli =
typeof project?.preferredCli === 'string'
? project.preferredCli
: typeof project?.preferred_cli === 'string'
? project.preferred_cli
: undefined;
const rawSelectedModel =
typeof project?.selectedModel === 'string'
? project.selectedModel
: typeof project?.selected_model === 'string'
? project.selected_model
: undefined;
console.log('📋 Loading project info:', {
preferredCli: rawPreferredCli,
selectedModel: rawSelectedModel,
});
setProjectName(project.name || `Project ${projectId.slice(0, 8)}`);
const projectCli = sanitizeCli(rawPreferredCli || preferredCli);
if (rawPreferredCli) {
updatePreferredCli(projectCli);
}
if (rawSelectedModel) {
updateSelectedModel(rawSelectedModel, projectCli);
} else {
updateSelectedModel(getDefaultModelForCli(projectCli), projectCli);
}
const followGlobal = !rawPreferredCli && !rawSelectedModel;
setUsingGlobalDefaults(followGlobal);
setProjectDescription(project.description || '');
if (project.initial_prompt) {
setHasInitialPrompt(true);
localStorage.setItem(`project_${projectId}_hasInitialPrompt`, 'true');
} else {
setHasInitialPrompt(false);
localStorage.setItem(`project_${projectId}_hasInitialPrompt`, 'false');
}
if (project.status === 'initializing') {
setProjectStatus('initializing');
setIsInitializing(true);
} else {
setProjectStatus('active');
setIsInitializing(false);
startDependencyInstallation();
triggerInitialPromptIfNeeded();
}
const normalizedModel = rawSelectedModel
? sanitizeModel(projectCli, rawSelectedModel)
: getDefaultModelForCli(projectCli);
return {
cli: rawPreferredCli ? projectCli : undefined,
model: normalizedModel,
status: project.status as ProjectStatus | undefined,
};
} catch (error) {
console.error('Failed to load project info:', error);
setProjectName(`Project ${projectId.slice(0, 8)}`);
setProjectDescription('');
setHasInitialPrompt(false);
localStorage.setItem(`project_${projectId}_hasInitialPrompt`, 'false');
setProjectStatus('active');
setIsInitializing(false);
setUsingGlobalDefaults(true);
return {};
}
}, [
projectId,
startDependencyInstallation,
triggerInitialPromptIfNeeded,
updatePreferredCli,
updateSelectedModel,
preferredCli,
]);
const loadProjectInfoRef = useRef(loadProjectInfo);
useEffect(() => {
loadProjectInfoRef.current = loadProjectInfo;
}, [loadProjectInfo]);
useEffect(() => {
if (!searchParams) return;
const cliParam = searchParams.get('cli');
const modelParam = searchParams.get('model');
if (!cliParam && !modelParam) {
return;
}
const sanitizedCli = cliParam ? sanitizeCli(cliParam) : preferredCli;
if (cliParam) {
setUsingGlobalDefaults(false);
updatePreferredCli(sanitizedCli);
}
if (modelParam) {
setUsingGlobalDefaults(false);
updateSelectedModel(modelParam, sanitizedCli);
}
}, [searchParams, preferredCli, updatePreferredCli, updateSelectedModel, setUsingGlobalDefaults]);
const loadSettingsRef = useRef(loadSettings);
useEffect(() => {
loadSettingsRef.current = loadSettings;
}, [loadSettings]);
const loadTreeRef = useRef(loadTree);
useEffect(() => {
loadTreeRef.current = loadTree;
}, [loadTree]);
const loadDeployStatusRef = useRef(loadDeployStatus);
useEffect(() => {
loadDeployStatusRef.current = loadDeployStatus;
}, [loadDeployStatus]);
const checkCurrentDeploymentRef = useRef(checkCurrentDeployment);
useEffect(() => {
checkCurrentDeploymentRef.current = checkCurrentDeployment;
}, [checkCurrentDeployment]);
// Stable message handlers with useCallback to prevent reassignment
const createStableMessageHandlers = useCallback(() => {
const addMessage = (message: any) => {
console.log('🔄 [StableHandler] Adding message via stable handler:', {
messageId: message.id,
role: message.role,
isOptimistic: message.isOptimistic,
requestId: message.requestId
});
// Track optimistic messages by requestId
if (message.isOptimistic && message.requestId) {
optimisticMessagesRef.current.set(message.requestId, message);
console.log('🔄 [StableHandler] Tracking optimistic message:', {
requestId: message.requestId,
tempId: message.id
});
}
// Also call the current handlers if they exist
if (messageHandlersRef.current) {
messageHandlersRef.current.add(message);
}
};
const removeMessage = (messageId: string) => {
console.log('🔄 [StableHandler] Removing message via stable handler:', messageId);
// Remove from optimistic messages tracking if it's an optimistic message
const optimisticMessage = Array.from(optimisticMessagesRef.current.values())
.find(msg => msg.id === messageId);
if (optimisticMessage && optimisticMessage.requestId) {
optimisticMessagesRef.current.delete(optimisticMessage.requestId);
console.log('🔄 [StableHandler] Removed optimistic message tracking:', {
requestId: optimisticMessage.requestId,
tempId: messageId
});
}
// Also call the current handlers if they exist
if (messageHandlersRef.current) {
messageHandlersRef.current.remove(messageId);
}
};
return { add: addMessage, remove: removeMessage };
}, []);
// Initialize stable handlers once
useEffect(() => {
stableMessageHandlers.current = createStableMessageHandlers();
const optimisticMessages = optimisticMessagesRef.current;
return () => {
stableMessageHandlers.current = null;
optimisticMessages.clear();
};
}, [createStableMessageHandlers]);
// Handle image upload with base64 conversion
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
Array.from(files).forEach(file => {
if (file.type.startsWith('image/')) {
const url = URL.createObjectURL(file);
// Convert to base64
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
setUploadedImages(prev => [...prev, {
name: file.name,
url,
base64
}]);
};
reader.readAsDataURL(file);
}
});
}
};
// Remove uploaded image
const removeUploadedImage = (index: number) => {
setUploadedImages(prev => {
const newImages = [...prev];
URL.revokeObjectURL(newImages[index].url);
newImages.splice(index, 1);
return newImages;
});
};
async function runAct(messageOverride?: string, externalImages?: any[]) {
let finalMessage = messageOverride || prompt;
const imagesToUse = externalImages || uploadedImages;
if (!finalMessage.trim() && imagesToUse.length === 0) {
alert('Please enter a task description or upload an image.');
return;
}
// Add additional instructions in Chat Mode
if (mode === 'chat') {
finalMessage = finalMessage + "\n\nDo not modify code, only answer to the user's request.";
}
// Create request fingerprint for deduplication
const requestFingerprint = JSON.stringify({
message: finalMessage.trim(),
imageCount: imagesToUse.length,
cliPreference: preferredCli,
model: selectedModel,
mode
});
// Check for duplicate pending requests
if (pendingRequestsRef.current.has(requestFingerprint)) {
console.log('🔄 [DEBUG] Duplicate request detected, skipping:', requestFingerprint);
return;
}
setIsRunning(true);
const requestId = crypto.randomUUID();
let tempUserMessageId: string | null = null;
// Add to pending requests
pendingRequestsRef.current.add(requestFingerprint);
try {
const uploadImageFromBase64 = async (img: { base64: string; name?: string }) => {
const base64String = img.base64;
const match = base64String.match(/^data:(.*?);base64,(.*)$/);
const mimeType = match && match[1] ? match[1] : 'image/png';
const base64Data = match && match[2] ? match[2] : base64String;
const byteString = atob(base64Data);
const buffer = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i += 1) {
buffer[i] = byteString.charCodeAt(i);
}
const extension = (() => {
if (mimeType.includes('png')) return 'png';
if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpg';
if (mimeType.includes('gif')) return 'gif';
if (mimeType.includes('webp')) return 'webp';
if (mimeType.includes('svg')) return 'svg';
return 'png';
})();
const inferredName = img.name && img.name.trim().length > 0 ? img.name.trim() : `image-${crypto.randomUUID()}.${extension}`;
const hasExtension = /\.[a-zA-Z0-9]+$/.test(inferredName);
const filename = hasExtension ? inferredName : `${inferredName}.${extension}`;
const file = new File([buffer], filename, { type: mimeType });
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/api/assets/${projectId}/upload`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Upload failed');
}
const result = await response.json();
return {
name: result.filename || filename,
path: result.absolute_path,
url: `/api/assets/${projectId}/${result.filename}`,
public_url: typeof result.public_url === 'string' ? result.public_url : undefined,
publicUrl: typeof result.public_url === 'string' ? result.public_url : undefined,
};
};
console.log('🖼️ Processing images in runAct:', {
imageCount: imagesToUse.length,
cli: preferredCli,
requestId
});
const processedImages: { name: string; path: string; url?: string; public_url?: string; publicUrl?: string }[] = [];
for (let i = 0; i < imagesToUse.length; i += 1) {
const image = imagesToUse[i];
console.log(`🖼️ Processing image ${i}:`, {
id: image.id,
filename: image.filename,
hasPath: !!image.path,
hasPublicUrl: !!image.publicUrl,
hasAssetUrl: !!image.assetUrl
});
if (image?.path) {
const name = image.filename || image.name || `Image ${i + 1}`;
const candidateUrl = typeof image.assetUrl === 'string' ? image.assetUrl : undefined;
const candidatePublicUrl = typeof image.publicUrl === 'string' ? image.publicUrl : undefined;
const processedImage = {
name,
path: image.path,
url: candidateUrl && candidateUrl.startsWith('/') ? candidateUrl : undefined,
public_url: candidatePublicUrl,
publicUrl: candidatePublicUrl,
};
console.log(`🖼️ Created processed image ${i}:`, processedImage);
processedImages.push(processedImage);
continue;
}
if (image?.base64) {
try {
const uploaded = await uploadImageFromBase64({ base64: image.base64, name: image.name });
processedImages.push(uploaded);
} catch (uploadError) {
console.error('Image upload failed:', uploadError);
alert('Failed to upload image. Please try again.');
setIsRunning(false);
// Remove from pending requests
pendingRequestsRef.current.delete(requestFingerprint);
return;
}
}
}
const requestBody = {
instruction: finalMessage,
images: processedImages,
isInitialPrompt: false,
cliPreference: preferredCli,
conversationId: conversationId || undefined,
requestId,
selectedModel,
};
console.log('📸 Sending request to act API:', {
messageLength: finalMessage.length,
imageCount: processedImages.length,
cli: preferredCli,
requestId,
images: processedImages.map(img => ({
name: img.name,
hasPath: !!img.path,
hasUrl: !!img.url,
hasPublicUrl: !!img.publicUrl
}))
});
// Optimistically add user message to UI BEFORE API call for instant feedback
tempUserMessageId = requestId + '-user-temp';
if (messageHandlersRef.current) {
const optimisticUserMessage = {
id: tempUserMessageId,
projectId: projectId,
role: 'user' as const,
messageType: 'chat' as const,
content: finalMessage,
conversationId: conversationId || null,
requestId: requestId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isStreaming: false,
isFinal: false,
isOptimistic: true,
metadata:
processedImages.length > 0
? {
attachments: processedImages.map((img) => ({
name: img.name,
path: img.path,
url: img.url,
publicUrl: img.publicUrl ?? img.public_url,
})),
}
: undefined,
};
console.log('🔄 [Optimistic] Adding optimistic user message via stable handler:', {
tempId: tempUserMessageId,
requestId,
content: finalMessage.substring(0, 50) + '...'
});
// Use stable handlers instead of direct messageHandlersRef to prevent reassignment issues
if (stableMessageHandlers.current) {
stableMessageHandlers.current.add(optimisticUserMessage);
} else if (messageHandlersRef.current) {
// Fallback to direct handlers if stable handlers aren't ready yet
messageHandlersRef.current.add(optimisticUserMessage);
}
}
// Add timeout to prevent indefinite waiting
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
let r: Response;
try {
r = await fetch(`${API_BASE}/api/chat/${projectId}/act`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!r.ok) {
const errorText = await r.text();
console.error('API Error:', errorText);
if (tempUserMessageId) {
console.log('🔄 [Optimistic] Removing optimistic user message due to API error via stable handler:', tempUserMessageId);
if (stableMessageHandlers.current) {
stableMessageHandlers.current.remove(tempUserMessageId);
} else if (messageHandlersRef.current) {
messageHandlersRef.current.remove(tempUserMessageId);
}
}
alert(`Failed to send message: ${r.status} ${r.statusText}\n${errorText}`);
return;
}
} catch (fetchError: any) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
if (tempUserMessageId) {
console.log('🔄 [Optimistic] Removing optimistic user message due to timeout via stable handler:', tempUserMessageId);
if (stableMessageHandlers.current) {
stableMessageHandlers.current.remove(tempUserMessageId);
} else if (messageHandlersRef.current) {
messageHandlersRef.current.remove(tempUserMessageId);
}
}
alert('Request timed out after 60 seconds. Please check your connection and try again.');
return;
}
throw fetchError;
}
const result = await r.json();
console.log('📸 Act API response received:', {
success: result.success,
userMessageId: result.userMessageId,
conversationId: result.conversationId,
requestId: result.requestId,
hasAttachments: processedImages.length > 0
});
const returnedConversationId =
typeof result?.conversationId === 'string'
? result.conversationId
: typeof result?.conversation_id === 'string'
? result.conversation_id
: undefined;
if (returnedConversationId) {
setConversationId(returnedConversationId);
}
const resolvedRequestId =
typeof result?.requestId === 'string'
? result.requestId
: typeof result?.request_id === 'string'
? result.request_id
: requestId;
const userMessageId =
typeof result?.userMessageId === 'string'
? result.userMessageId
: typeof result?.user_message_id === 'string'
? result.user_message_id
: '';
createRequest(resolvedRequestId, userMessageId, finalMessage, mode);
// Refresh data after completion
await loadTree('.');
// Reset prompt and uploaded images
setPrompt('');
// Clean up old format images if any
if (uploadedImages && uploadedImages.length > 0) {
uploadedImages.forEach(img => {
if (img.url) URL.revokeObjectURL(img.url);
});
setUploadedImages([]);
}
} catch (error: any) {
console.error('Act execution error:', error);
if (tempUserMessageId) {
console.log('🔄 [Optimistic] Removing optimistic user message due to execution error via stable handler:', tempUserMessageId);
if (stableMessageHandlers.current) {
stableMessageHandlers.current.remove(tempUserMessageId);
} else if (messageHandlersRef.current) {
messageHandlersRef.current.remove(tempUserMessageId);
}
}
const errorMessage = error?.message || String(error);
alert(`Failed to send message: ${errorMessage}\n\nPlease try again. If the problem persists, check the console for details.`);
} finally {
setIsRunning(false);
// Remove from pending requests
pendingRequestsRef.current.delete(requestFingerprint);
}
}
// Handle project status updates via callback from ChatLog
const handleProjectStatusUpdate = (status: string, message?: string) => {
const previousStatus = projectStatus;
// Ignore if status is the same (prevent duplicates)
if (previousStatus === status) {
return;
}
setProjectStatus(status as ProjectStatus);
if (message) {
setInitializationMessage(message);
}
// If project becomes active, stop showing loading UI
if (status === 'active') {
setIsInitializing(false);
// Handle only when transitioning from initializing → active
if (previousStatus === 'initializing') {
// Start dependency installation
startDependencyInstallation();
loadTreeRef.current?.('.');
}
// Initial prompt: trigger once with shared guard (handles active-via-WS case)
triggerInitialPromptIfNeeded();
} else if (status === 'failed') {
setIsInitializing(false);
}
};
// Function to start dependency installation in background
const handleRetryInitialization = async () => {
setProjectStatus('initializing');
setIsInitializing(true);
setInitializationMessage('Retrying project initialization...');
try {
const response = await fetch(`${API_BASE}/api/projects/${projectId}/retry-initialization`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to retry initialization');
}
} catch (error) {
console.error('Failed to retry initialization:', error);
setProjectStatus('failed');
setInitializationMessage('Failed to retry initialization. Please try again.');
}
};
// Load states from localStorage when projectId changes
useEffect(() => {
if (typeof window !== 'undefined' && projectId) {
const storedHasInitialPrompt = localStorage.getItem(`project_${projectId}_hasInitialPrompt`);
const storedTaskComplete = localStorage.getItem(`project_${projectId}_taskComplete`);
if (storedHasInitialPrompt !== null) {
setHasInitialPrompt(storedHasInitialPrompt === 'true');
}
if (storedTaskComplete !== null) {
setAgentWorkComplete(storedTaskComplete === 'true');
}
}
}, [projectId]);
// NEW: Auto control preview server based on active request status
const previousActiveState = useRef(false);
useEffect(() => {
if (!hasActiveRequests && !previewUrl && !isStartingPreview) {
if (!previousActiveState.current) {
console.log('🔄 Preview not running; auto-starting');
} else {
console.log('✅ Task completed, ensuring preview server is running');
}
start();
}
previousActiveState.current = hasActiveRequests;
}, [hasActiveRequests, previewUrl, isStartingPreview, start]);
// Poll for file changes in code view
useEffect(() => {
if (!showPreview && selectedFile && !hasUnsavedChanges) {
const interval = setInterval(() => {
reloadCurrentFile();
}, 2000); // Check every 2 seconds
return () => clearInterval(interval);
}
}, [showPreview, selectedFile, hasUnsavedChanges, reloadCurrentFile]);
useEffect(() => {
if (!projectId) {
return;
}
let canceled = false;
const initializeChat = async () => {
try {
const projectSettings = await loadProjectInfoRef.current?.();
if (canceled) return;
await loadSettingsRef.current?.(projectSettings);
if (canceled) return;
await loadTreeRef.current?.('.');
if (canceled) return;
await loadDeployStatusRef.current?.();
if (canceled) return;
checkCurrentDeploymentRef.current?.();
} catch (error) {
console.error('Failed to initialize chat view:', error);
}
};
initializeChat();
const handleServicesUpdate = () => {
loadDeployStatusRef.current?.();
};
const handleBeforeUnload = () => {
navigator.sendBeacon(`${API_BASE}/api/projects/${projectId}/preview/stop`);
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('services-updated', handleServicesUpdate);
return () => {
canceled = true;
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('services-updated', handleServicesUpdate);
const currentPreview = previewUrlRef.current;
if (currentPreview) {
fetch(`${API_BASE}/api/projects/${projectId}/preview/stop`, { method: 'POST' }).catch(() => {});
}
};
}, [projectId]);
// Cleanup pending requests on unmount
useEffect(() => {
const pendingRequests = pendingRequestsRef.current;
return () => {
pendingRequests.clear();
};
}, []);
// React to global settings changes when using global defaults
const { settings: globalSettings } = useGlobalSettings();
useEffect(() => {
if (!usingGlobalDefaults) return;
if (!globalSettings) return;
const cli = sanitizeCli(globalSettings.default_cli);
updatePreferredCli(cli);
const modelFromGlobal = globalSettings.cli_settings?.[cli]?.model;
if (modelFromGlobal) {
updateSelectedModel(modelFromGlobal, cli);
} else {
updateSelectedModel(getDefaultModelForCli(cli), cli);
}
}, [globalSettings, usingGlobalDefaults, updatePreferredCli, updateSelectedModel]);
// Show loading UI if project is initializing
return (
<>
<style jsx global>{`
/* Light theme syntax highlighting */
.hljs {
background: #f9fafb !important;
color: #374151 !important;
}
.hljs-punctuation,
.hljs-bracket,
.hljs-operator {
color: #1f2937 !important;
font-weight: 600 !important;
}
.hljs-built_in,
.hljs-keyword {
color: #7c3aed !important;
font-weight: 600 !important;
}
.hljs-string {
color: #059669 !important;
}
.hljs-number {
color: #dc2626 !important;
}
.hljs-comment {
color: #6b7280 !important;
font-style: italic;
}
.hljs-function,
.hljs-title {
color: #2563eb !important;
font-weight: 600 !important;
}
.hljs-variable,
.hljs-attr {
color: #dc2626 !important;
}
.hljs-tag,
.hljs-name {
color: #059669 !important;
}
/* Make parentheses, brackets, and braces more visible */
.hljs-punctuation:is([data-char="("], [data-char=")"], [data-char="["], [data-char="]"], [data-char="{"], [data-char="}"]) {
color: #1f2937 !important;
font-weight: bold !important;
background: rgba(59, 130, 246, 0.1);
border-radius: 2px;
padding: 0 1px;
}
`}</style>
<div className="h-screen bg-white flex relative overflow-hidden">
<div className="h-full w-full flex">
{/* Left: Chat window */}
<div
style={{ width: '30%' }}
className="h-full border-r border-gray-200 flex flex-col"
>
{/* Chat header */}
<div className="bg-white border-b border-gray-200 p-4 h-[73px] flex items-center">
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/')}
className="flex items-center justify-center w-8 h-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors"
title="Back to home"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M12 19L5 12L12 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<div>
<h1 className="text-lg font-semibold text-gray-900 ">{projectName || 'Loading...'}</h1>
{projectDescription && (
<p className="text-sm text-gray-500 ">
{projectDescription}
</p>
)}
</div>
</div>
</div>
{/* Chat log area */}
<div className="flex-1 min-h-0">
<ChatErrorBoundary>
<ChatLog
projectId={projectId}
onAddUserMessage={(handlers) => {
console.log('🔄 [HandlerSetup] ChatLog provided new handlers, updating references');
messageHandlersRef.current = handlers;
// Also update stable handlers if they exist
if (stableMessageHandlers.current) {
console.log('🔄 [HandlerSetup] Updating stable handlers reference');
// Note: stableMessageHandlers.current already has its own add/remove logic
// We don't replace it completely, just keep the reference to handlers
}
}}
onSessionStatusChange={(isRunningValue) => {
console.log('🔍 [DEBUG] Session status change:', isRunningValue);
setIsRunning(isRunningValue);
// Track agent task completion and auto-start preview
if (!isRunningValue && hasInitialPrompt && !agentWorkComplete && !previewUrl) {
setAgentWorkComplete(true);
// Save to localStorage
localStorage.setItem(`project_${projectId}_taskComplete`, 'true');
// Auto-start preview server after initial prompt task completion
start();
}
}}
onSseFallbackActive={(active) => {
console.log('🔄 [SSE] Fallback status:', active);
setIsSseFallbackActive(active);
}}
onProjectStatusUpdate={handleProjectStatusUpdate}
startRequest={startRequest}
completeRequest={completeRequest}
/>
</ChatErrorBoundary>
</div>
{/* Simple input area */}
<div className="p-4 rounded-bl-2xl">
<ChatInput
onSendMessage={(message, images) => {
// Pass images to runAct
runAct(message, images);
}}
disabled={isRunning}
placeholder={mode === 'act' ? "Ask Claudable..." : "Chat with Claudable..."}
mode={mode}
onModeChange={setMode}
projectId={projectId}
preferredCli={preferredCli}
selectedModel={selectedModel}
thinkingMode={thinkingMode}
onThinkingModeChange={setThinkingMode}
modelOptions={modelOptions}
onModelChange={handleModelChange}
modelChangeDisabled={isUpdatingModel}
cliOptions={cliOptions}
onCliChange={handleCliChange}
cliChangeDisabled={isUpdatingModel}
/>
</div>
</div>
{/* Right: Preview/Code area */}
<div className="h-full flex flex-col bg-black" style={{ width: '70%' }}>
{/* Content area */}
<div className="flex-1 min-h-0 flex flex-col">
{/* Controls Bar */}
<div className="bg-white border-b border-gray-200 px-4 h-[73px] flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Toggle switch */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
showPreview
? 'bg-white text-gray-900 '
: 'text-gray-600 hover:text-gray-900 '
}`}
onClick={() => setShowPreview(true)}
>
<span className="w-4 h-4 flex items-center justify-center"><FaDesktop size={16} /></span>
</button>
<button
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
!showPreview
? 'bg-white text-gray-900 '
: 'text-gray-600 hover:text-gray-900 '
}`}
onClick={() => setShowPreview(false)}
>
<span className="w-4 h-4 flex items-center justify-center"><FaCode size={16} /></span>
</button>
</div>
{/* Center Controls */}
{showPreview && previewUrl && (
<div className="flex items-center gap-3">
{/* Route Navigation */}
<div className="h-9 flex items-center bg-gray-100 rounded-lg px-3 border border-gray-200 ">
<span className="text-gray-400 mr-2">
<FaHome size={12} />
</span>
<span className="text-sm text-gray-500 mr-1">/</span>
<input
type="text"
value={currentRoute.startsWith('/') ? currentRoute.slice(1) : currentRoute}
onChange={(e) => {
const value = e.target.value;
setCurrentRoute(value ? `/${value}` : '/');
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
navigateToRoute(currentRoute);
}
}}
className="bg-transparent text-sm text-gray-700 outline-none w-40"
placeholder="route"
/>
<button
onClick={() => navigateToRoute(currentRoute)}
className="ml-2 text-gray-500 hover:text-gray-700 "
>
<FaArrowRight size={12} />
</button>
</div>
{/* Action Buttons Group */}
<div className="flex items-center gap-1.5">
<button
className="h-9 w-9 flex items-center justify-center bg-gray-100 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors"
onClick={() => {
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.src = iframe.src;
}
}}
title="Refresh preview"
>
<FaRedo size={14} />
</button>
{/* Device Mode Toggle */}
<div className="h-9 flex items-center gap-1 bg-gray-100 rounded-lg px-1 border border-gray-200 ">
<button
aria-label="Desktop preview"
className={`h-7 w-7 flex items-center justify-center rounded transition-colors ${
deviceMode === 'desktop'
? 'text-blue-600 bg-blue-50 '
: 'text-gray-400 hover:text-gray-600 '
}`}
onClick={() => setDeviceMode('desktop')}
>
<FaDesktop size={14} />
</button>
<button
aria-label="Mobile preview"
className={`h-7 w-7 flex items-center justify-center rounded transition-colors ${
deviceMode === 'mobile'
? 'text-blue-600 bg-blue-50 '
: 'text-gray-400 hover:text-gray-600 '
}`}
onClick={() => setDeviceMode('mobile')}
>
<FaMobileAlt size={14} />
</button>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Settings Button */}
<button
onClick={() => setShowGlobalSettings(true)}
className="h-9 w-9 flex items-center justify-center bg-gray-100 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors"
title="Settings"
>
<FaCog size={16} />
</button>
{/* Stop Button */}
{showPreview && previewUrl && (
<button
className="h-9 px-3 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
onClick={stop}
>
<FaStop size={12} />
Stop
</button>
)}
{/* Publish/Update */}
{showPreview && previewUrl && (
<div className="relative">
<button
className="h-9 flex items-center gap-2 px-3 bg-black text-white rounded-lg text-sm font-medium transition-colors hover:bg-gray-900 border border-black/10 shadow-sm"
onClick={() => setShowPublishPanel(true)}
>
<FaRocket size={14} />
Publish
{deploymentStatus === 'deploying' && (
<span className="ml-2 inline-block w-2 h-2 rounded-full bg-amber-400"></span>
)}
{deploymentStatus === 'ready' && (
<span className="ml-2 inline-block w-2 h-2 rounded-full bg-emerald-400"></span>
)}
</button>
{false && showPublishPanel && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-xl border border-gray-200 z-50 p-5">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Publish Project</h3>
{/* Deployment Status Display */}
{deploymentStatus === 'deploying' && (
<div className="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200 ">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<p className="text-sm font-medium text-blue-700 ">Deployment in progress...</p>
</div>
<p className="text-xs text-blue-600 ">Building and deploying your project. This may take a few minutes.</p>
</div>
)}
{deploymentStatus === 'ready' && publishedUrl && (
<div className="mb-4 p-4 bg-green-50 rounded-lg border border-green-200 ">
<p className="text-sm font-medium text-green-700 mb-2">Currently published at:</p>
<a
href={publishedUrl ?? undefined}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-green-600 font-mono hover:underline break-all"
>
{publishedUrl}
</a>
</div>
)}
{deploymentStatus === 'error' && (
<div className="mb-4 p-4 bg-red-50 rounded-lg border border-red-200 ">
<p className="text-sm font-medium text-red-700 mb-2">Deployment failed</p>
<p className="text-xs text-red-600 ">There was an error during deployment. Please try again.</p>
</div>
)}
<div className="space-y-4">
{!githubConnected || !vercelConnected ? (
<div className="p-4 bg-amber-50 rounded-lg border border-amber-200 ">
<p className="text-sm font-medium text-gray-900 mb-3">To publish, connect the following services:</p>
<div className="space-y-2">
{!githubConnected && (
<div className="flex items-center gap-2 text-amber-700 ">
<svg className="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="text-sm">GitHub repository not connected</span>
</div>
)}
{!vercelConnected && (
<div className="flex items-center gap-2 text-amber-700 ">
<svg className="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="text-sm">Vercel project not connected</span>
</div>
)}
</div>
<p className="mt-3 text-sm text-gray-600 ">
Go to
<button
onClick={() => {
setShowPublishPanel(false);
setShowGlobalSettings(true);
}}
className="text-indigo-600 hover:text-indigo-500 underline font-medium mx-1"
>
Settings → Service Integrations
</button>
to connect.
</p>
</div>
) : null}
<button
disabled={publishLoading || deploymentStatus === 'deploying' || !githubConnected || !vercelConnected}
onClick={async () => {
console.log('🚀 Publish started');
setPublishLoading(true);
try {
// Push to GitHub
console.log('🚀 Pushing to GitHub...');
const pushRes = await fetch(`${API_BASE}/api/projects/${projectId}/github/push`, { method: 'POST' });
if (!pushRes.ok) {
const errorText = await pushRes.text();
console.error('🚀 GitHub push failed:', errorText);
throw new Error(errorText);
}
// Deploy to Vercel
console.log('🚀 Deploying to Vercel...');
const deployUrl = `${API_BASE}/api/projects/${projectId}/vercel/deploy`;
const vercelRes = await fetch(deployUrl, {
method: 'POST'
});
if (!vercelRes.ok) {
const responseText = await vercelRes.text();
console.error('🚀 Vercel deploy failed:', responseText);
}
if (vercelRes.ok) {
const data = await vercelRes.json();
console.log('🚀 Deployment started, polling for status...');
// Set deploying status BEFORE ending publishLoading to prevent gap
setDeploymentStatus('deploying');
if (data.deployment_id) {
startDeploymentPolling(data.deployment_id);
}
// Only set URL if deployment is already ready
if (data.status === 'READY' && data.deployment_url) {
const url = data.deployment_url.startsWith('http') ? data.deployment_url : `https://${data.deployment_url}`;
setPublishedUrl(url);
setDeploymentStatus('ready');
}
} else {
const errorText = await vercelRes.text();
console.error('🚀 Vercel deploy failed:', vercelRes.status, errorText);
// if Vercel not connected, just close
setDeploymentStatus('idle');
setPublishLoading(false); // Stop loading even on Vercel deployment failure
}
// Keep panel open to show deployment progress
} catch (e) {
console.error('🚀 Publish failed:', e);
alert('Publish failed. Check Settings and tokens.');
setDeploymentStatus('idle');
setPublishLoading(false); // Stop loading on error
// Close panel after error
setTimeout(() => {
setShowPublishPanel(false);
}, 1000);
} finally {
loadDeployStatus();
}
}}
className={`w-full px-4 py-3 rounded-lg font-medium text-white transition-colors ${
publishLoading || deploymentStatus === 'deploying' || !githubConnected || !vercelConnected
? 'bg-gray-400 cursor-not-allowed'
: 'bg-indigo-600 hover:bg-indigo-700 '
}`}
>
{publishLoading
? 'Publishing...'
: deploymentStatus === 'deploying'
? 'Deploying...'
: !githubConnected || !vercelConnected
? 'Connect Services First'
: deploymentStatus === 'ready' && publishedUrl ? 'Update' : 'Publish'
}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Content Area */}
<div className="flex-1 relative bg-black overflow-hidden">
<AnimatePresence initial={false}>
{showPreview ? (
<MotionDiv
key="preview"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ height: '100%' }}
>
{previewUrl ? (
<div className="relative w-full h-full bg-gray-100 flex items-center justify-center">
<div
className={`bg-white ${
deviceMode === 'mobile'
? 'w-[375px] h-[667px] rounded-[25px] border-8 border-gray-800 shadow-2xl'
: 'w-full h-full'
} overflow-hidden`}
>
<iframe
ref={iframeRef}
className="w-full h-full border-none bg-white "
src={previewUrl}
onError={() => {
// Show error overlay
const overlay = document.getElementById('iframe-error-overlay');
if (overlay) overlay.style.display = 'flex';
}}
onLoad={() => {
// Hide error overlay when loaded successfully
const overlay = document.getElementById('iframe-error-overlay');
if (overlay) overlay.style.display = 'none';
}}
/>
{/* Error overlay */}
<div
id="iframe-error-overlay"
className="absolute inset-0 bg-gray-50 flex items-center justify-center z-10"
style={{ display: 'none' }}
>
<div className="text-center max-w-md mx-auto p-6">
<div className="text-4xl mb-4">🔄</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
Connection Issue
</h3>
<p className="text-gray-600 mb-4">
The preview couldn't load properly. Try clicking the refresh button to reload the page.
</p>
<button
className="flex items-center gap-2 mx-auto px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
onClick={() => {
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.src = iframe.src;
}
const overlay = document.getElementById('iframe-error-overlay');
if (overlay) overlay.style.display = 'none';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4v6h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Refresh Now
</button>
</div>
</div>
</div>
</div>
) : (
<div className="h-full w-full flex items-center justify-center bg-gray-50 relative">
{/* Gradient background similar to main page */}
<div className="absolute inset-0">
<div className="absolute inset-0 bg-white " />
<div
className="absolute inset-0 hidden transition-all duration-1000 ease-in-out"
style={{
background: `radial-gradient(circle at 50% 100%,
${activeBrandColor}66 0%,
${activeBrandColor}4D 25%,
${activeBrandColor}33 50%,
transparent 70%)`
}}
/>
{/* Light mode gradient - subtle */}
<div
className="absolute inset-0 block transition-all duration-1000 ease-in-out"
style={{
background: `radial-gradient(circle at 50% 100%,
${activeBrandColor}40 0%,
${activeBrandColor}26 25%,
transparent 50%)`
}}
/>
</div>
{/* Content with z-index to be above gradient */}
<div className="relative z-10 w-full h-full flex items-center justify-center">
{isStartingPreview ? (
<MotionDiv
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
>
{/* Claudable Symbol with loading spinner */}
<div className="w-40 h-40 mx-auto mb-6 relative">
<div
className="w-full h-full"
style={{
backgroundColor: activeBrandColor,
mask: 'url(/Symbol_white.png) no-repeat center/contain',
WebkitMask: 'url(/Symbol_white.png) no-repeat center/contain',
opacity: 0.9
}}
/>
{/* Loading spinner in center */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="w-14 h-14 border-4 rounded-full animate-spin"
style={{
borderTopColor: 'transparent',
borderRightColor: activeBrandColor,
borderBottomColor: activeBrandColor,
borderLeftColor: activeBrandColor,
}}
/>
</div>
</div>
{/* Content */}
<h3 className="text-xl font-semibold text-gray-900 mb-3">
Starting Preview Server
</h3>
<div className="flex items-center justify-center gap-1 text-gray-600 ">
<span>{previewInitializationMessage}</span>
<MotionDiv
className="flex gap-1 ml-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<MotionDiv
animate={{ opacity: [0, 1, 0] }}
transition={{ duration: 1.5, repeat: Infinity, delay: 0 }}
className="w-1 h-1 bg-gray-600 rounded-full"
/>
<MotionDiv
animate={{ opacity: [0, 1, 0] }}
transition={{ duration: 1.5, repeat: Infinity, delay: 0.3 }}
className="w-1 h-1 bg-gray-600 rounded-full"
/>
<MotionDiv
animate={{ opacity: [0, 1, 0] }}
transition={{ duration: 1.5, repeat: Infinity, delay: 0.6 }}
className="w-1 h-1 bg-gray-600 rounded-full"
/>
</MotionDiv>
</div>
</MotionDiv>
) : (
<div className="text-center">
<MotionDiv
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
{/* Claudable Symbol */}
{hasActiveRequests ? (
<>
<div className="w-40 h-40 mx-auto mb-6 relative">
<MotionDiv
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
style={{ transformOrigin: "center center" }}
className="w-full h-full"
>
<div
className="w-full h-full"
style={{
backgroundColor: activeBrandColor,
mask: 'url(/Symbol_white.png) no-repeat center/contain',
WebkitMask: 'url(/Symbol_white.png) no-repeat center/contain',
opacity: 0.9
}}
/>
</MotionDiv>
</div>
<h3 className="text-2xl font-bold mb-3 relative overflow-hidden inline-block">
<span
className="relative"
style={{
background: `linear-gradient(90deg,
#6b7280 0%,
#6b7280 30%,
#ffffff 50%,
#6b7280 70%,
#6b7280 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
animation: 'shimmerText 5s linear infinite'
}}
>
Building...
</span>
<style>{`
@keyframes shimmerText {
0% {
background-position: 200% center;
}
100% {
background-position: -200% center;
}
}
`}</style>
</h3>
</>
) : (
<>
<div
onClick={!isRunning && !isStartingPreview ? start : undefined}
className={`w-40 h-40 mx-auto mb-6 relative ${!isRunning && !isStartingPreview ? 'cursor-pointer group' : ''}`}
>
{/* Claudable Symbol with rotating animation when starting */}
<MotionDiv
className="w-full h-full"
animate={isStartingPreview ? { rotate: 360 } : {}}
transition={{ duration: 6, repeat: isStartingPreview ? Infinity : 0, ease: "linear" }}
>
<div
className="w-full h-full"
style={{
backgroundColor: activeBrandColor,
mask: 'url(/Symbol_white.png) no-repeat center/contain',
WebkitMask: 'url(/Symbol_white.png) no-repeat center/contain',
opacity: 0.9
}}
/>
</MotionDiv>
{/* Icon in Center - Play or Loading */}
<div className="absolute inset-0 flex items-center justify-center">
{isStartingPreview ? (
<div
className="w-14 h-14 border-4 rounded-full animate-spin"
style={{
borderTopColor: 'transparent',
borderRightColor: activeBrandColor,
borderBottomColor: activeBrandColor,
borderLeftColor: activeBrandColor,
}}
/>
) : (
<MotionDiv
className="flex items-center justify-center"
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.9 }}
>
<FaPlay
size={32}
/>
</MotionDiv>
)}
</div>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-3">
Preview Not Running
</h3>
<p className="text-gray-600 max-w-lg mx-auto">
Start your development server to see live changes
</p>
</>
)}
</MotionDiv>
</div>
)}
</div>
</div>
)}
</MotionDiv>
) : (
<MotionDiv
key="code"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex bg-white "
>
{/* Left Sidebar - File Explorer (VS Code style) */}
<div className="w-64 flex-shrink-0 bg-gray-50 border-r border-gray-200 flex flex-col">
{/* File Tree */}
<div className="flex-1 overflow-y-auto bg-gray-50 custom-scrollbar">
{!tree || tree.length === 0 ? (
<div className="px-3 py-8 text-center text-[11px] text-gray-600 select-none">
No files found
</div>
) : (
<TreeView
entries={tree || []}
selectedFile={selectedFile}
expandedFolders={expandedFolders}
folderContents={folderContents}
onToggleFolder={toggleFolder}
onSelectFile={openFile}
onLoadFolder={handleLoadFolder}
level={0}
parentPath=""
getFileIcon={getFileIcon}
/>
)}
</div>
</div>
{/* Right Editor Area */}
<div className="flex-1 flex flex-col bg-white min-w-0">
{selectedFile ? (
<>
{/* File Tab */}
<div className="flex-shrink-0 bg-gray-100 ">
<div className="flex items-center gap-3 bg-white px-3 py-1.5 border-t-2 border-t-blue-500 ">
<div className="flex items-center gap-2 min-w-0">
<span className="w-4 h-4 flex items-center justify-center">
{getFileIcon(tree.find(e => e.path === selectedFile) || { path: selectedFile, type: 'file' })}
</span>
<span className="truncate text-[13px] text-gray-700 " style={{ fontFamily: "'Segoe UI', Tahoma, sans-serif" }}>
{selectedFile.split('/').pop()}
</span>
</div>
{hasUnsavedChanges && (
<span className="text-[11px] text-amber-600 ">
• Unsaved changes
</span>
)}
{!hasUnsavedChanges && saveFeedback === 'success' && (
<span className="text-[11px] text-green-600 ">
Saved
</span>
)}
{saveFeedback === 'error' && (
<span
className="text-[11px] text-red-600 truncate max-w-[160px]"
title={saveError ?? 'Failed to save file'}
>
Save error
</span>
)}
{!hasUnsavedChanges && saveFeedback !== 'success' && isFileUpdating && (
<span className="text-[11px] text-green-600 ">
Updated
</span>
)}
<div className="ml-auto flex items-center gap-2">
<button
className="px-3 py-1 text-xs font-medium rounded bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-300 disabled:text-gray-600 disabled:cursor-not-allowed "
onClick={handleSaveFile}
disabled={!hasUnsavedChanges || isSavingFile}
title="Save (Ctrl+S)"
>
{isSavingFile ? 'Saving…' : 'Save'}
</button>
<button
className="text-gray-700 hover:bg-gray-200 px-1 rounded"
onClick={() => {
if (hasUnsavedChanges) {
const confirmClose =
typeof window !== 'undefined'
? window.confirm('You have unsaved changes. Close without saving?')
: true;
if (!confirmClose) {
return;
}
}
setSelectedFile('');
setContent('');
setEditedContent('');
editedContentRef.current = '';
setHasUnsavedChanges(false);
setSaveFeedback('idle');
setSaveError(null);
setIsFileUpdating(false);
}}
>
×
</button>
</div>
</div>
</div>
{/* Code Editor */}
<div className="flex-1 overflow-hidden">
<div className="w-full h-full flex bg-white overflow-hidden">
{/* Line Numbers */}
<div
ref={lineNumberRef}
className="bg-gray-50 px-3 py-4 select-none flex-shrink-0 overflow-y-auto overflow-x-hidden custom-scrollbar pointer-events-none"
aria-hidden="true"
>
<div className="text-[13px] font-mono text-gray-500 leading-[19px]">
{(editedContent || '').split('\n').map((_, index) => (
<div key={index} className="text-right pr-2">
{index + 1}
</div>
))}
</div>
</div>
{/* Code Content */}
<div className="relative flex-1">
<pre
ref={highlightRef}
aria-hidden="true"
className="absolute inset-0 m-0 p-4 overflow-hidden text-[13px] leading-[19px] font-mono text-gray-800 whitespace-pre pointer-events-none"
style={{ fontFamily: "'Fira Code', 'Consolas', 'Monaco', monospace" }}
>
<code
className={`language-${getFileLanguage(selectedFile)}`}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
<span className="block h-full min-h-[1px]" />
</pre>
<textarea
ref={editorRef}
value={editedContent}
onChange={onEditorChange}
onScroll={handleEditorScroll}
onKeyDown={handleEditorKeyDown}
spellCheck={false}
autoCorrect="off"
autoCapitalize="none"
autoComplete="off"
wrap="off"
aria-label="Code editor"
className="absolute inset-0 w-full h-full resize-none bg-transparent text-transparent caret-gray-800 outline-none font-mono text-[13px] leading-[19px] p-4 whitespace-pre overflow-auto custom-scrollbar"
style={{ fontFamily: "'Fira Code', 'Consolas', 'Monaco', monospace" }}
/>
</div>
</div>
</div>
</>
) : (
/* Welcome Screen */
<div className="flex-1 flex items-center justify-center bg-white ">
<div className="text-center">
<span className="w-16 h-16 mb-4 opacity-10 text-gray-400 mx-auto flex items-center justify-center"><FaCode size={64} /></span>
<h3 className="text-lg font-medium text-gray-700 mb-2">
Welcome to Code Editor
</h3>
<p className="text-sm text-gray-500 ">
Select a file from the explorer to start viewing code
</p>
</div>
</div>
)}
</div>
</MotionDiv>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
</div>
{/* Publish Modal */}
{showPublishPanel && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowPublishPanel(false)} />
<div className="relative w-full max-w-lg bg-white border border-gray-200 rounded-2xl shadow-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gray-50/60 ">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-black border border-black/10 ">
<FaRocket size={14} />
</div>
<div>
<h3 className="text-base font-semibold text-gray-900 ">Publish Project</h3>
<p className="text-xs text-gray-600 ">Deploy with Vercel, linked to your GitHub repo</p>
</div>
</div>
<button onClick={() => setShowPublishPanel(false)} className="text-gray-400 hover:text-gray-600 ">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
</button>
</div>
<div className="p-6 space-y-4">
{deploymentStatus === 'deploying' && (
<div className="p-4 rounded-xl border border-blue-200 bg-blue-50 ">
<div className="flex items-center gap-2 mb-1">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<p className="text-sm font-medium text-blue-700 ">Deployment in progress…</p>
</div>
<p className="text-xs text-blue-700/80 ">Building and deploying your project. This may take a few minutes.</p>
</div>
)}
{deploymentStatus === 'ready' && publishedUrl && (
<div className="p-4 rounded-xl border border-emerald-200 bg-emerald-50 ">
<p className="text-sm font-medium text-emerald-700 mb-2">Published successfully</p>
<div className="flex items-center gap-2">
<a href={publishedUrl} target="_blank" rel="noopener noreferrer" className="text-sm font-mono text-emerald-700 underline break-all flex-1">
{publishedUrl}
</a>
<button
onClick={() => navigator.clipboard?.writeText(publishedUrl)}
className="px-2 py-1 text-xs rounded-lg border border-emerald-300/80 text-emerald-700 hover:bg-emerald-100 "
>
Copy
</button>
</div>
</div>
)}
{deploymentStatus === 'error' && (
<div className="p-4 rounded-xl border border-red-200 bg-red-50 ">
<p className="text-sm font-medium text-red-700 ">Deployment failed. Please try again.</p>
</div>
)}
{!githubConnected || !vercelConnected ? (
<div className="p-4 rounded-xl border border-amber-200 bg-amber-50 ">
<p className="text-sm font-medium text-gray-900 mb-2">Connect the following services:</p>
<div className="space-y-1 text-amber-700 text-sm">
{!githubConnected && (<div className="flex items-center gap-2"><span className="w-1.5 h-1.5 rounded-full bg-amber-500"/>GitHub repository not connected</div>)}
{!vercelConnected && (<div className="flex items-center gap-2"><span className="w-1.5 h-1.5 rounded-full bg-amber-500"/>Vercel project not connected</div>)}
</div>
<button
className="mt-3 w-full px-4 py-2 rounded-xl border border-gray-200 text-gray-800 hover:bg-gray-50 "
onClick={() => { setShowPublishPanel(false); setShowGlobalSettings(true); }}
>
Open Settings → Services
</button>
</div>
) : null}
<button
disabled={publishLoading || deploymentStatus === 'deploying' || !githubConnected || !vercelConnected}
onClick={async () => {
try {
setPublishLoading(true);
setDeploymentStatus('deploying');
// 1) Push to GitHub to ensure branch/commit exists
try {
const pushRes = await fetch(`${API_BASE}/api/projects/${projectId}/github/push`, { method: 'POST' });
if (!pushRes.ok) {
const err = await pushRes.text();
console.error('🚀 GitHub push failed:', err);
throw new Error(err);
}
} catch (e) {
console.error('🚀 GitHub push step failed', e);
throw e;
}
// Small grace period to let GitHub update default branch
await new Promise(r => setTimeout(r, 800));
// 2) Deploy to Vercel (branch auto-resolved on server)
const deployUrl = `${API_BASE}/api/projects/${projectId}/vercel/deploy`;
const vercelRes = await fetch(deployUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: 'main' })
});
if (vercelRes.ok) {
const data = await vercelRes.json();
setDeploymentStatus('deploying');
if (data.deployment_id) startDeploymentPolling(data.deployment_id);
if (data.ready && data.deployment_url) {
const url = data.deployment_url.startsWith('http') ? data.deployment_url : `https://${data.deployment_url}`;
setPublishedUrl(url);
setDeploymentStatus('ready');
}
} else {
const errorText = await vercelRes.text();
console.error('🚀 Vercel deploy failed:', vercelRes.status, errorText);
setDeploymentStatus('idle');
setPublishLoading(false);
}
} catch (e) {
console.error('🚀 Publish failed:', e);
alert('Publish failed. Check Settings and tokens.');
setDeploymentStatus('idle');
setPublishLoading(false);
setTimeout(() => setShowPublishPanel(false), 1000);
} finally {
loadDeployStatus();
}
}}
className={`w-full px-4 py-3 rounded-xl font-medium text-white transition ${
publishLoading || deploymentStatus === 'deploying' || !githubConnected || !vercelConnected
? 'bg-gray-400 cursor-not-allowed'
: 'bg-black hover:bg-gray-900'
}`}
>
{publishLoading ? 'Publishing…' : deploymentStatus === 'deploying' ? 'Deploying…' : (!githubConnected || !vercelConnected) ? 'Connect Services First' : (deploymentStatus === 'ready' && publishedUrl ? 'Update' : 'Publish')}
</button>
</div>
</div>
</div>
)}
{/* Project Settings Modal */}
<ProjectSettings
isOpen={showGlobalSettings}
onClose={() => setShowGlobalSettings(false)}
projectId={projectId}
projectName={projectName}
projectDescription={projectDescription}
initialTab="services"
onProjectUpdated={({ name, description }) => {
setProjectName(name);
setProjectDescription(description ?? '');
}}
/>
</>
);
}
```
## /app/api/assets/[project_id]/[filename]/route.ts
```ts path="/app/api/assets/[project_id]/[filename]/route.ts"
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getProjectById } from '@/lib/services/project';
interface RouteContext {
params: Promise<{ project_id: string; filename: string }>;
}
const PROJECTS_DIR = process.env.PROJECTS_DIR || './data/projects';
const PROJECTS_DIR_ABSOLUTE = path.isAbsolute(PROJECTS_DIR)
? PROJECTS_DIR
: path.resolve(process.cwd(), PROJECTS_DIR);
function inferContentType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
switch (ext) {
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.svg':
return 'image/svg+xml';
default:
return 'application/octet-stream';
}
}
export async function GET(_request: Request, { params }: RouteContext) {
const { project_id, filename } = await params;
try {
console.log('📸 Asset serving request:', {
project_id,
filename,
projectsDir: PROJECTS_DIR,
userAgent: _request.headers.get('user-agent')
});
const project = await getProjectById(project_id);
if (!project) {
console.log('📸 Asset serving failed: Project not found:', project_id);
return NextResponse.json({ success: false, error: 'Project not found' }, { status: 404 });
}
const filePath = path.join(PROJECTS_DIR_ABSOLUTE, project_id, 'assets', filename);
console.log('📸 Checking file path:', {
filePath,
exists: await fs.access(filePath).then(() => true).catch(() => false)
});
const fileStat = await fs.stat(filePath).catch(() => null);
if (!fileStat || !fileStat.isFile()) {
console.log('📸 Asset serving failed: File not found:', {
filePath,
fileStat,
projectAssetsDir: path.join(PROJECTS_DIR, project_id, 'assets')
});
// Check if assets directory exists
const assetsDir = path.join(PROJECTS_DIR_ABSOLUTE, project_id, 'assets');
const assetsDirExists = await fs.access(assetsDir).then(() => true).catch(() => false);
console.log('📸 Assets directory exists:', assetsDirExists);
// List files in assets directory if it exists
if (assetsDirExists) {
try {
const files = await fs.readdir(assetsDir);
console.log('📸 Files in assets directory:', files);
} catch (error) {
console.log('📸 Failed to list assets directory files:', error);
}
}
return NextResponse.json({ success: false, error: 'Image not found' }, { status: 404 });
}
const fileBuffer = await fs.readFile(filePath);
const response = new NextResponse(fileBuffer as unknown as BodyInit);
response.headers.set('Content-Type', inferContentType(filename));
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
console.log('📸 Asset serving success:', {
filename,
size: fileBuffer.length,
contentType: inferContentType(filename),
project_id
});
return response;
} catch (error) {
console.error('[Assets Get] Failed:', error);
console.error('[Assets Get] Error details:', {
project_id,
filename,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
return NextResponse.json(
{
success: false,
error: 'Failed to load image',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/assets/[project_id]/logo/route.ts
```ts path="/app/api/assets/[project_id]/logo/route.ts"
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getProjectById } from '@/lib/services/project';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
const PROJECTS_DIR = process.env.PROJECTS_DIR || './data/projects';
const PROJECTS_DIR_ABSOLUTE = path.isAbsolute(PROJECTS_DIR)
? PROJECTS_DIR
: path.resolve(process.cwd(), PROJECTS_DIR);
export async function POST(request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const project = await getProjectById(project_id);
if (!project) {
return NextResponse.json({ success: false, error: 'Project not found' }, { status: 404 });
}
const body = await request.json();
const b64 = typeof body?.b64_png === 'string' ? body.b64_png : null;
if (!b64) {
return NextResponse.json({ success: false, error: 'b64_png is required' }, { status: 400 });
}
const buffer = Buffer.from(b64, 'base64');
const assetsPath = path.join(PROJECTS_DIR_ABSOLUTE, project_id, 'assets');
await fs.mkdir(assetsPath, { recursive: true });
const logoPath = path.join(assetsPath, 'logo.png');
await fs.writeFile(logoPath, buffer);
return NextResponse.json({ success: true, path: 'assets/logo.png' });
} catch (error) {
console.error('[Assets Logo] Failed:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to save logo',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/assets/[project_id]/upload/route.ts
```ts path="/app/api/assets/[project_id]/upload/route.ts"
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { randomUUID } from 'crypto';
import { getProjectById } from '@/lib/services/project';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
const PROJECTS_DIR = process.env.PROJECTS_DIR || './data/projects';
const PROJECTS_DIR_ABSOLUTE = path.isAbsolute(PROJECTS_DIR)
? PROJECTS_DIR
: path.resolve(process.cwd(), PROJECTS_DIR);
function resolveAssetsPath(projectId: string): string {
return path.join(PROJECTS_DIR_ABSOLUTE, projectId, 'assets');
}
export async function POST(request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const project = await getProjectById(project_id);
if (!project) {
return NextResponse.json({ success: false, error: 'Project not found' }, { status: 404 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ success: false, error: 'File field is required' }, { status: 400 });
}
if (!file.type.startsWith('image/')) {
return NextResponse.json({ success: false, error: 'File must be an image' }, { status: 400 });
}
const projectAssetsPath = resolveAssetsPath(project_id);
await fs.mkdir(projectAssetsPath, { recursive: true });
const originalName = file.name || 'image.png';
const extension = path.extname(originalName) || '.png';
const uniqueName = `${randomUUID()}${extension}`;
const absolutePath = path.join(projectAssetsPath, uniqueName);
const resolvedAbsolutePath = path.resolve(absolutePath);
const arrayBuffer = await file.arrayBuffer();
await fs.writeFile(resolvedAbsolutePath, Buffer.from(arrayBuffer));
let hostPublicPath: string | null = null;
let projectPublicPath: string | null = null;
let publicUrl: string | null = null;
try {
const rootUploadsDir = path.join(process.cwd(), 'public', 'uploads');
await fs.mkdir(rootUploadsDir, { recursive: true });
const hostDestination = path.join(rootUploadsDir, uniqueName);
try {
await fs.access(hostDestination);
} catch {
await fs.copyFile(resolvedAbsolutePath, hostDestination);
}
hostPublicPath = hostDestination;
publicUrl = `/uploads/${uniqueName}`;
} catch (copyError) {
console.warn('[Assets Upload] Failed to mirror asset into application public/uploads:', copyError);
}
try {
const projectRoot = project.repoPath
? (path.isAbsolute(project.repoPath) ? project.repoPath : path.resolve(process.cwd(), project.repoPath))
: path.join(PROJECTS_DIR_ABSOLUTE, project_id);
const uploadsDir = path.join(projectRoot, 'public', 'uploads');
await fs.mkdir(uploadsDir, { recursive: true });
projectPublicPath = path.join(uploadsDir, uniqueName);
try {
await fs.access(projectPublicPath);
} catch {
await fs.copyFile(resolvedAbsolutePath, projectPublicPath);
}
} catch (copyError) {
console.warn('[Assets Upload] Failed to mirror asset into project public/uploads:', copyError);
projectPublicPath = null;
if (!hostPublicPath) {
publicUrl = null;
}
}
return NextResponse.json({
success: true,
path: `assets/${uniqueName}`,
absolute_path: resolvedAbsolutePath,
filename: uniqueName,
original_filename: originalName,
public_path: hostPublicPath ?? projectPublicPath,
public_url: publicUrl,
});
} catch (error) {
console.error('[Assets Upload] Failed:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to upload image',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/chat/[project_id]/act/route.ts
```ts path="/app/api/chat/[project_id]/act/route.ts"
/**
* AI Action API Route
* POST /api/chat/[project_id]/act - Execute AI command
*/
import { NextRequest, NextResponse } from 'next/server';
import {
getProjectById,
updateProject,
updateProjectActivity,
} from '@/lib/services/project';
import { createMessage } from '@/lib/services/message';
import { initializeNextJsProject as initializeClaudeProject, applyChanges as applyClaudeChanges } from '@/lib/services/cli/claude';
import { initializeNextJsProject as initializeCodexProject, applyChanges as applyCodexChanges } from '@/lib/services/cli/codex';
import { initializeNextJsProject as initializeCursorProject, applyChanges as applyCursorChanges } from '@/lib/services/cli/cursor';
import { initializeNextJsProject as initializeQwenProject, applyChanges as applyQwenChanges } from '@/lib/services/cli/qwen';
import { initializeNextJsProject as initializeGLMProject, applyChanges as applyGLMChanges } from '@/lib/services/cli/glm';
import { getDefaultModelForCli, normalizeModelId } from '@/lib/constants/cliModels';
import { streamManager } from '@/lib/services/stream';
import type { ChatActRequest } from '@/types/backend';
import { generateProjectId } from '@/lib/utils';
import { previewManager } from '@/lib/services/preview';
import path from 'path';
import fs from 'fs/promises';
import { randomUUID } from 'crypto';
import { serializeMessage } from '@/lib/serializers/chat';
import {
upsertUserRequest,
markUserRequestAsProcessing,
} from '@/lib/services/user-requests';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
function coerceString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
const PROJECTS_DIR = process.env.PROJECTS_DIR || './data/projects';
const PROJECTS_DIR_ABSOLUTE = path.isAbsolute(PROJECTS_DIR)
? PROJECTS_DIR
: path.resolve(process.cwd(), PROJECTS_DIR);
function resolveAssetsPath(projectId: string): string {
return path.join(PROJECTS_DIR_ABSOLUTE, projectId, 'assets');
}
function ensureAbsoluteAssetPath(projectId: string, inputPath: string): string {
const normalized = path.normalize(inputPath);
if (path.isAbsolute(normalized)) {
return normalized;
}
const resolvedFromCwd = path.resolve(process.cwd(), normalized);
if (resolvedFromCwd.startsWith(PROJECTS_DIR_ABSOLUTE)) {
return resolvedFromCwd;
}
const projectBase = path.join(PROJECTS_DIR_ABSOLUTE, projectId);
return path.resolve(projectBase, normalized);
}
function resolveProjectRoot(projectId: string, repoPath?: string | null): string {
if (repoPath) {
return path.isAbsolute(repoPath) ? repoPath : path.resolve(process.cwd(), repoPath);
}
return path.join(PROJECTS_DIR_ABSOLUTE, projectId);
}
async function mirrorAssetToPublic(
projectRoot: string,
filename: string,
sourcePath: string,
): Promise<{ publicPath: string | null; publicUrl: string | null }> {
const resolvedSourcePath = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(process.cwd(), sourcePath);
const hostUploadsDir = path.join(process.cwd(), 'public', 'uploads');
let hostPublicPath: string | null = null;
try {
await fs.mkdir(hostUploadsDir, { recursive: true });
const destinationPath = path.join(hostUploadsDir, filename);
try {
await fs.access(destinationPath);
} catch {
await fs.copyFile(resolvedSourcePath, destinationPath);
}
hostPublicPath = destinationPath;
} catch (error) {
console.warn('[API] Failed to mirror asset into application public/uploads:', error);
}
try {
const uploadsDir = path.join(projectRoot, 'public', 'uploads');
await fs.mkdir(uploadsDir, { recursive: true });
const destinationPath = path.join(uploadsDir, filename);
try {
await fs.access(destinationPath);
} catch {
await fs.copyFile(resolvedSourcePath, destinationPath);
}
return {
publicPath: hostPublicPath ?? destinationPath,
publicUrl: hostPublicPath ? `/uploads/${filename}` : null,
};
} catch (error) {
console.warn('[API] Failed to mirror asset into project public/uploads:', error);
if (hostPublicPath) {
return { publicPath: hostPublicPath, publicUrl: `/uploads/${filename}` };
}
return { publicPath: null, publicUrl: null };
}
}
function inferExtensionFromMime(mime?: string): string {
if (!mime) return '.png';
const normalized = mime.toLowerCase();
if (normalized.includes('png')) return '.png';
if (normalized.includes('jpeg') || normalized.includes('jpg')) return '.jpg';
if (normalized.includes('gif')) return '.gif';
if (normalized.includes('webp')) return '.webp';
if (normalized.includes('svg')) return '.svg';
return '.png';
}
async function materializeBase64Image(
projectId: string,
projectRoot: string,
base64: string,
nameHint?: string,
mimeType?: string,
): Promise<{ absolutePath: string; filename: string; publicUrl: string | null }> {
const buffer = Buffer.from(base64, 'base64');
const extension = inferExtensionFromMime(mimeType);
const safeName = nameHint && nameHint.trim() ? nameHint.trim() : `image-${randomUUID()}`;
const filename = `${safeName.replace(/[^a-zA-Z0-9-_]/g, '-') || 'image'}-${randomUUID()}${extension}`;
const assetsDir = resolveAssetsPath(projectId);
await fs.mkdir(assetsDir, { recursive: true });
const absolutePath = path.join(assetsDir, filename);
await fs.writeFile(absolutePath, buffer);
const mirror = await mirrorAssetToPublic(projectRoot, filename, absolutePath);
return {
absolutePath,
filename,
publicUrl: mirror.publicUrl,
};
}
type RawImageAttachment = Record<string, unknown>;
async function normalizeImageAttachment(
projectId: string,
projectRoot: string,
raw: RawImageAttachment,
index: number,
): Promise<{ name: string; path: string; url: string; publicUrl?: string } | null> {
const name = typeof raw.name === 'string' && raw.name.trim().length > 0 ? raw.name.trim() : `Image ${index + 1}`;
const providedUrl = typeof raw.url === 'string' && raw.url.trim().length > 0 ? raw.url.trim() : undefined;
const providedPublicUrl =
typeof raw.public_url === 'string' && raw.public_url.trim().length > 0
? raw.public_url.trim()
: typeof raw.publicUrl === 'string' && raw.publicUrl.trim().length > 0
? raw.publicUrl.trim()
: undefined;
const pathValue =
typeof raw.path === 'string' && raw.path.trim().length > 0 ? ensureAbsoluteAssetPath(projectId, raw.path.trim()) : null;
const base64DataCandidate =
typeof raw.base64_data === 'string'
? raw.base64_data
: typeof raw.base64Data === 'string'
? raw.base64Data
: null;
const mimeTypeCandidate =
typeof raw.mime_type === 'string'
? raw.mime_type
: typeof raw.mimeType === 'string'
? raw.mimeType
: undefined;
if (pathValue) {
try {
await fs.stat(pathValue);
const filename = path.basename(pathValue);
let effectivePublicUrl = providedPublicUrl;
if (!effectivePublicUrl) {
const mirror = await mirrorAssetToPublic(projectRoot, filename, pathValue);
effectivePublicUrl = mirror.publicUrl ?? undefined;
}
return {
name,
path: pathValue,
url: providedUrl ?? `/api/assets/${projectId}/${filename}`,
publicUrl: effectivePublicUrl,
};
} catch {
// fall through and try to materialize if base64 present
}
}
if (base64DataCandidate) {
try {
const materialized = await materializeBase64Image(
projectId,
projectRoot,
base64DataCandidate,
name,
mimeTypeCandidate,
);
return {
name,
path: materialized.absolutePath,
url: providedUrl ?? `/api/assets/${projectId}/${materialized.filename}`,
publicUrl: providedPublicUrl ?? materialized.publicUrl ?? undefined,
};
} catch (error) {
console.error('[API] Failed to materialize base64 image:', error);
return null;
}
}
return null;
}
/**
* POST /api/chat/[project_id]/act
* Execute AI command
*/
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const rawBody = await request.json().catch(() => ({}));
const body = (rawBody && typeof rawBody === 'object' ? rawBody : {}) as ChatActRequest &
Record<string, unknown>;
const project = await getProjectById(project_id);
if (!project) {
return NextResponse.json(
{ success: false, error: 'Project not found' },
{ status: 404 },
);
}
const legacyBody = body as Record<string, unknown>;
const projectRoot = resolveProjectRoot(project_id, project.repoPath);
const rawInstruction = typeof body.instruction === 'string' ? body.instruction : '';
const instructionWithoutLegacyPaths = rawInstruction.replace(/\n*Image #\d+ path: [^\n]+/g, '').trim();
const rawImages: RawImageAttachment[] = Array.isArray((body as Record<string, unknown>).images)
? ((body as Record<string, unknown>).images as RawImageAttachment[])
: Array.isArray(legacyBody['images'])
? (legacyBody['images'] as RawImageAttachment[])
: [];
const processedImages: { name: string; path: string; url: string; publicUrl?: string }[] = [];
for (let index = 0; index < rawImages.length; index += 1) {
const normalized = await normalizeImageAttachment(project_id, projectRoot, rawImages[index], index);
if (normalized) {
processedImages.push(normalized);
}
}
const imageLines = processedImages.map((image, idx) => `Image #${idx + 1} path: ${image.path}`);
const finalInstruction = [instructionWithoutLegacyPaths, imageLines.join('\n')]
.filter((segment) => segment && segment.trim().length > 0)
.join('\n\n')
.trim();
if (!finalInstruction) {
return NextResponse.json(
{ success: false, error: 'instruction or images are required' },
{ status: 400 },
);
}
const cliPreferenceRaw =
coerceString((body as Record<string, unknown>).cliPreference) ??
coerceString(legacyBody['cli_preference']) ??
project.preferredCli ??
'claude';
const cliPreference = cliPreferenceRaw.toLowerCase();
const selectedModelRaw =
coerceString(body.selectedModel) ??
coerceString(legacyBody['selected_model']) ??
project.selectedModel ??
getDefaultModelForCli(cliPreference);
const selectedModel = normalizeModelId(cliPreference, selectedModelRaw);
const conversationId =
coerceString(body.conversationId) ?? coerceString(legacyBody['conversation_id']);
const requestId =
coerceString(body.requestId) ??
coerceString(legacyBody['request_id']) ??
generateProjectId();
const isInitialPrompt =
body.isInitialPrompt === true ||
legacyBody['is_initial_prompt'] === true ||
legacyBody['is_initial_prompt'] === 'true';
const metadata =
processedImages.length > 0
? {
attachments: processedImages.map((image) => ({
name: image.name,
url: image.url,
publicUrl: image.publicUrl,
path: image.path,
})),
}
: undefined;
console.log('📸 Creating message with attachments:', {
projectId: project_id,
hasAttachments: processedImages.length > 0,
attachmentsCount: processedImages.length,
metadataKeys: metadata ? Object.keys(metadata) : [],
metadataString: JSON.stringify(metadata, null, 2)
});
const userMessage = await createMessage({
projectId: project_id,
role: 'user',
messageType: 'chat',
content: finalInstruction,
conversationId: conversationId ?? undefined,
cliSource: cliPreference,
metadata,
requestId: requestId,
});
console.log('📸 Message created successfully:', {
messageId: userMessage.id,
hasMetadata: Boolean(metadata),
metadataType: metadata ? typeof metadata : 'undefined',
metadataKeys: metadata ? Object.keys(metadata) : [],
metadataString: metadata ? JSON.stringify(metadata, null, 2) : undefined,
metadataJsonLength: userMessage.metadataJson ? userMessage.metadataJson.length : 0,
});
if (requestId) {
try {
const storedInstruction =
rawInstruction && rawInstruction.trim().length > 0
? rawInstruction.trim()
: instructionWithoutLegacyPaths || finalInstruction;
await upsertUserRequest({
id: requestId,
projectId: project_id,
instruction: storedInstruction || finalInstruction,
cliPreference,
});
await markUserRequestAsProcessing(requestId);
} catch (error) {
console.error('[API] Failed to record user request metadata:', error);
}
}
streamManager.publish(project_id, {
type: 'message',
data: serializeMessage(userMessage, { requestId }),
});
await updateProjectActivity(project_id);
const projectPath = project.repoPath || path.join(process.cwd(), 'projects', project_id);
const existingSelected = normalizeModelId(project.preferredCli ?? 'claude', project.selectedModel ?? undefined);
if (
project.preferredCli !== cliPreference ||
existingSelected !== selectedModel
) {
try {
await updateProject(project_id, {
preferredCli: cliPreference,
selectedModel,
});
} catch (error) {
console.error('[API] Failed to persist project CLI/model settings:', error);
}
}
try {
const status = previewManager.getStatus(project_id);
if (!status.url) {
previewManager.start(project_id).catch((error) => {
console.warn('[API] Failed to auto-start preview (will continue):', error);
});
}
} catch (error) {
console.warn('[API] Preview auto-start check failed (will continue):', error);
}
if (isInitialPrompt) {
const executor =
cliPreference === 'codex'
? initializeCodexProject
: cliPreference === 'cursor'
? initializeCursorProject
: cliPreference === 'qwen'
? initializeQwenProject
: cliPreference === 'glm'
? initializeGLMProject
: initializeClaudeProject;
executor(
project_id,
projectPath,
finalInstruction,
selectedModel,
requestId,
).catch((error) => {
console.error('[API] Failed to initialize project:', error);
});
} else {
const executor =
cliPreference === 'codex'
? applyCodexChanges
: cliPreference === 'cursor'
? applyCursorChanges
: cliPreference === 'qwen'
? applyQwenChanges
: cliPreference === 'glm'
? applyGLMChanges
: applyClaudeChanges;
const sessionId =
cliPreference === 'claude'
? project.activeClaudeSessionId || undefined
: cliPreference === 'cursor'
? project.activeCursorSessionId || undefined
: undefined;
executor(
project_id,
projectPath,
finalInstruction,
selectedModel,
sessionId,
requestId,
).catch((error) => {
console.error('[API] Failed to execute AI:', error);
});
}
return NextResponse.json({
success: true,
message: 'AI execution started',
requestId,
userMessageId: userMessage.id,
conversationId: conversationId ?? null,
});
} catch (error) {
console.error('[API] Failed to execute AI:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to execute AI',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/chat/[project_id]/active-session/route.ts
```ts path="/app/api/chat/[project_id]/active-session/route.ts"
import { NextResponse } from 'next/server';
import { getActiveSession } from '@/lib/services/chat-sessions';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const session = await getActiveSession(project_id);
// Return 200 with null data when no session exists (successful query, no results)
// This prevents console 404 errors while still indicating no active session
if (!session) {
return NextResponse.json({ success: true, data: null });
}
return NextResponse.json({ success: true, data: session });
} catch (error) {
console.error('[API] Failed to get active session:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to get active session',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/chat/[project_id]/cli-preference/route.ts
```ts path="/app/api/chat/[project_id]/cli-preference/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import {
getProjectCliPreference,
updateProjectCliPreference,
} from '@/lib/services/project';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteContext) {
const { project_id } = await params;
const preference = await getProjectCliPreference(project_id);
if (!preference) {
return NextResponse.json(
{ success: false, error: 'Project not found' },
{ status: 404 },
);
}
return NextResponse.json({ success: true, data: preference });
}
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ success: false, error: 'Invalid payload' },
{ status: 400 },
);
}
const update = {
preferredCli:
typeof body.preferredCli === 'string'
? body.preferredCli
: typeof body.preferred_cli === 'string'
? body.preferred_cli
: undefined,
fallbackEnabled:
typeof body.fallbackEnabled === 'boolean'
? body.fallbackEnabled
: typeof body.fallback_enabled === 'boolean'
? body.fallback_enabled
: undefined,
selectedModel:
typeof body.selectedModel === 'string'
? body.selectedModel
: typeof body.selected_model === 'string'
? body.selected_model
: undefined,
};
const updated = await updateProjectCliPreference(project_id, update);
return NextResponse.json({ success: true, data: updated });
} catch (error) {
console.error('[API] Failed to update CLI preference:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to update CLI preference',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/chat/[project_id]/messages/route.ts
```ts path="/app/api/chat/[project_id]/messages/route.ts"
/**
* Messages API Route
* GET /api/chat/[project_id]/messages - Get message history
*/
import { NextRequest, NextResponse } from 'next/server';
import { getMessagesByProjectId, createMessage, deleteMessagesByProjectId, getMessagesCountByProjectId } from '@/lib/services/message';
import type { CreateMessageInput } from '@/types/backend';
import { serializeMessages, serializeMessage } from '@/lib/serializers/chat';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
/**
* GET /api/chat/[project_id]/messages
* Get project message history
*/
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
const [messages, totalCount] = await Promise.all([
getMessagesByProjectId(project_id, limit, offset),
getMessagesCountByProjectId(project_id),
]);
const serialized = serializeMessages(messages);
const res = NextResponse.json({
success: true,
data: serialized,
totalCount,
pagination: {
limit,
offset,
count: serialized.length,
hasMore: offset + serialized.length < totalCount,
},
});
res.headers.set('Cache-Control', 'no-store');
return res;
} catch (error) {
console.error('[API] Failed to get messages:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch messages',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
/**
* POST /api/chat/[project_id]/messages
* Create new message (for system/user logging)
*/
export async function POST(
request: NextRequest,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const payload = await request.json();
const content =
typeof payload.content === 'string' ? payload.content.trim() : '';
if (!content) {
return NextResponse.json(
{ success: false, error: 'content is required' },
{ status: 400 }
);
}
const role = typeof payload.role === 'string' ? payload.role : 'user';
const rawMessageType = typeof payload.message_type === 'string' ? payload.message_type.toLowerCase() : undefined;
const messageType: CreateMessageInput['messageType'] = ((): CreateMessageInput['messageType'] => {
switch (rawMessageType) {
case 'chat':
case 'tool_use':
case 'error':
case 'info':
return rawMessageType;
default:
return role === 'system' ? 'info' : 'chat';
}
})();
const conversationIdValue =
typeof payload.conversationId === 'string'
? payload.conversationId
: typeof payload.conversation_id === 'string'
? payload.conversation_id
: undefined;
const sessionIdValue =
typeof payload.sessionId === 'string'
? payload.sessionId
: typeof payload.session_id === 'string'
? payload.session_id
: undefined;
const cliSourceValue =
typeof payload.cliSource === 'string'
? payload.cliSource
: typeof payload.cli_source === 'string'
? payload.cli_source
: undefined;
const input: CreateMessageInput = {
projectId: project_id,
role,
messageType,
content,
conversationId: conversationIdValue,
sessionId: sessionIdValue,
cliSource: cliSourceValue,
};
const message = await createMessage(input);
const res = NextResponse.json({ success: true, data: serializeMessage(message) }, { status: 201 });
res.headers.set('Cache-Control', 'no-store');
return res;
} catch (error) {
console.error('[API] Failed to create message:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to create message',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
/**
* DELETE /api/chat/[project_id]/messages
* Delete all messages (optionally filter by conversation)
*/
export async function DELETE(
request: NextRequest,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const { searchParams } = new URL(request.url);
const conversationId =
searchParams.get('conversationId') ?? searchParams.get('conversation_id') ?? undefined;
const deleted = await deleteMessagesByProjectId(project_id, conversationId || undefined);
return NextResponse.json({
success: true,
deleted,
});
} catch (error) {
console.error('[API] Failed to delete messages:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to delete messages',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
// Force dynamic and Node runtime to avoid caching and ensure DB freshness
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/chat/[project_id]/requests/active/route.ts
```ts path="/app/api/chat/[project_id]/requests/active/route.ts"
import { NextResponse } from 'next/server';
import { getActiveRequests } from '@/lib/services/user-requests';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const summary = await getActiveRequests(project_id);
return NextResponse.json(summary);
} catch (error) {
console.error('[API] Failed to get active requests:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to get active requests',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/chat/[project_id]/sessions/[session_id]/status/route.ts
```ts path="/app/api/chat/[project_id]/sessions/[session_id]/status/route.ts"
import { NextResponse } from 'next/server';
import { getSessionById } from '@/lib/services/chat-sessions';
interface RouteContext {
params: Promise<{ project_id: string; session_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { project_id, session_id } = await params;
const session = await getSessionById(project_id, session_id);
if (!session) {
return NextResponse.json({ success: false, error: 'Session not found' }, { status: 404 });
}
return NextResponse.json({ success: true, data: session });
} catch (error) {
console.error('[API] Failed to get session status:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to get session status',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/chat/[project_id]/stream/route.ts
```ts path="/app/api/chat/[project_id]/stream/route.ts"
/**
* Server-Sent Events (SSE) Stream API
* GET /api/chat/[project_id]/stream - Real-time streaming
*/
import { NextRequest } from 'next/server';
import { streamManager } from '@/lib/services/stream';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
/**
* GET /api/chat/[project_id]/stream
* SSE streaming connection
*/
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
const { project_id } = await params;
// Create ReadableStream
const stream = new ReadableStream({
start(controller) {
console.log(`[SSE] Client connected to project: ${project_id}`);
// Add connection to StreamManager
streamManager.addStream(project_id, controller);
// Send connection confirmation message
const welcomeMessage = `data: ${JSON.stringify({
type: 'connected',
data: {
projectId: project_id,
timestamp: new Date().toISOString(),
transport: 'sse',
},
})}\n\n`;
try {
controller.enqueue(new TextEncoder().encode(welcomeMessage));
} catch (error) {
console.error('[SSE] Failed to send welcome message:', error);
}
// Heartbeat (every 30 seconds)
const heartbeatInterval = setInterval(() => {
try {
const heartbeat = `data: ${JSON.stringify({
type: 'heartbeat',
data: {
timestamp: new Date().toISOString(),
},
})}\n\n`;
controller.enqueue(new TextEncoder().encode(heartbeat));
} catch (error) {
console.error('[SSE] Failed to send heartbeat:', error);
clearInterval(heartbeatInterval);
}
}, 30000);
// Cleanup on connection close
request.signal.addEventListener('abort', () => {
console.log(`[SSE] Client disconnected from project: ${project_id}`);
clearInterval(heartbeatInterval);
streamManager.removeStream(project_id, controller);
});
},
cancel(controller) {
console.log(`[SSE] Stream cancelled for project: ${project_id}`);
streamManager.removeStream(project_id, controller);
},
});
// Return SSE response
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-store, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable Nginx buffering
},
});
}
// Ensure Node runtime + dynamic rendering for consistent in-memory streams
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/env/[project_id]/[key]/route.ts
```ts path="/app/api/env/[project_id]/[key]/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { updateEnvVar, deleteEnvVar } from '@/lib/services/env';
interface RouteContext {
params: Promise<{ project_id: string; key: string }>;
}
export async function PUT(request: NextRequest, { params }: RouteContext) {
try {
const { project_id, key } = await params;
const body = await request.json();
if (typeof body?.value !== 'string') {
return NextResponse.json(
{ success: false, error: 'value must be a string' },
{ status: 400 },
);
}
const updated = await updateEnvVar(project_id, key, body.value);
if (!updated) {
return NextResponse.json(
{ success: false, error: 'Environment variable not found' },
{ status: 404 },
);
}
return NextResponse.json({
success: true,
message: `Environment variable '${key}' updated`,
});
} catch (error) {
console.error('[Env API] Failed to update env var:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to update environment variable',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export async function DELETE(_request: NextRequest, { params }: RouteContext) {
try {
const { project_id, key } = await params;
const deleted = await deleteEnvVar(project_id, key);
if (!deleted) {
return NextResponse.json(
{ success: false, error: 'Environment variable not found' },
{ status: 404 },
);
}
return NextResponse.json({
success: true,
message: `Environment variable '${key}' deleted`,
});
} catch (error) {
console.error('[Env API] Failed to delete env var:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to delete environment variable',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/env/[project_id]/conflicts/route.ts
```ts path="/app/api/env/[project_id]/conflicts/route.ts"
import { NextResponse } from 'next/server';
import { detectEnvConflicts } from '@/lib/services/env';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const result = await detectEnvConflicts(project_id);
return NextResponse.json(result);
} catch (error) {
console.error('[Env API] Failed to check conflicts:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to check environment conflicts',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/env/[project_id]/route.ts
```ts path="/app/api/env/[project_id]/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { listEnvVars, createEnvVar } from '@/lib/services/env';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const envVars = await listEnvVars(project_id);
return NextResponse.json(envVars);
} catch (error) {
console.error('[Env API] Failed to fetch env vars:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch environment variables',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
if (!body?.key || typeof body.key !== 'string') {
return NextResponse.json(
{ success: false, error: 'key is required' },
{ status: 400 },
);
}
if (typeof body.value !== 'string') {
return NextResponse.json(
{ success: false, error: 'value must be a string' },
{ status: 400 },
);
}
const record = await createEnvVar(project_id, {
key: body.key,
value: body.value,
scope: body.scope,
varType: body.var_type ?? body.varType,
isSecret: body.is_secret ?? body.isSecret,
description: body.description,
});
return NextResponse.json({ success: true, data: record }, { status: 201 });
} catch (error) {
console.error('[Env API] Failed to create env var:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
const status = message.includes('already exists') ? 409 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to create environment variable',
message,
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/env/[project_id]/sync/db-to-file/route.ts
```ts path="/app/api/env/[project_id]/sync/db-to-file/route.ts"
import { NextResponse } from 'next/server';
import { syncDbToEnvFile } from '@/lib/services/env';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const synced = await syncDbToEnvFile(project_id);
return NextResponse.json({
success: true,
synced_count: synced,
message: `Synced ${synced} env vars from database to file`,
});
} catch (error) {
console.error('[Env API] Failed to sync DB to file:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to sync database to env file',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/env/[project_id]/sync/file-to-db/route.ts
```ts path="/app/api/env/[project_id]/sync/file-to-db/route.ts"
import { NextResponse } from 'next/server';
import { syncEnvFileToDb } from '@/lib/services/env';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const synced = await syncEnvFileToDb(project_id);
return NextResponse.json({
success: true,
synced_count: synced,
message: `Synced ${synced} env vars from file to database`,
});
} catch (error) {
console.error('[Env API] Failed to sync file to DB:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to sync env file to database',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/env/[project_id]/upsert/route.ts
```ts path="/app/api/env/[project_id]/upsert/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { upsertEnvVar } from '@/lib/services/env';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
if (!body?.key || typeof body.key !== 'string') {
return NextResponse.json(
{ success: false, error: 'key is required' },
{ status: 400 },
);
}
if (typeof body.value !== 'string') {
return NextResponse.json(
{ success: false, error: 'value must be a string' },
{ status: 400 },
);
}
const record = await upsertEnvVar(project_id, {
key: body.key,
value: body.value,
scope: body.scope,
varType: body.var_type ?? body.varType,
isSecret: body.is_secret ?? body.isSecret,
description: body.description,
});
return NextResponse.json({ success: true, data: record });
} catch (error) {
console.error('[Env API] Failed to upsert env var:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to upsert environment variable',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/github/check-repo/[repo_name]/route.ts
```ts path="/app/api/github/check-repo/[repo_name]/route.ts"
import { NextResponse } from 'next/server';
import { checkRepositoryAvailability } from '@/lib/services/github';
interface RouteContext {
params: Promise<{ repo_name: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { repo_name } = await params;
const result = await checkRepositoryAvailability(repo_name);
if (result.exists) {
return NextResponse.json({ available: false, username: result.username }, { status: 409 });
}
return NextResponse.json({ available: true, username: result.username });
} catch (error) {
console.error('[API] Failed to check repository availability:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to check repository availability',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/github/create-repo/route.ts
```ts path="/app/api/github/create-repo/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { createRepository, getGithubUser } from '@/lib/services/github';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (!body || typeof body !== 'object') {
return NextResponse.json({ success: false, error: 'Invalid payload' }, { status: 400 });
}
const repoName = typeof body.repo_name === 'string' ? body.repo_name : undefined;
if (!repoName) {
return NextResponse.json({ success: false, error: 'repo_name is required' }, { status: 400 });
}
const description = typeof body.description === 'string' ? body.description : '';
const isPrivate = typeof body.private === 'boolean' ? body.private : false;
const repo = await createRepository({
repoName,
description,
private: isPrivate,
});
const user = await getGithubUser();
return NextResponse.json({
success: true,
repo_url: repo.html_url,
html_url: repo.html_url,
clone_url: repo.clone_url,
default_branch: repo.default_branch,
owner: user.login,
});
} catch (error) {
console.error('[API] Failed to create GitHub repository:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to create GitHub repository',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/files/content/route.ts
```ts path="/app/api/projects/[project_id]/files/content/route.ts"
/**
* GET /api/projects/[id]/files/content - Get file content
*/
import { NextRequest, NextResponse } from 'next/server';
import {
readProjectFileContent,
writeProjectFileContent,
FileBrowserError,
} from '@/lib/services/file-browser';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const url = new URL(request.url);
const filePath = url.searchParams.get('path');
if (!filePath) {
return NextResponse.json(
{ success: false, error: 'path query parameter is required' },
{ status: 400 }
);
}
const file = await readProjectFileContent(project_id, filePath);
return NextResponse.json({
success: true,
data: file,
});
} catch (error) {
if (error instanceof FileBrowserError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status }
);
}
console.error('[API] Failed to read project file:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to read project file',
},
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
const filePath = body.path;
const content = body.content;
if (!filePath || typeof filePath !== 'string') {
return NextResponse.json(
{ success: false, error: 'path is required' },
{ status: 400 }
);
}
if (typeof content !== 'string') {
return NextResponse.json(
{ success: false, error: 'content must be a string' },
{ status: 400 }
);
}
await writeProjectFileContent(project_id, filePath, content);
return NextResponse.json({
success: true,
data: { path: filePath },
});
} catch (error) {
if (error instanceof FileBrowserError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status }
);
}
console.error('[API] Failed to write project file:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to write project file',
},
{ status: 500 }
);
}
}
```
## /app/api/projects/[project_id]/files/route.ts
```ts path="/app/api/projects/[project_id]/files/route.ts"
/**
* GET /api/projects/[id]/files - Get project directory list
*/
import { NextRequest, NextResponse } from 'next/server';
import { listProjectDirectory, FileBrowserError } from '@/lib/services/file-browser';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const url = new URL(request.url);
const dir = url.searchParams.get('path') ?? '.';
const entries = await listProjectDirectory(project_id, dir);
return NextResponse.json({
success: true,
data: {
entries,
},
});
} catch (error) {
if (error instanceof FileBrowserError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status }
);
}
console.error('[API] Failed to list project files:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to list project files',
},
{ status: 500 }
);
}
}
```
## /app/api/projects/[project_id]/github/connect/route.ts
```ts path="/app/api/projects/[project_id]/github/connect/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { connectProjectToGitHub } from '@/lib/services/github';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
if (!body || typeof body !== 'object') {
return NextResponse.json({ success: false, error: 'Invalid payload' }, { status: 400 });
}
const repoName = typeof body.repo_name === 'string' ? body.repo_name : undefined;
if (!repoName) {
return NextResponse.json({ success: false, error: 'repo_name is required' }, { status: 400 });
}
const description = typeof body.description === 'string' ? body.description : '';
const isPrivate = typeof body.private === 'boolean' ? body.private : false;
const result = await connectProjectToGitHub(project_id, {
repoName,
description,
private: isPrivate,
});
return NextResponse.json({
success: true,
repo_url: result.repo_url,
clone_url: result.clone_url,
default_branch: result.default_branch,
owner: result.owner,
message: 'GitHub repository created and connected',
});
} catch (error) {
console.error('[API] Failed to connect GitHub repository:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to connect GitHub repository',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/github/push/route.ts
```ts path="/app/api/projects/[project_id]/github/push/route.ts"
import { NextResponse } from 'next/server';
import { pushProjectToGitHub } from '@/lib/services/github';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
await pushProjectToGitHub(project_id);
return NextResponse.json({ success: true, message: 'Changes pushed to GitHub' });
} catch (error) {
console.error('[API] Failed to push to GitHub:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to push to GitHub',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/install-dependencies/route.ts
```ts path="/app/api/projects/[project_id]/install-dependencies/route.ts"
/**
* POST /api/projects/[project_id]/install-dependencies
* Run npm install (or equivalent) for a project workspace.
*/
import { NextResponse } from 'next/server';
import { previewManager } from '@/lib/services/preview';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(
_request: Request,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const result = await previewManager.installDependencies(project_id);
return NextResponse.json({
success: true,
logs: result.logs,
});
} catch (error) {
console.error('[API] Failed to install dependencies:', error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: 'Failed to install dependencies',
},
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/preview/start/route.ts
```ts path="/app/api/projects/[project_id]/preview/start/route.ts"
/**
* POST /api/projects/[id]/preview/start
* Launches the development server for a project and returns the preview URL.
*/
import { NextResponse } from 'next/server';
import { previewManager } from '@/lib/services/preview';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(
_request: Request,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const preview = await previewManager.start(project_id);
return NextResponse.json({
success: true,
data: preview,
});
} catch (error) {
console.error('[API] Failed to start preview:', error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : 'Failed to start preview',
},
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/preview/status/route.ts
```ts path="/app/api/projects/[project_id]/preview/status/route.ts"
/**
* GET /api/projects/[id]/preview/status
* Returns the current preview status for the project.
*/
import { NextResponse } from 'next/server';
import { previewManager } from '@/lib/services/preview';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(
_request: Request,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const preview = previewManager.getStatus(project_id);
return NextResponse.json({
success: true,
data: preview,
});
} catch (error) {
console.error('[API] Failed to fetch preview status:', error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: 'Failed to fetch preview status',
},
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/preview/stop/route.ts
```ts path="/app/api/projects/[project_id]/preview/stop/route.ts"
/**
* POST /api/projects/[id]/preview/stop
* Stops the development server for the project if it is running.
*/
import { NextResponse } from 'next/server';
import { previewManager } from '@/lib/services/preview';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(
_request: Request,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const preview = await previewManager.stop(project_id);
return NextResponse.json({
success: true,
data: preview,
});
} catch (error) {
console.error('[API] Failed to stop preview:', error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : 'Failed to stop preview',
},
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/route.ts
```ts path="/app/api/projects/[project_id]/route.ts"
/**
* Single Project API Routes
* GET /api/projects/[project_id] - Retrieve project
* PUT /api/projects/[project_id] - Update project
* DELETE /api/projects/[project_id] - Delete project
*/
import { NextRequest, NextResponse } from 'next/server';
import {
getProjectById,
updateProject,
deleteProject,
} from '@/lib/services/project';
import type { UpdateProjectInput } from '@/types/backend';
import { serializeProject } from '@/lib/serializers/project';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
/**
* GET /api/projects/[project_id]
* Retrieve specific project
*/
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const project = await getProjectById(project_id);
if (!project) {
return NextResponse.json(
{ success: false, error: 'Project not found' },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: serializeProject(project) });
} catch (error) {
console.error('[API] Failed to get project:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch project',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
/**
* PUT /api/projects/[project_id]
* Update project
*/
export async function PUT(
request: NextRequest,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
const body = await request.json();
const input: UpdateProjectInput = {
name: body.name,
description: body.description,
status: body.status,
previewUrl: body.previewUrl,
previewPort: body.previewPort,
preferredCli: body.preferredCli,
selectedModel: body.selectedModel,
settings: body.settings,
};
const project = await updateProject(project_id, input);
return NextResponse.json({ success: true, data: serializeProject(project) });
} catch (error) {
console.error('[API] Failed to update project:', error);
// Distinguish between different error types
if (error instanceof Error) {
if (error.message.includes('not found')) {
return NextResponse.json(
{ success: false, error: 'Project not found' },
{ status: 404 }
);
}
if (error.message.includes('validation') || error.message.includes('invalid')) {
return NextResponse.json(
{ success: false, error: 'Invalid input', message: error.message },
{ status: 400 }
);
}
}
return NextResponse.json(
{
success: false,
error: 'Failed to update project',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
/**
* DELETE /api/projects/[project_id]
* Delete project
*/
export async function DELETE(
request: NextRequest,
{ params }: RouteContext
) {
try {
const { project_id } = await params;
await deleteProject(project_id);
return NextResponse.json({
success: true,
message: 'Project deleted successfully',
});
} catch (error) {
console.error('[API] Failed to delete project:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to delete project',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/services/[service_id]/route.ts
```ts path="/app/api/projects/[project_id]/services/[service_id]/route.ts"
import { NextResponse } from 'next/server';
import { deleteProjectService } from '@/lib/services/project-services';
interface RouteContext {
params: Promise<{ project_id: string; service_id: string }>;
}
export async function DELETE(_request: Request, { params }: RouteContext) {
try {
const { service_id } = await params;
const deleted = await deleteProjectService(service_id);
if (!deleted) {
return NextResponse.json({ success: false, error: 'Service not found' }, { status: 404 });
}
return NextResponse.json({ success: true, message: 'Service disconnected' });
} catch (error) {
console.error('[API] Failed to delete project service:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to delete project service',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/services/route.ts
```ts path="/app/api/projects/[project_id]/services/route.ts"
import { NextResponse } from 'next/server';
import { listProjectServices } from '@/lib/services/project-services';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const services = await listProjectServices(project_id);
const payload = services.map((service) => ({
...service,
service_data: service.serviceData,
}));
return NextResponse.json(payload);
} catch (error) {
console.error('[API] Failed to load project services:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to load project services',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/supabase/connect/route.ts
```ts path="/app/api/projects/[project_id]/supabase/connect/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { connectExistingSupabase } from '@/lib/services/supabase';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
const supabaseProjectId =
typeof body?.project_id === 'string'
? body.project_id
: typeof body?.supabase_project_id === 'string'
? body.supabase_project_id
: undefined;
const projectUrl = typeof body?.project_url === 'string' ? body.project_url : undefined;
if (!supabaseProjectId || !projectUrl) {
return NextResponse.json(
{ success: false, error: 'project_id and project_url are required' },
{ status: 400 },
);
}
const result = await connectExistingSupabase(project_id, {
projectId: supabaseProjectId,
projectUrl,
projectName: typeof body?.project_name === 'string' ? body.project_name : undefined,
region: typeof body?.region === 'string' ? body.region : undefined,
});
return NextResponse.json({ success: true, data: result });
} catch (error) {
console.error('[API] Failed to connect Supabase project:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to connect Supabase project',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/vercel/connect/route.ts
```ts path="/app/api/projects/[project_id]/vercel/connect/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { connectVercelProject } from '@/lib/services/vercel';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
const projectName = typeof body?.project_name === 'string' ? body.project_name : undefined;
if (!projectName) {
return NextResponse.json({ success: false, error: 'project_name is required' }, { status: 400 });
}
const teamId =
typeof body?.team_id === 'string'
? body.team_id
: typeof body?.teamId === 'string'
? body.teamId
: undefined;
const result = await connectVercelProject(project_id, projectName, {
githubRepo: typeof body?.github_repo === 'string' ? body.github_repo : undefined,
teamId,
});
return NextResponse.json({
success: true,
data: result,
message: `Connected Vercel project ${projectName}`,
});
} catch (error) {
console.error('[API] Failed to connect Vercel project:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to connect Vercel project',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/vercel/deploy/route.ts
```ts path="/app/api/projects/[project_id]/vercel/deploy/route.ts"
import { NextResponse } from 'next/server';
import { triggerVercelDeployment } from '@/lib/services/vercel';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function POST(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const result = await triggerVercelDeployment(project_id);
return NextResponse.json({
success: true,
deployment_id: result.deploymentId ?? null,
deployment_url: result.deploymentUrl ?? null,
status: result.status ?? null,
});
} catch (error) {
console.error('[API] Failed to trigger Vercel deployment:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to trigger Vercel deployment',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/[project_id]/vercel/deployment/current/route.ts
```ts path="/app/api/projects/[project_id]/vercel/deployment/current/route.ts"
import { NextResponse } from 'next/server';
import { getCurrentDeploymentStatus } from '@/lib/services/vercel';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const status = await getCurrentDeploymentStatus(project_id);
return NextResponse.json(status);
} catch (error) {
console.error('[API] Failed to get deployment status:', error);
const statusCode = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to get deployment status',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: statusCode },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/projects/route.ts
```ts path="/app/api/projects/route.ts"
/**
* Projects API Routes
* GET /api/projects - Get all projects
* POST /api/projects - Create new project
*/
import { NextRequest } from 'next/server';
import { getAllProjects, createProject } from '@/lib/services/project';
import type { CreateProjectInput } from '@/types/backend';
import { serializeProjects, serializeProject } from '@/lib/serializers/project';
import { getDefaultModelForCli, normalizeModelId } from '@/lib/constants/cliModels';
import { createSuccessResponse, createErrorResponse, handleApiError } from '@/lib/utils/api-response';
/**
* GET /api/projects
* Get all projects list
*/
export async function GET() {
try {
const projects = await getAllProjects();
return createSuccessResponse(serializeProjects(projects));
} catch (error) {
return handleApiError(error, 'API', 'Failed to fetch projects');
}
}
/**
* POST /api/projects
* Create new project
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const preferredCli = String(body.preferredCli || body.preferred_cli || 'claude').toLowerCase();
const requestedModel = body.selectedModel || body.selected_model;
const input: CreateProjectInput = {
project_id: body.project_id,
name: body.name,
initialPrompt: body.initialPrompt || body.initial_prompt,
preferredCli,
selectedModel: normalizeModelId(preferredCli, requestedModel ?? getDefaultModelForCli(preferredCli)),
description: body.description,
};
// Validation
if (!input.project_id || !input.name) {
return createErrorResponse('project_id and name are required', undefined, 400);
}
const project = await createProject(input);
return createSuccessResponse(serializeProject(project), 201);
} catch (error) {
return handleApiError(error, 'API', 'Failed to create project');
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/repo/[project_id]/file/route.ts
```ts path="/app/api/repo/[project_id]/file/route.ts"
/**
* /api/repo/[project_id]/file
* Retrieve and update file content
*/
import { NextRequest, NextResponse } from 'next/server';
import {
readProjectFileContent,
writeProjectFileContent,
FileBrowserError,
} from '@/lib/services/file-browser';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const { searchParams } = new URL(request.url);
const path = searchParams.get('path');
if (!path) {
return NextResponse.json(
{ error: 'path query parameter is required' },
{ status: 400 }
);
}
const file = await readProjectFileContent(project_id, path);
const response = NextResponse.json(file);
response.headers.set('Cache-Control', 'no-store');
return response;
} catch (error) {
if (error instanceof FileBrowserError) {
return NextResponse.json(
{ error: error.message },
{ status: error.status }
);
}
console.error('[API] Failed to read file:', error);
return NextResponse.json(
{ error: 'Failed to read file' },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
const path = body?.path;
const content = body?.content;
if (!path || typeof path !== 'string') {
return NextResponse.json(
{ error: 'path is required' },
{ status: 400 }
);
}
if (typeof content !== 'string') {
return NextResponse.json(
{ error: 'content must be a string' },
{ status: 400 }
);
}
await writeProjectFileContent(project_id, path, content);
return NextResponse.json({ success: true, path });
} catch (error) {
if (error instanceof FileBrowserError) {
return NextResponse.json(
{ error: error.message },
{ status: error.status }
);
}
console.error('[API] Failed to write file:', error);
return NextResponse.json(
{ error: 'Failed to write file' },
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/repo/[project_id]/tree/route.ts
```ts path="/app/api/repo/[project_id]/tree/route.ts"
/**
* GET /api/repo/[project_id]/tree
* Retrieve project file tree
*/
import { NextRequest, NextResponse } from 'next/server';
import { listProjectDirectory, FileBrowserError } from '@/lib/services/file-browser';
interface RouteContext {
params: Promise<{ project_id: string }>;
}
export async function GET(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const { searchParams } = new URL(request.url);
const dir = searchParams.get('dir') ?? '.';
const entries = await listProjectDirectory(project_id, dir);
const payload = entries.map((entry) => ({
path: entry.path,
type: entry.type === 'directory' ? 'dir' : 'file',
size: entry.size ?? undefined,
hasChildren: Boolean(entry.hasChildren),
}));
const response = NextResponse.json(payload);
response.headers.set('Cache-Control', 'no-store');
return response;
} catch (error) {
if (error instanceof FileBrowserError) {
return NextResponse.json(
{ error: error.message },
{ status: error.status }
);
}
console.error('[API] Failed to list repo tree:', error);
return NextResponse.json(
{ error: 'Failed to load repository tree' },
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/settings/cli-status/route.ts
```ts path="/app/api/settings/cli-status/route.ts"
/**
* CLI Status API Route
* GET /api/settings/cli-status - Check CLI installation status
*/
import { NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { CLIStatus } from '@/types/backend';
import { CODEX_MODEL_DEFINITIONS } from '@/lib/constants/codexModels';
import { QWEN_MODEL_DEFINITIONS } from '@/lib/constants/qwenModels';
import { GLM_MODEL_DEFINITIONS } from '@/lib/constants/glmModels';
import { CURSOR_MODEL_DEFINITIONS } from '@/lib/constants/cursorModels';
const execAsync = promisify(exec);
/**
* Check Claude Code CLI installation
*/
async function checkClaudeCodeCLI(): Promise<{
installed: boolean;
version?: string;
error?: string;
}> {
try {
const { stdout } = await execAsync('claude --version');
const version = stdout.trim();
return {
installed: true,
version,
};
} catch (error) {
return {
installed: false,
error: error instanceof Error ? error.message : 'Failed to check CLI',
};
}
}
async function checkCodexCLI(): Promise<{
installed: boolean;
version?: string;
error?: string;
}> {
const executable = process.platform === 'win32' ? 'codex.cmd' : 'codex';
try {
const { stdout } = await execAsync(`${executable} --version`);
const version = stdout.trim();
return {
installed: true,
version: version || 'installed',
};
} catch (error) {
return {
installed: false,
error: error instanceof Error ? error.message : 'Failed to check Codex CLI',
};
}
}
async function checkCursorCLI(): Promise<{
installed: boolean;
version?: string;
error?: string;
}> {
const executable = process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent';
try {
const { stdout, stderr } = await execAsync(`${executable} --version`);
const output = `${stdout.trim()} ${stderr.trim()}`.trim();
const version = output.length > 0 ? output : 'installed';
return {
installed: true,
version,
};
} catch (error) {
return {
installed: false,
error: error instanceof Error ? error.message : 'Failed to check Cursor CLI',
};
}
}
async function checkQwenCLI(): Promise<{
installed: boolean;
version?: string;
error?: string;
}> {
const executable = process.platform === 'win32' ? 'qwen.cmd' : 'qwen';
try {
const { stdout } = await execAsync(`${executable} --version`);
const version = stdout.trim();
return {
installed: true,
version: version || 'installed',
};
} catch (error) {
return {
installed: false,
error: error instanceof Error ? error.message : 'Failed to check Qwen CLI',
};
}
}
/**
* GET /api/settings/cli-status
* Check CLI installation status
*/
export async function GET() {
try {
const status: CLIStatus = {
claude: {
installed: false,
checking: false,
},
cursor: {
installed: false,
checking: false,
},
codex: {
installed: false,
checking: false,
},
gemini: {
installed: false,
checking: false,
},
qwen: {
installed: false,
checking: false,
},
glm: {
installed: false,
checking: false,
},
};
// Check Claude Code CLI installation
const claudeStatus = await checkClaudeCodeCLI();
status.claude = {
installed: claudeStatus.installed,
version: claudeStatus.version,
checking: false,
error: claudeStatus.error,
};
const codexStatus = await checkCodexCLI();
status.codex = {
installed: codexStatus.installed,
version: codexStatus.version,
checking: false,
error: codexStatus.error,
models: CODEX_MODEL_DEFINITIONS.map(model => model.id),
};
const cursorStatus = await checkCursorCLI();
status.cursor = {
installed: cursorStatus.installed,
version: cursorStatus.version,
checking: false,
error: cursorStatus.error,
models: CURSOR_MODEL_DEFINITIONS.map((model) => model.id),
};
const qwenStatus = await checkQwenCLI();
status.qwen = {
installed: qwenStatus.installed,
version: qwenStatus.version,
checking: false,
error: qwenStatus.error,
models: QWEN_MODEL_DEFINITIONS.map((model) => model.id),
};
const glmStatus = claudeStatus;
status.glm = {
installed: glmStatus.installed,
version: glmStatus.version,
checking: false,
error: glmStatus.error,
models: GLM_MODEL_DEFINITIONS.map((model) => model.id),
};
return NextResponse.json(status);
} catch (error) {
console.error('[API] Failed to check CLI status:', error);
return NextResponse.json(
{
error: 'Failed to check CLI status',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/settings/global/route.ts
```ts path="/app/api/settings/global/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import {
loadGlobalSettings,
updateGlobalSettings,
normalizeCliSettings,
} from '@/lib/services/settings';
function serialize(settings: Awaited<ReturnType<typeof loadGlobalSettings>>) {
return {
...settings,
defaultCli: settings.default_cli,
cliSettings: settings.cli_settings,
};
}
export async function GET() {
const settings = await loadGlobalSettings();
return NextResponse.json(serialize(settings));
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const candidate = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const update: Record<string, unknown> = {};
const defaultCli = candidate.default_cli ?? candidate.defaultCli;
if (typeof defaultCli === 'string') {
update.default_cli = defaultCli;
}
const cliSettingsRaw = candidate.cli_settings ?? candidate.cliSettings;
const cliSettings = normalizeCliSettings(cliSettingsRaw as Record<string, unknown> | undefined);
if (cliSettings) {
update.cli_settings = cliSettings;
}
const nextSettings = await updateGlobalSettings(update);
return NextResponse.json(serialize(nextSettings));
} catch (error) {
console.error('[API] Failed to update global settings:', error);
return NextResponse.json(
{
error: 'Failed to update global settings',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/settings/route.ts
```ts path="/app/api/settings/route.ts"
export { GET, PUT } from './global/route';
```
## /app/api/supabase/create-project/route.ts
```ts path="/app/api/supabase/create-project/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import { createSupabaseProject } from '@/lib/services/supabase';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const projectId =
typeof body?.project_id === 'string'
? body.project_id
: typeof body?.projectId === 'string'
? body.projectId
: undefined;
const projectName = typeof body?.project_name === 'string' ? body.project_name : undefined;
const dbPass = typeof body?.db_pass === 'string' ? body.db_pass : undefined;
const organizationId =
typeof body?.organization_id === 'string'
? body.organization_id
: typeof body?.organizationId === 'string'
? body.organizationId
: undefined;
if (!projectId || !projectName || !dbPass || !organizationId) {
return NextResponse.json(
{ success: false, error: 'project_id, project_name, organization_id, and db_pass are required' },
{ status: 400 },
);
}
const region = typeof body?.region === 'string' ? body.region : 'us-east-1';
const result = await createSupabaseProject(projectId, projectName, {
dbPassword: dbPass,
region,
organizationId,
});
return NextResponse.json({
success: true,
project_id: result.id,
name: result.name,
organization_id: result.organization_id,
status: result.status,
region: result.region,
created_at: result.inserted_at ?? result.created_at ?? new Date().toISOString(),
});
} catch (error) {
console.error('[API] Failed to create Supabase project:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to create Supabase project',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/supabase/organizations/route.ts
```ts path="/app/api/supabase/organizations/route.ts"
import { NextResponse } from 'next/server';
import { listSupabaseOrganizations } from '@/lib/services/supabase';
export async function GET() {
try {
const organizations = await listSupabaseOrganizations();
return NextResponse.json({ success: true, organizations });
} catch (error) {
console.error('[API] Failed to list Supabase organizations:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Supabase organizations',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/supabase/projects/[supabase_project_id]/api-keys/route.ts
```ts path="/app/api/supabase/projects/[supabase_project_id]/api-keys/route.ts"
import { NextResponse } from 'next/server';
import { getSupabaseApiKeys } from '@/lib/services/supabase';
interface RouteContext {
params: Promise<{ supabase_project_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { supabase_project_id } = await params;
const keys = await getSupabaseApiKeys(supabase_project_id);
return NextResponse.json({ success: true, keys });
} catch (error) {
console.error('[API] Failed to fetch Supabase API keys:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Supabase API keys',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/supabase/projects/[supabase_project_id]/route.ts
```ts path="/app/api/supabase/projects/[supabase_project_id]/route.ts"
import { NextResponse } from 'next/server';
import { getSupabaseProject } from '@/lib/services/supabase';
interface RouteContext {
params: Promise<{ supabase_project_id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { supabase_project_id } = await params;
const project = await getSupabaseProject(supabase_project_id);
return NextResponse.json({ success: true, project });
} catch (error) {
console.error('[API] Failed to fetch Supabase project:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Supabase project',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/tokens/[...segments]/route.ts
```ts path="/app/api/tokens/[...segments]/route.ts"
import { NextRequest, NextResponse } from 'next/server';
import {
deleteServiceToken,
getPlainServiceToken,
getServiceToken,
touchServiceToken,
} from '@/lib/services/tokens';
interface RouteContext {
params: Promise<{ segments?: string[] }>;
}
function isProvider(value: string): boolean {
return value === 'github' || value === 'supabase' || value === 'vercel';
}
export async function GET(request: NextRequest, { params }: RouteContext) {
const { segments = [] } = await params;
if (segments.length === 1) {
const provider = segments[0];
if (!isProvider(provider)) {
return NextResponse.json(
{ success: false, error: 'Invalid provider' },
{ status: 400 },
);
}
const record = await getServiceToken(provider);
if (!record) {
return NextResponse.json({ success: false, error: 'Token not found' }, { status: 404 });
}
return NextResponse.json(record);
}
if (segments.length === 3 && segments[0] === 'internal' && segments[2] === 'token') {
const provider = segments[1];
if (!isProvider(provider)) {
return NextResponse.json(
{ success: false, error: 'Invalid provider' },
{ status: 400 },
);
}
const token = await getPlainServiceToken(provider);
if (!token) {
return NextResponse.json({ success: false, error: 'Token not found' }, { status: 404 });
}
await touchServiceToken(provider);
return NextResponse.json({ token });
}
return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 });
}
export async function DELETE(_request: NextRequest, { params }: RouteContext) {
const { segments = [] } = await params;
if (segments.length !== 1) {
return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 });
}
const tokenId = segments[0];
const deleted = await deleteServiceToken(tokenId);
if (!deleted) {
return NextResponse.json({ success: false, error: 'Token not found' }, { status: 404 });
}
return NextResponse.json({ success: true, message: 'Token deleted successfully' });
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/tokens/route.ts
```ts path="/app/api/tokens/route.ts"
import { NextRequest } from 'next/server';
import { createServiceToken } from '@/lib/services/tokens';
import { createSuccessResponse, handleApiError } from '@/lib/utils/api-response';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const provider = typeof body?.provider === 'string' ? body.provider : '';
const token = typeof body?.token === 'string' ? body.token : '';
const name = typeof body?.name === 'string' ? body.name : '';
const record = await createServiceToken(provider, token, name);
return createSuccessResponse(record, 201);
} catch (error) {
return handleApiError(error, 'Tokens API', 'Failed to save token');
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/api/vercel/check-project/[name]/route.ts
```ts path="/app/api/vercel/check-project/[name]/route.ts"
import { NextResponse } from 'next/server';
import { checkVercelProjectAvailability } from '@/lib/services/vercel';
interface RouteContext {
params: Promise<{ name: string }>;
}
export async function GET(request: Request, { params }: RouteContext) {
try {
const { name } = await params;
const url = new URL(request.url);
const teamId =
url.searchParams.get('teamId') ??
url.searchParams.get('team_id') ??
undefined;
const result = await checkVercelProjectAvailability(name, { teamId });
return NextResponse.json(result);
} catch (error) {
console.error('[API] Failed to check Vercel project availability:', error);
const status = error instanceof Error && 'status' in error ? (error as any).status ?? 500 : 500;
return NextResponse.json(
{
success: false,
error: 'Failed to check Vercel project availability',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status },
);
}
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
```
## /app/layout.tsx
```tsx path="/app/layout.tsx"
import './globals.css'
import 'highlight.js/styles/github-dark.css'
import GlobalSettingsProvider from '@/contexts/GlobalSettingsContext'
import { AuthProvider } from '@/contexts/AuthContext'
import Header from '@/components/layout/Header'
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Claudable',
description: 'Claudable Application',
icons: {
icon: '/Claudable_Icon.png',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body className="bg-gray-50 text-gray-900 min-h-screen">
<AuthProvider>
<GlobalSettingsProvider>
<Header />
<main>{children}</main>
</GlobalSettingsProvider>
</AuthProvider>
</body>
</html>
);
}
```
## /components/ErrorBoundary.tsx
```tsx path="/components/ErrorBoundary.tsx"
'use client';
import React, { Component, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* Error Boundary Component
* Catches errors in child components and displays a fallback UI
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center p-8 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-800">
<div className="text-4xl mb-4">⚠️</div>
<h2 className="text-xl font-semibold text-red-800 dark:text-red-200 mb-2">
Something went wrong
</h2>
<p className="text-sm text-red-600 dark:text-red-300 mb-4 text-center max-w-md">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm font-medium transition-colors"
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
/**
* Chat Error Boundary
* Specialized error boundary for chat components
*/
export function ChatErrorBoundary({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
fallback={
<div className="flex flex-col items-center justify-center h-full p-8 bg-blue-50 dark:bg-blue-900/10 rounded-lg">
<div className="text-4xl mb-4">🔄</div>
<h2 className="text-xl font-semibold text-blue-800 dark:text-blue-200 mb-2">
The connection is temporarily unstable
</h2>
<p className="text-sm text-blue-600 dark:text-blue-300 mb-6 text-center max-w-md">
We are experiencing a temporary issue with the chat connection. It will retry automatically in a few seconds, or you can click the button below to refresh now.
</p>
<div className="flex gap-3">
<button
onClick={() => {
// Attempt to reset state instead of relying on auto-refresh
window.location.reload();
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium transition-colors"
>
Refresh
</button>
<button
onClick={() => {
// Navigate back to the previous page
window.history.back();
}}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-md text-sm font-medium transition-colors"
>
Go Back
</button>
</div>
<p className="text-xs text-blue-500 dark:text-blue-400 mt-4 text-center">
If the issue continues, refresh the page or try again later.
</p>
</div>
}
onError={(error) => {
console.error('[ChatErrorBoundary] Error in chat component:', error);
// Provide a more user-friendly error message
console.log('💡 Tip: Check your network connection or refresh the page.');
}}
>
{children}
</ErrorBoundary>
);
}
```
## /contexts/AuthContext.tsx
```tsx path="/contexts/AuthContext.tsx"
"use client";
import { createContext, ReactNode } from 'react';
/**
* Simplified AuthContext for token-based authentication
* OAuth functionality has been removed since we migrated to token-based auth
*/
interface AuthContextType {
// Empty for now - only provides basic auth context structure
// Token management is handled directly by individual components
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
// Token-based auth only - OAuth functionality removed
// Authentication state is managed locally in components that need it
return (
<AuthContext.Provider value={{}}>
{children}
</AuthContext.Provider>
);
}
```
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.