From 7f0f336dede50982ec7b92a28c9533b6b0f3f49c Mon Sep 17 00:00:00 2001 From: newstory Date: Sat, 27 Jun 2026 22:09:53 +0200 Subject: [PATCH] feat: Opus 4.8, Agent Skills (+per-project toggle), Claude Design import, Nuxt generation, provider-aware Git, reliability & tests A broad set of capabilities on top of upstream, plus a hardening pass. Build & agent - Claude Opus 4.8 via the Agent SDK with adaptive thinking + an effort selector (Auto / Deep / Off) in the composer. - New projects scaffold a full multi-page Nuxt 4 + Nuxt UI app (layout with header/footer, shared nav, Home/Pricing/About/Contact pages, error page). - Provider-aware Git layer (GIT_PROVIDER = github | gitea) for repo create, push and reading CI deploy status; provider-agnostic "Git" wording in the UI. Agent Skills - Per-project + global skills in settings and a quick-access modal (checkboxes + live search) in the chat toolbar. - Hard per-project enable/disable: default auto-load; once any skill is disabled the project loads only the enabled set (project dirs + global symlinks), so a disabled skill is genuinely not loaded. Untouched projects unchanged. Import from Claude Design - Upload a claude.ai/design .zip; screens/fonts/assets are staged into a gitignored design-reference/ folder (noise filtered, wrapper dirs handled), then the agent gets an editable instruction to port them into the app's own components. UI - Resizable chat/preview split (draggable divider, width persisted; an overlay keeps the drag working over the cross-origin preview iframe). - Publish flow shows the real CI deploy run plus the last deployed commit + time. Reliability & security - Encrypt provider tokens at rest (legacy plaintext migrates on read). - Preview dev servers: idle eviction + LRU so the port pool can't be exhausted; stop leaking orphaned processes; keep warm across refresh; fix a tight auto-start retry loop; clear deploy poll intervals on unmount; no-store on the status endpoint. - Persist the encryption key; DB indexes + crash recovery; keep prerender failOnError disabled so deploys don't break on a placeholder link. Code quality & tests - vitest + 59 unit tests (path, chat serializer, design-import, skill-id, formatting, tool-metadata parsers). - Extractions/de-dup: pure skill-id module, agent system prompt file, formatting helpers, and the tool-use metadata parsers shared between CLI adapters and the chat log (removing a triplicated copy). --- .dockerignore | 13 + .env.example | 26 + Dockerfile | 39 + app/[project_id]/chat/page.tsx | 706 ++-- app/api/assets/[project_id]/logo/route.ts | 6 +- app/api/chat/[project_id]/act/route.ts | 48 +- .../chat/[project_id]/cli-preference/route.ts | 2 +- app/api/chat/[project_id]/messages/route.ts | 7 +- app/api/env/[project_id]/[key]/route.ts | 2 +- app/api/env/[project_id]/route.ts | 2 +- app/api/env/[project_id]/upsert/route.ts | 2 +- app/api/git/provider/route.ts | 20 + app/api/github/create-repo/route.ts | 2 +- .../[project_id]/deploy/status/route.ts | 33 + .../[project_id]/design-import/route.ts | 113 + .../[project_id]/files/content/route.ts | 2 +- .../[project_id]/github/connect/route.ts | 2 +- .../[project_id]/github/push/route.ts | 8 +- .../[project_id]/preview/status/route.ts | 7 +- app/api/projects/[project_id]/route.ts | 2 +- .../[project_id]/skills/[name]/route.ts | 63 + app/api/projects/[project_id]/skills/route.ts | 51 + .../[project_id]/supabase/connect/route.ts | 2 +- .../[project_id]/vercel/connect/route.ts | 2 +- app/api/projects/route.ts | 2 +- app/api/repo/[project_id]/file/route.ts | 2 +- app/api/settings/global/route.ts | 2 +- app/api/supabase/create-project/route.ts | 2 +- app/api/tokens/[...segments]/route.ts | 5 +- app/api/tokens/route.ts | 2 +- components/chat/ChatInput.tsx | 60 +- components/chat/ChatLog.tsx | 254 +- components/chat/DesignImportModal.tsx | 256 ++ components/chat/SkillsModal.tsx | 181 ++ components/modals/GitHubRepoModal.tsx | 4 +- components/settings/ProjectSettings.tsx | 17 +- components/settings/ServiceSettings.tsx | 6 +- components/settings/SkillsSettings.tsx | 314 ++ docker-compose.yml | 44 + hooks/useUserRequests.ts | 6 +- hooks/useWebSocket.ts | 14 +- instrumentation.ts | 15 + lib/constants/claudeModels.ts | 26 +- lib/crypto.ts | 39 +- lib/db/client.ts | 40 +- lib/serializers/client/chat.test.ts | 75 + lib/services/cli/claude.ts | 395 +-- .../cli/prompts/claude-system-prompt.ts | 40 + lib/services/cli/tool-metadata.test.ts | 91 + lib/services/cli/tool-metadata.ts | 261 ++ lib/services/design-import.test.ts | 66 + lib/services/design-import.ts | 254 ++ lib/services/git-provider.ts | 63 + lib/services/git.ts | 8 +- lib/services/github.ts | 188 +- lib/services/message.ts | 9 +- lib/services/preview.ts | 262 +- lib/services/project.ts | 20 +- lib/services/scaffold-deploy.ts | 240 ++ lib/services/skills-id.test.ts | 28 + lib/services/skills-id.ts | 34 + lib/services/skills.ts | 418 +++ lib/services/startup-recovery.ts | 46 + lib/services/tokens.ts | 32 +- lib/services/user-requests.ts | 13 + lib/utils/format.test.ts | 50 + lib/utils/format.ts | 99 + lib/utils/path.test.ts | 67 + lib/utils/scaffold.ts | 747 +++-- package-lock.json | 2886 ++++++++++++----- package.json | 6 +- prisma/schema.prisma | 6 +- stubs/react-icons-fa.tsx | 12 + tsconfig.json | 4 +- vitest.config.ts | 15 + 75 files changed, 6943 insertions(+), 1943 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 app/api/git/provider/route.ts create mode 100644 app/api/projects/[project_id]/deploy/status/route.ts create mode 100644 app/api/projects/[project_id]/design-import/route.ts create mode 100644 app/api/projects/[project_id]/skills/[name]/route.ts create mode 100644 app/api/projects/[project_id]/skills/route.ts create mode 100644 components/chat/DesignImportModal.tsx create mode 100644 components/chat/SkillsModal.tsx create mode 100644 components/settings/SkillsSettings.tsx create mode 100644 docker-compose.yml create mode 100644 instrumentation.ts create mode 100644 lib/serializers/client/chat.test.ts create mode 100644 lib/services/cli/prompts/claude-system-prompt.ts create mode 100644 lib/services/cli/tool-metadata.test.ts create mode 100644 lib/services/cli/tool-metadata.ts create mode 100644 lib/services/design-import.test.ts create mode 100644 lib/services/design-import.ts create mode 100644 lib/services/git-provider.ts create mode 100644 lib/services/scaffold-deploy.ts create mode 100644 lib/services/skills-id.test.ts create mode 100644 lib/services/skills-id.ts create mode 100644 lib/services/skills.ts create mode 100644 lib/services/startup-recovery.ts create mode 100644 lib/utils/format.test.ts create mode 100644 lib/utils/format.ts create mode 100644 lib/utils/path.test.ts create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..0925588b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.next +.git +data +dist +build +out +.turbo +.vercel +electron/dist +*.log +.env +.env.local diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..7ba4103e --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Claudable server config — copy to .env on your host (DO NOT COMMIT). + +# --- Agent auth: run claude -p with your Claude subscription (no API billing) --- +# Generate once with `claude setup-token` on a logged-in machine, paste here. +CLAUDE_CODE_OAUTH_TOKEN= + +# --- Git provider: 'github' (default) or 'gitea' (self-hosted) --- +GIT_PROVIDER=github +GIT_API_BASE_URL=https://git.example.com/api/v1 +GIT_HTTP_BASE=https://git.example.com +GIT_ORG=your-org +GIT_DEPLOY_DOMAIN=example.com +# Git API token (write:repository, write:organization). Used for repo create + push. +GIT_TOKEN= + +# --- App / public URL --- +DATABASE_URL=file:../data/cc.db +PROJECTS_DIR=./data/projects +WEB_PORT=3700 +# Public URL of this Claudable instance (behind your reverse proxy). +NEXT_PUBLIC_APP_URL=https://claudable.example.com +# Template for per-project preview URLs ({port} is substituted). +PREVIEW_URL_TEMPLATE=https://preview-{port}.example.com +# How your reverse proxy reaches host-network containers (host gateway IP, or +# host.docker.internal on Docker Desktop). +DEPLOY_HOST_GATEWAY=host.docker.internal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..463b3186 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Claudable — self-hosted AI web builder, running the agent via `claude -p` +# (Claude Code CLI / Agent SDK) with subscription auth (CLAUDE_CODE_OAUTH_TOKEN). +FROM node:22-bookworm-slim + +# Tooling the agent needs at runtime: git (push), ripgrep (claude search), ca-certs. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates ripgrep curl \ + && rm -rf /var/lib/apt/lists/* + +# Claude Code CLI on PATH so the Agent SDK can spawn `claude` headless. +RUN npm install -g @anthropic-ai/claude-code + +# Run as the non-root `node` user (uid 1000, matches the host volume owner) — Claude +# Code refuses --dangerously-skip-permissions as root. Building entirely as `node` +# (with COPY --chown) means files are created node-owned, so NO slow recursive chown. +WORKDIR /app +RUN chown node:node /app +USER node + +# Pre-create ~/.claude as node so a bind-mount at ~/.claude/skills doesn't make +# Docker create the parent as root (which blocks the agent writing session-env). +RUN mkdir -p /home/node/.claude + +# Install deps (cached on lockfile). --ignore-scripts skips electron/postinstall. +COPY --chown=node:node package*.json ./ +RUN npm ci --ignore-scripts +COPY --chown=node:node prisma ./prisma +RUN npx prisma generate + +# Build the Next.js app. +COPY --chown=node:node . . +RUN npm run build + +ENV NODE_ENV=production +ENV PORT=3700 +ENV WEB_PORT=3700 + +# Ensure the SQLite schema exists on the mounted volume, then start. +CMD ["sh", "-c", "npx prisma db push --skip-generate && npx next start -p ${WEB_PORT}"] diff --git a/app/[project_id]/chat/page.tsx b/app/[project_id]/chat/page.tsx index 28523312..102b06fe 100644 --- a/app/[project_id]/chat/page.tsx +++ b/app/[project_id]/chat/page.tsx @@ -4,12 +4,15 @@ 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 { 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, FaFileImport, FaPuzzlePiece } 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 DesignImportModal from '@/components/chat/DesignImportModal'; +import SkillsModal from '@/components/chat/SkillsModal'; +import { formatTimeAgo, getFileLanguage, escapeHtml } from '@/lib/utils/format'; import { ChatErrorBoundary } from '@/components/ErrorBoundary'; import { useUserRequests } from '@/hooks/useUserRequests'; import { useGlobalSettings } from '@/contexts/GlobalSettingsContext'; @@ -31,6 +34,7 @@ import { const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ''; +/** Human relative time, e.g. "just now", "5 minutes ago", "2 hours ago", "3 days ago". */ const assistantBrandColors = ACTIVE_CLI_BRAND_COLORS; const CLI_LABELS = ACTIVE_CLI_NAME_MAP; @@ -252,13 +256,69 @@ export default function ChatPage() { const [initialPromptSent, setInitialPromptSent] = useState(false); const initialPromptSentRef = useRef(false); const [showPublishPanel, setShowPublishPanel] = useState(false); + const [showDesignImport, setShowDesignImport] = useState(false); + const [showSkills, setShowSkills] = useState(false); + + // Resizable chat/preview split. Width of the left chat panel as a % of the + // window; drag the divider to resize, persisted to localStorage. + const CHAT_WIDTH_KEY = 'claudable:chatWidthPct'; + const CHAT_WIDTH_MIN = 20; + const CHAT_WIDTH_MAX = 70; + const [chatWidthPct, setChatWidthPct] = useState(30); + const [isResizing, setIsResizing] = useState(false); + const splitContainerRef = useRef(null); + const chatWidthRef = useRef(30); + + useEffect(() => { + const saved = Number(localStorage.getItem(CHAT_WIDTH_KEY)); + if (saved >= CHAT_WIDTH_MIN && saved <= CHAT_WIDTH_MAX) { + setChatWidthPct(saved); + chatWidthRef.current = saved; + } + }, []); + + const startChatResize = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + // A full-window overlay (rendered while isResizing) sits above the preview + // iframe so the cross-origin iframe can't swallow mousemove/mouseup — without + // it, dragging over the preview freezes and releasing never registers. + setIsResizing(true); + const onMove = (ev: MouseEvent) => { + const rect = splitContainerRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + const pct = ((ev.clientX - rect.left) / rect.width) * 100; + const clamped = Math.min(CHAT_WIDTH_MAX, Math.max(CHAT_WIDTH_MIN, pct)); + chatWidthRef.current = clamped; + setChatWidthPct(clamped); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + setIsResizing(false); + localStorage.setItem(CHAT_WIDTH_KEY, String(Math.round(chatWidthRef.current))); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, []); const [publishLoading, setPublishLoading] = useState(false); const [githubConnected, setGithubConnected] = useState(null); const [vercelConnected, setVercelConnected] = useState(null); + // Git provider config (server-driven). For the self-hosted Gitea flow, deploys + // happen via the Actions runner so Vercel is not required. + const [gitProvider, setGitProvider] = useState(null); + const [gitDeployDomain, setGitDeployDomain] = useState(null); + const [githubRepoName, setGithubRepoName] = useState(null); const [publishedUrl, setPublishedUrl] = useState(null); const [deploymentId, setDeploymentId] = useState(null); const [deploymentStatus, setDeploymentStatus] = useState<'idle' | 'deploying' | 'ready' | 'error'>('idle'); const deployPollRef = useRef(null); + // Set when an auto-start fails, to stop the effect from re-firing start() every + // ~2s (a tight retry loop that floods /preview/start). Cleared on success or + // when the user explicitly clicks the Play button. + const previewStartFailedRef = useRef(false); + // Real CI deploy run details (Gitea Actions) for the publish UI. + const [deployRun, setDeployRun] = useState<{ state: string; runNumber?: number; url?: string; title?: string; sha?: string; updatedAt?: string } | null>(null); + const giteaPollRef = useRef(null); const [isStartingPreview, setIsStartingPreview] = useState(false); const [previewInitializationMessage, setPreviewInitializationMessage] = useState('Starting development server...'); const [cliStatuses, setCliStatuses] = useState>({}); @@ -271,7 +331,7 @@ export default function ChatPage() { const [preferredCli, setPreferredCli] = useState(DEFAULT_ACTIVE_CLI); const [selectedModel, setSelectedModel] = useState(getDefaultModelForCli(DEFAULT_ACTIVE_CLI)); const [usingGlobalDefaults, setUsingGlobalDefaults] = useState(true); - const [thinkingMode, setThinkingMode] = useState(false); + const [thinkingMode, setThinkingMode] = useState<'off' | 'auto' | 'forced'>('auto'); const [isUpdatingModel, setIsUpdatingModel] = useState(false); const [currentRoute, setCurrentRoute] = useState('/'); const iframeRef = useRef(null); @@ -335,6 +395,7 @@ export default function ChatPage() { conversationId: conversationId || undefined, requestId, selectedModel, + thinkingMode, }; const r = await fetch(`${API_BASE}/api/chat/${projectId}/act`, { @@ -386,7 +447,7 @@ export default function ChatPage() { } finally { setIsRunning(false); } - }, [initialPromptSent, preferredCli, conversationId, projectId, selectedModel, createRequest]); + }, [initialPromptSent, preferredCli, conversationId, projectId, selectedModel, thinkingMode, createRequest]); // Guarded trigger that can be called from multiple places safely const triggerInitialPromptIfNeeded = useCallback(() => { @@ -604,6 +665,12 @@ const persistProjectPreferences = useCallback( // Check actual project connections (not just token existence) setGithubConnected(!!githubConnection); setVercelConnected(!!vercelConnection); + const ghData = githubConnection?.service_data as Record | undefined; + setGithubRepoName( + (ghData?.repo_name as string) || + (typeof ghData?.repo_url === 'string' ? ghData.repo_url.split('/').pop() : null) || + null, + ); // Set published URL only if actually deployed if (vercelConnection && vercelConnection.service_data) { @@ -637,11 +704,112 @@ const persistProjectPreferences = useCallback( } }, [projectId]); + // Load the server's git provider config once (drives Gitea-vs-GitHub publish UI). + useEffect(() => { + let cancelled = false; + fetch(`${API_BASE}/api/git/provider`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (cancelled || !d) return; + if (typeof d.provider === 'string') setGitProvider(d.provider); + if (typeof d.deployDomain === 'string') setGitDeployDomain(d.deployDomain); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const isGitea = gitProvider === 'gitea'; + + // In the Gitea flow the live URL is derived from the repo + deploy domain. + useEffect(() => { + if (isGitea && githubConnected && githubRepoName && gitDeployDomain) { + setPublishedUrl((prev) => prev || `https://${githubRepoName}.${gitDeployDomain}`); + } + }, [isGitea, githubConnected, githubRepoName, gitDeployDomain]); + + // Poll the REAL Gitea Actions deploy run (queued -> running -> success/failure) + // instead of guessing with a timer. Stops on a terminal state or timeout. + // Poll the REAL Gitea Actions deploy run. `baselineRun` is the latest run + // number BEFORE this publish — we only treat a run NEWER than it as "this + // deploy", otherwise the first poll reads the previous (already-finished) run + // and stops instantly (the "first click does nothing" bug). + const startGiteaDeployPolling = useCallback((baselineRun?: number | null) => { + if (giteaPollRef.current) { clearInterval(giteaPollRef.current); giteaPollRef.current = null; } + setDeploymentStatus('deploying'); + const startedAt = Date.now(); + const stop = () => { if (giteaPollRef.current) { clearInterval(giteaPollRef.current); giteaPollRef.current = null; } }; + const poll = async () => { + try { + const r = await fetch(`${API_BASE}/api/projects/${projectId}/deploy/status`, { cache: 'no-store' }); + if (r.ok) { + const d = await r.json(); + if (d?.found) { + const isNewRun = baselineRun == null + || (typeof d.runNumber === 'number' && d.runNumber > baselineRun); + if (!isNewRun) { + // The new run hasn't registered yet — keep showing "queued". + setDeployRun({ state: 'queued' }); + // If no new run appears within 40s, there was nothing to deploy + // (no changes) — the site is already live from the prior run. + if (Date.now() - startedAt > 40000) { + setDeploymentStatus('ready'); stop(); return; + } + } else { + setDeployRun({ state: d.state, runNumber: d.runNumber, url: d.url, title: d.title, sha: d.sha, updatedAt: d.updatedAt }); + if (d.state === 'success') { + if (d.liveUrl) setPublishedUrl(d.liveUrl); + setDeploymentStatus('ready'); stop(); return; + } + if (d.state === 'failure' || d.state === 'cancelled') { + setDeploymentStatus('error'); stop(); return; + } + } + } + } + } catch { + // transient; keep polling + } + // Safety timeout (~6 min) so it never spins forever. + if (Date.now() - startedAt > 6 * 60 * 1000) stop(); + }; + poll(); + giteaPollRef.current = setInterval(poll, 4000); + }, [projectId]); + + // When the Publish modal opens (Gitea flow), reflect the real current/last + // deploy run so the user always sees actual status — and resume polling if a + // run is still in progress. + useEffect(() => { + if (!showPublishPanel || !isGitea || !githubConnected) return; + let cancelled = false; + fetch(`${API_BASE}/api/projects/${projectId}/deploy/status`, { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (cancelled || !d?.found) return; + setDeployRun({ state: d.state, runNumber: d.runNumber, url: d.url, title: d.title, sha: d.sha, updatedAt: d.updatedAt }); + if (d.state === 'success') { + // Only record the live URL — do NOT mark 'ready'. Marking ready here + // would show "Published successfully" the moment the popup opens, + // before the user has clicked anything. A neutral "Currently live" + // block shows the URL instead. + if (d.liveUrl) setPublishedUrl(d.liveUrl); + } else if ((d.state === 'running' || d.state === 'queued') && !giteaPollRef.current) { + // A deploy is genuinely in progress right now — reflect it. + startGiteaDeployPolling(); + } + // failure/cancelled on open: leave idle so the user can just re-publish. + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [showPublishPanel, isGitea, githubConnected, projectId, startGiteaDeployPolling]); + const startDeploymentPolling = useCallback((depId: string) => { if (deployPollRef.current) clearInterval(deployPollRef.current); setDeploymentStatus('deploying'); setDeploymentId(depId); - + console.log('🔍 Monitoring deployment:', depId); deployPollRef.current = setInterval(async () => { @@ -765,36 +933,86 @@ const persistProjectPreferences = useCallback( const start = useCallback(async () => { try { + // Fast path: if the dev server is already running, show it immediately + // (no loading overlay, no artificial delay). + try { + const s = await fetch(`${API_BASE}/api/projects/${projectId}/preview/status`, { cache: 'no-store' }); + if (s.ok) { + const sp = (await s.json())?.data ?? {}; + if (sp.status === 'running' && typeof sp.url === 'string') { + setPreviewUrl(sp.url); + setIsStartingPreview(false); + previewStartFailedRef.current = false; + return; + } + } + } catch { + // fall through to a normal (cold) start + } + setIsStartingPreview(true); setPreviewInitializationMessage('Starting development server...'); - - // Simulate progress updates - setTimeout(() => setPreviewInitializationMessage('Installing dependencies...'), 1000); - setTimeout(() => setPreviewInitializationMessage('Building your application...'), 2500); - + + // Only relevant on a genuine cold start; cleared as soon as start returns. + const t1 = setTimeout(() => setPreviewInitializationMessage('Installing dependencies...'), 3000); + const t2 = setTimeout(() => setPreviewInitializationMessage('Building your application...'), 9000); + const r = await fetch(`${API_BASE}/api/projects/${projectId}/preview/start`, { method: 'POST' }); + clearTimeout(t1); + clearTimeout(t2); if (!r.ok) { console.error('Failed to start preview:', r.statusText); setPreviewInitializationMessage('Failed to start preview'); + // Don't let the auto-start effect immediately retry in a tight loop. + previewStartFailedRef.current = true; 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); + // Reveal the iframe as soon as the URL is available (no artificial wait). + setPreviewUrl(typeof data.url === 'string' ? data.url : null); + setIsStartingPreview(false); + previewStartFailedRef.current = false; + setCurrentRoute('/'); } catch (error) { console.error('Error starting preview:', error); setPreviewInitializationMessage('An error occurred'); + previewStartFailedRef.current = true; setTimeout(() => setIsStartingPreview(false), 2000); } }, [projectId]); + // The preview iframe is cross-origin, so it can't be read directly. It reports + // its current route to us via postMessage (injected claudable-preview plugin); + // keep the URL bar in sync with in-app navigation. + useEffect(() => { + if (!previewUrl) return; + let previewOrigin: string; + try { previewOrigin = new URL(previewUrl).origin; } catch { return; } + const onMessage = (event: MessageEvent) => { + if (event.origin !== previewOrigin) return; + const data = event.data as { source?: string; path?: string } | null; + if (data && data.source === 'claudable-preview' && typeof data.path === 'string') { + setCurrentRoute(data.path.startsWith('/') ? data.path : `/${data.path}`); + } + }; + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [previewUrl]); + + // Keep-warm heartbeat: while a preview is open, ping its status every few + // minutes so the server-side idle sweep doesn't evict an actively-viewed + // preview (the status read refreshes its lastAccessedAt). + useEffect(() => { + if (!previewUrl) return; + const id = setInterval(() => { + fetch(`${API_BASE}/api/projects/${projectId}/preview/status`, { cache: 'no-store' }).catch(() => {}); + }, 5 * 60 * 1000); + return () => clearInterval(id); + }, [previewUrl, projectId]); + // Navigate to specific route in iframe const navigateToRoute = (route: string) => { if (previewUrl && iframeRef.current) { @@ -1208,83 +1426,7 @@ const persistProjectPreferences = useCallback( }, [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, '''); - } + // getFileLanguage / escapeHtml now live in @/lib/utils/format (tested). // Get file icon based on type function getFileIcon(entry: Entry): React.ReactElement { @@ -1819,6 +1961,7 @@ const persistProjectPreferences = useCallback( conversationId: conversationId || undefined, requestId, selectedModel, + thinkingMode, }; console.log('📸 Sending request to act API:', { @@ -2069,7 +2212,7 @@ const persistProjectPreferences = useCallback( const previousActiveState = useRef(false); useEffect(() => { - if (!hasActiveRequests && !previewUrl && !isStartingPreview) { + if (!hasActiveRequests && !previewUrl && !isStartingPreview && !previewStartFailedRef.current) { if (!previousActiveState.current) { console.log('🔄 Preview not running; auto-starting'); } else { @@ -2078,6 +2221,12 @@ const persistProjectPreferences = useCallback( start(); } + // While the agent is running (it may be fixing whatever made the last start + // fail), clear the failure latch so auto-start retries once the run finishes + // (that idle transition re-runs this effect). + if (hasActiveRequests) { + previewStartFailedRef.current = false; + } previousActiveState.current = hasActiveRequests; }, [hasActiveRequests, previewUrl, isStartingPreview, start]); @@ -2126,18 +2275,23 @@ const persistProjectPreferences = useCallback( loadDeployStatusRef.current?.(); }; - const handleBeforeUnload = () => { - navigator.sendBeacon(`${API_BASE}/api/projects/${projectId}/preview/stop`); - }; - - window.addEventListener('beforeunload', handleBeforeUnload); + // NOTE: We intentionally do NOT stop the preview on `beforeunload`. That + // fires on every page refresh, and killing the dev server there forced a + // ~15-30s cold recompile on each reload (the fast-path could never reuse the + // running server). Leaving it running lets a refresh reattach instantly. The + // server is still stopped when the user navigates away from the project (the + // effect cleanup below), which bounds how many dev servers stay alive. window.addEventListener('services-updated', handleServicesUpdate); return () => { canceled = true; - window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('services-updated', handleServicesUpdate); + // Stop deploy/publish pollers so they don't keep hitting the API after the + // chat page unmounts (e.g. navigating back to the dashboard). + if (deployPollRef.current) { clearInterval(deployPollRef.current); deployPollRef.current = null; } + if (giteaPollRef.current) { clearInterval(giteaPollRef.current); giteaPollRef.current = null; } + const currentPreview = previewUrlRef.current; if (currentPreview) { fetch(`${API_BASE}/api/projects/${projectId}/preview/stop`, { method: 'POST' }).catch(() => {}); @@ -2235,12 +2389,17 @@ const persistProjectPreferences = useCallback( `} + {/* While resizing, this overlay sits above the preview iframe so it can't + capture the mouse — keeps the drag smooth in both directions and lets + mouseup register anywhere on screen. */} + {isResizing &&
} +
-
+
{/* Left: Chat window */}
{/* Chat header */}
@@ -2270,6 +2429,7 @@ const persistProjectPreferences = useCallback( { console.log('🔄 [HandlerSetup] ChatLog provided new handlers, updating references'); messageHandlersRef.current = handlers; @@ -2330,8 +2490,20 @@ const persistProjectPreferences = useCallback(
+ {/* Draggable divider to resize the chat / preview split */} +
+ {/* wider invisible hit area for easier grabbing */} +
+
+ {/* Right: Preview/Code area */} -
+
{/* Content area */}
{/* Controls Bar */} @@ -2407,7 +2579,25 @@ const persistProjectPreferences = useCallback( > - + + {/* Open preview in a new tab */} + + {/* Device Mode Toggle */}
- + + {/* Skills */} + + + {/* Import from Claude Design */} + + {/* Stop Button */} {showPreview && previewUrl && ( - {false && showPublishPanel && ( -
-

Publish Project

- - {/* Deployment Status Display */} - {deploymentStatus === 'deploying' && ( -
-
-
-

Deployment in progress...

-
-

Building and deploying your project. This may take a few minutes.

-
- )} - - {deploymentStatus === 'ready' && publishedUrl && ( -
-

Currently published at:

- - {publishedUrl} - -
- )} - - {deploymentStatus === 'error' && ( -
-

Deployment failed

-

There was an error during deployment. Please try again.

-
- )} - -
- {!githubConnected || !vercelConnected ? ( -
-

To publish, connect the following services:

-
- {!githubConnected && ( -
- - - - GitHub repository not connected -
- )} - {!vercelConnected && ( -
- - - - Vercel project not connected -
- )} -
-

- Go to - - to connect. -

-
- ) : null} - - -
-
- )}
)}
@@ -2862,7 +2913,7 @@ const persistProjectPreferences = useCallback( ) : ( <>
{ previewStartFailedRef.current = false; start(); } : undefined} className={`w-40 h-40 mx-auto mb-6 relative ${!isRunning && !isStartingPreview ? 'cursor-pointer group' : ''}`} > {/* Claudable Symbol with rotating animation when starting */} @@ -3108,6 +3159,19 @@ const persistProjectPreferences = useCallback( {/* Publish Modal */} + setShowDesignImport(false)} + onApply={(prompt) => { runAct(prompt); }} + /> + + setShowSkills(false)} + /> + {showPublishPanel && (
setShowPublishPanel(false)} /> @@ -3119,7 +3183,7 @@ const persistProjectPreferences = useCallback(

Publish Project

-

Deploy with Vercel, linked to your GitHub repo

+

{isGitea ? 'Pushes your code to Git — auto-deploys via CI' : 'Deploy with Vercel, linked to your GitHub repo'}

+
+ {deployRun?.state === 'success' && (deployRun?.title || deployRun?.updatedAt) && ( +

+ Last deployed{formatTimeAgo(deployRun.updatedAt) ? ` ${formatTimeAgo(deployRun.updatedAt)}` : ''} + {deployRun.title ? ` · ${deployRun.title}` : ''} + {deployRun.sha ? ` (${deployRun.sha})` : ''} + {deployRun.url ? <> · log : null} +

+ )} +

Click Update to deploy your latest changes.

)} @@ -3157,16 +3267,25 @@ const persistProjectPreferences = useCallback( {deploymentStatus === 'error' && (
-

Deployment failed. Please try again.

+

+ {deployRun?.state === 'cancelled' ? 'Deployment was cancelled.' : 'Deployment failed.'} +

+ {isGitea && deployRun?.url && ( +

+ + View the failed build log{deployRun.runNumber ? ` (run #${deployRun.runNumber})` : ''} → + +

+ )}
)} - {!githubConnected || !vercelConnected ? ( + {!githubConnected || (!isGitea && !vercelConnected) ? (

Connect the following services:

- {!githubConnected && (
GitHub repository not connected
)} - {!vercelConnected && (
Vercel project not connected
)} + {!githubConnected && (
Git repository not connected
)} + {!isGitea && !vercelConnected && (
Vercel project not connected
)}
diff --git a/app/api/assets/[project_id]/logo/route.ts b/app/api/assets/[project_id]/logo/route.ts index fda82663..aea8bd24 100644 --- a/app/api/assets/[project_id]/logo/route.ts +++ b/app/api/assets/[project_id]/logo/route.ts @@ -20,11 +20,15 @@ export async function POST(request: Request, { params }: RouteContext) { return NextResponse.json({ success: false, error: 'Project not found' }, { status: 404 }); } - const body = await request.json(); + const body = await request.json().catch(() => null); 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 }); } + // Cap the payload (~6MB base64 ≈ 4.5MB binary) to avoid memory/disk abuse. + if (b64.length > 6 * 1024 * 1024) { + return NextResponse.json({ success: false, error: 'Logo too large (max ~4.5MB)' }, { status: 413 }); + } const buffer = Buffer.from(b64, 'base64'); const assetsPath = path.join(PROJECTS_DIR_ABSOLUTE, project_id, 'assets'); diff --git a/app/api/chat/[project_id]/act/route.ts b/app/api/chat/[project_id]/act/route.ts index c5395c04..a51259d9 100644 --- a/app/api/chat/[project_id]/act/route.ts +++ b/app/api/chat/[project_id]/act/route.ts @@ -27,6 +27,7 @@ import { serializeMessage } from '@/lib/serializers/chat'; import { upsertUserRequest, markUserRequestAsProcessing, + markUserRequestAsFailed, } from '@/lib/services/user-requests'; interface RouteContext { @@ -49,16 +50,22 @@ function resolveAssetsPath(projectId: string): string { } function ensureAbsoluteAssetPath(projectId: string, inputPath: string): string { + const projectBase = path.join(PROJECTS_DIR_ABSOLUTE, projectId); 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; + // Absolute paths are kept as-is; relative paths resolve under the project. + const candidate = path.isAbsolute(normalized) + ? normalized + : path.resolve(projectBase, normalized); + // Security: the path MUST stay inside the projects directory. Without this an + // attacker could pass an absolute path like "/etc/passwd" (or "../../..") and + // the app would stat/copy arbitrary files. + if ( + candidate !== PROJECTS_DIR_ABSOLUTE && + !candidate.startsWith(PROJECTS_DIR_ABSOLUTE + path.sep) + ) { + throw new Error('Asset path is outside the projects directory'); } - const projectBase = path.join(PROJECTS_DIR_ABSOLUTE, projectId); - return path.resolve(projectBase, normalized); + return candidate; } function resolveProjectRoot(projectId: string, repoPath?: string | null): string { @@ -289,6 +296,13 @@ export async function POST(request: NextRequest, { params }: RouteContext) { getDefaultModelForCli(cliPreference); const selectedModel = normalizeModelId(cliPreference, selectedModelRaw); + const thinkingModeRaw = + coerceString(body.thinkingMode) ?? coerceString(legacyBody['thinking_mode']); + const thinkingMode: 'off' | 'auto' | 'forced' = + thinkingModeRaw === 'off' || thinkingModeRaw === 'forced' + ? thinkingModeRaw + : 'auto'; + const conversationId = coerceString(body.conversationId) ?? coerceString(legacyBody['conversation_id']); @@ -437,15 +451,29 @@ export async function POST(request: NextRequest, { params }: RouteContext) { ? project.activeCursorSessionId || undefined : undefined; - executor( + // thinkingMode is only consumed by the Claude executor; other CLIs + // ignore the extra argument. Cast to avoid a union-arity type error. + (executor as (...args: unknown[]) => Promise)( project_id, projectPath, finalInstruction, selectedModel, sessionId, requestId, - ).catch((error) => { + cliPreference === 'claude' ? thinkingMode : undefined, + ).catch(async (error) => { console.error('[API] Failed to execute AI:', error); + // If the executor rejected outright, its own finally never marked the + // request terminal — do it here so the row can't get stuck in an + // active status and permanently lock the project. + if (requestId) { + await markUserRequestAsFailed( + requestId, + error instanceof Error ? error.message : 'AI execution failed', + ).catch((markError) => { + console.error('[API] Failed to mark request failed:', markError); + }); + } }); } diff --git a/app/api/chat/[project_id]/cli-preference/route.ts b/app/api/chat/[project_id]/cli-preference/route.ts index 8fe53e8a..2972ef1a 100644 --- a/app/api/chat/[project_id]/cli-preference/route.ts +++ b/app/api/chat/[project_id]/cli-preference/route.ts @@ -24,7 +24,7 @@ export async function GET(_request: NextRequest, { params }: RouteContext) { export async function POST(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; if (!body || typeof body !== 'object') { return NextResponse.json( { success: false, error: 'Invalid payload' }, diff --git a/app/api/chat/[project_id]/messages/route.ts b/app/api/chat/[project_id]/messages/route.ts index 4fb32fc9..e0be116e 100644 --- a/app/api/chat/[project_id]/messages/route.ts +++ b/app/api/chat/[project_id]/messages/route.ts @@ -23,8 +23,11 @@ export async function GET( 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'); + // Clamp pagination so a NaN / negative / huge value can't break the query. + const rawLimit = parseInt(searchParams.get('limit') || '50', 10); + const rawOffset = parseInt(searchParams.get('offset') || '0', 10); + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 500) : 50; + const offset = Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : 0; const [messages, totalCount] = await Promise.all([ getMessagesByProjectId(project_id, limit, offset), diff --git a/app/api/env/[project_id]/[key]/route.ts b/app/api/env/[project_id]/[key]/route.ts index c0bf138c..816aeacd 100644 --- a/app/api/env/[project_id]/[key]/route.ts +++ b/app/api/env/[project_id]/[key]/route.ts @@ -8,7 +8,7 @@ interface RouteContext { export async function PUT(request: NextRequest, { params }: RouteContext) { try { const { project_id, key } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; if (typeof body?.value !== 'string') { return NextResponse.json( { success: false, error: 'value must be a string' }, diff --git a/app/api/env/[project_id]/route.ts b/app/api/env/[project_id]/route.ts index 6a5e04ec..edc68766 100644 --- a/app/api/env/[project_id]/route.ts +++ b/app/api/env/[project_id]/route.ts @@ -26,7 +26,7 @@ export async function GET(_request: NextRequest, { params }: RouteContext) { export async function POST(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; if (!body?.key || typeof body.key !== 'string') { return NextResponse.json( { success: false, error: 'key is required' }, diff --git a/app/api/env/[project_id]/upsert/route.ts b/app/api/env/[project_id]/upsert/route.ts index 952394bf..cbb5d002 100644 --- a/app/api/env/[project_id]/upsert/route.ts +++ b/app/api/env/[project_id]/upsert/route.ts @@ -8,7 +8,7 @@ interface RouteContext { export async function POST(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; if (!body?.key || typeof body.key !== 'string') { return NextResponse.json( { success: false, error: 'key is required' }, diff --git a/app/api/git/provider/route.ts b/app/api/git/provider/route.ts new file mode 100644 index 00000000..c26bc211 --- /dev/null +++ b/app/api/git/provider/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { getGitProviderConfig } from '@/lib/services/git-provider'; + +/** + * Exposes the server's git provider configuration to the client so the Publish + * UI can adapt (e.g. the self-hosted Gitea flow deploys via the Actions runner + * and does not need Vercel). + */ +export async function GET() { + const cfg = getGitProviderConfig(); + return NextResponse.json({ + success: true, + provider: cfg.provider, + deployDomain: cfg.deployDomain, + org: cfg.org, + }); +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; diff --git a/app/api/github/create-repo/route.ts b/app/api/github/create-repo/route.ts index f3e2d827..ff535b09 100644 --- a/app/api/github/create-repo/route.ts +++ b/app/api/github/create-repo/route.ts @@ -3,7 +3,7 @@ import { createRepository, getGithubUser } from '@/lib/services/github'; export async function POST(request: NextRequest) { try { - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; if (!body || typeof body !== 'object') { return NextResponse.json({ success: false, error: 'Invalid payload' }, { status: 400 }); } diff --git a/app/api/projects/[project_id]/deploy/status/route.ts b/app/api/projects/[project_id]/deploy/status/route.ts new file mode 100644 index 00000000..80e8c261 --- /dev/null +++ b/app/api/projects/[project_id]/deploy/status/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { getDeployRunStatus } from '@/lib/services/github'; + +interface RouteContext { + params: Promise<{ project_id: string }>; +} + +/** + * Real deployment status for the self-hosted (Gitea Actions) publish flow: + * the latest CI run's state (queued/running/success/failure), a link to the + * run log, and the live URL. Polled by the Publish UI. + */ +export async function GET(_request: Request, { params }: RouteContext) { + try { + const { project_id } = await params; + const status = await getDeployRunStatus(project_id); + const res = NextResponse.json({ success: true, ...status }); + res.headers.set('Cache-Control', 'no-store'); + return res; + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Failed to get deploy status', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ); + } +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; diff --git a/app/api/projects/[project_id]/design-import/route.ts b/app/api/projects/[project_id]/design-import/route.ts new file mode 100644 index 00000000..e81f2a9b --- /dev/null +++ b/app/api/projects/[project_id]/design-import/route.ts @@ -0,0 +1,113 @@ +/** + * POST /api/projects/[id]/design-import + * Accepts a Claude Design (claude.ai/design) zip export as multipart form-data + * (field `file`), stages the useful design files into the project's + * `design-reference/` folder, and returns a manifest plus a ready-to-edit + * instruction the user can send to the agent to port the design. + */ + +import { NextResponse } from 'next/server'; +import { getProjectById } from '@/lib/services/project'; +import { extractDesignImport, buildPortPrompt } from '@/lib/services/design-import'; + +interface RouteContext { + params: Promise<{ project_id: string }>; +} + +// Claude Design exports can be large (assets + fonts), but we only keep a small +// subset. Cap the upload to protect the server from absurd payloads. +const MAX_UPLOAD_BYTES = 600 * 1024 * 1024; // 600 MB + +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 }); + } + if (!project.repoPath) { + return NextResponse.json( + { success: false, error: 'Project has no workspace directory' }, + { status: 400 } + ); + } + + let form: FormData; + try { + form = await request.formData(); + } catch { + return NextResponse.json( + { success: false, error: 'Expected multipart form-data with a "file" field' }, + { status: 400 } + ); + } + + const file = form.get('file'); + if (!file || typeof file === 'string') { + return NextResponse.json( + { success: false, error: 'No file uploaded (field "file")' }, + { status: 400 } + ); + } + + const blob = file as File; + const name = (blob.name || '').toLowerCase(); + const isZip = + name.endsWith('.zip') || + blob.type === 'application/zip' || + blob.type === 'application/x-zip-compressed'; + if (!isZip) { + return NextResponse.json( + { success: false, error: 'Please upload a .zip export from Claude Design' }, + { status: 400 } + ); + } + if (blob.size > MAX_UPLOAD_BYTES) { + return NextResponse.json( + { + success: false, + error: `Zip is too large (${(blob.size / 1024 / 1024).toFixed(0)} MB). Limit is ${MAX_UPLOAD_BYTES / 1024 / 1024} MB.`, + }, + { status: 413 } + ); + } + + const bytes = new Uint8Array(await blob.arrayBuffer()); + + let manifest; + try { + manifest = await extractDesignImport(bytes, project.repoPath); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to extract the design export', + }, + { status: 422 } + ); + } + + return NextResponse.json({ + success: true, + data: { + manifest, + suggestedPrompt: buildPortPrompt(manifest), + }, + }); + } catch (error) { + console.error('[API] design-import failed:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Design import failed', + }, + { status: 500 } + ); + } +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +// Allow large multipart bodies (the kept subset is small, but the raw zip can be big). +export const maxDuration = 120; diff --git a/app/api/projects/[project_id]/files/content/route.ts b/app/api/projects/[project_id]/files/content/route.ts index c9d36140..1798ace3 100644 --- a/app/api/projects/[project_id]/files/content/route.ts +++ b/app/api/projects/[project_id]/files/content/route.ts @@ -54,7 +54,7 @@ export async function GET(request: NextRequest, { params }: RouteContext) { export async function PUT(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const filePath = body.path; const content = body.content; diff --git a/app/api/projects/[project_id]/github/connect/route.ts b/app/api/projects/[project_id]/github/connect/route.ts index 56556250..c5705d58 100644 --- a/app/api/projects/[project_id]/github/connect/route.ts +++ b/app/api/projects/[project_id]/github/connect/route.ts @@ -8,7 +8,7 @@ interface RouteContext { export async function POST(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; if (!body || typeof body !== 'object') { return NextResponse.json({ success: false, error: 'Invalid payload' }, { status: 400 }); } diff --git a/app/api/projects/[project_id]/github/push/route.ts b/app/api/projects/[project_id]/github/push/route.ts index 0858a12a..7ee95290 100644 --- a/app/api/projects/[project_id]/github/push/route.ts +++ b/app/api/projects/[project_id]/github/push/route.ts @@ -8,8 +8,12 @@ interface RouteContext { 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' }); + const pushed = await pushProjectToGitHub(project_id); + return NextResponse.json({ + success: true, + pushed, + message: pushed ? 'Changes pushed' : 'Already up to date — no changes to deploy', + }); } 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; diff --git a/app/api/projects/[project_id]/preview/status/route.ts b/app/api/projects/[project_id]/preview/status/route.ts index 8e54b56a..5a942217 100644 --- a/app/api/projects/[project_id]/preview/status/route.ts +++ b/app/api/projects/[project_id]/preview/status/route.ts @@ -18,10 +18,15 @@ export async function GET( const { project_id } = await params; const preview = previewManager.getStatus(project_id); - return NextResponse.json({ + const res = NextResponse.json({ success: true, data: preview, }); + // Status is live and changes constantly; never let the browser cache it + // (a stale 'starting'/'stopped' makes the UI cold-start an already-running + // preview). + res.headers.set('Cache-Control', 'no-store'); + return res; } catch (error) { console.error('[API] Failed to fetch preview status:', error); return NextResponse.json( diff --git a/app/api/projects/[project_id]/route.ts b/app/api/projects/[project_id]/route.ts index 59c0c612..85ad36de 100644 --- a/app/api/projects/[project_id]/route.ts +++ b/app/api/projects/[project_id]/route.ts @@ -61,7 +61,7 @@ export async function PUT( ) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const input: UpdateProjectInput = { name: body.name, diff --git a/app/api/projects/[project_id]/skills/[name]/route.ts b/app/api/projects/[project_id]/skills/[name]/route.ts new file mode 100644 index 00000000..f8182c16 --- /dev/null +++ b/app/api/projects/[project_id]/skills/[name]/route.ts @@ -0,0 +1,63 @@ +/** + * Single skill API + * GET /api/projects/[project_id]/skills/[name] - read a skill + * DELETE /api/projects/[project_id]/skills/[name] - delete a skill + */ + +import { NextRequest } from 'next/server'; +import { getSkill, deleteSkill, setSkillEnabled, SkillError } from '@/lib/services/skills'; +import { createSuccessResponse, createErrorResponse, handleApiError } from '@/lib/utils/api-response'; + +interface RouteContext { + params: Promise<{ project_id: string; name: string }>; +} + +// PATCH /api/projects/[project_id]/skills/[name] - enable/disable a skill for this project +export async function PATCH(request: NextRequest, { params }: RouteContext) { + try { + const { project_id, name } = await params; + const body = await request.json().catch(() => ({})); + if (typeof body?.enabled !== 'boolean') { + return createErrorResponse('enabled (boolean) is required', undefined, 400); + } + const skills = await setSkillEnabled(project_id, name, body.enabled); + return createSuccessResponse({ id: name, enabled: body.enabled, skills }); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to update skill'); + } +} + +export async function GET(_request: NextRequest, { params }: RouteContext) { + try { + const { project_id, name } = await params; + const skill = await getSkill(project_id, name); + if (!skill) { + return createErrorResponse('Skill not found', undefined, 404); + } + return createSuccessResponse(skill); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to read skill'); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteContext) { + try { + const { project_id, name } = await params; + const ok = await deleteSkill(project_id, name); + return createSuccessResponse({ deleted: ok, name }); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to delete skill'); + } +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; diff --git a/app/api/projects/[project_id]/skills/route.ts b/app/api/projects/[project_id]/skills/route.ts new file mode 100644 index 00000000..e048c514 --- /dev/null +++ b/app/api/projects/[project_id]/skills/route.ts @@ -0,0 +1,51 @@ +/** + * Per-project Skills API + * GET /api/projects/[project_id]/skills - list skills + * POST /api/projects/[project_id]/skills - create/update a skill + */ + +import { NextRequest } from 'next/server'; +import { listAllSkills, saveSkill, SkillError } from '@/lib/services/skills'; +import { createSuccessResponse, createErrorResponse, handleApiError } from '@/lib/utils/api-response'; + +interface RouteContext { + params: Promise<{ project_id: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteContext) { + try { + const { project_id } = await params; + const skills = await listAllSkills(project_id); + return createSuccessResponse(skills); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to list skills'); + } +} + +export async function POST(request: NextRequest, { params }: RouteContext) { + try { + const { project_id } = await params; + const body = await request.json().catch(() => ({})); + if (!body || typeof body.name !== 'string' || body.name.trim().length === 0) { + return createErrorResponse('name is required', undefined, 400); + } + const skill = await saveSkill(project_id, { + name: body.name, + description: typeof body.description === 'string' ? body.description : '', + content: typeof body.content === 'string' ? body.content : '', + raw: typeof body.raw === 'string' ? body.raw : undefined, + }); + return createSuccessResponse(skill, 201); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to save skill'); + } +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; diff --git a/app/api/projects/[project_id]/supabase/connect/route.ts b/app/api/projects/[project_id]/supabase/connect/route.ts index 75c6b523..e0374ac0 100644 --- a/app/api/projects/[project_id]/supabase/connect/route.ts +++ b/app/api/projects/[project_id]/supabase/connect/route.ts @@ -8,7 +8,7 @@ interface RouteContext { export async function POST(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const supabaseProjectId = typeof body?.project_id === 'string' ? body.project_id diff --git a/app/api/projects/[project_id]/vercel/connect/route.ts b/app/api/projects/[project_id]/vercel/connect/route.ts index a3e6ab1a..7a6b39a5 100644 --- a/app/api/projects/[project_id]/vercel/connect/route.ts +++ b/app/api/projects/[project_id]/vercel/connect/route.ts @@ -8,7 +8,7 @@ interface RouteContext { export async function POST(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; 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 }); diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 3b097ae5..70671fb9 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -30,7 +30,7 @@ export async function GET() { */ export async function POST(request: NextRequest) { try { - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const preferredCli = String(body.preferredCli || body.preferred_cli || 'claude').toLowerCase(); const requestedModel = body.selectedModel || body.selected_model; diff --git a/app/api/repo/[project_id]/file/route.ts b/app/api/repo/[project_id]/file/route.ts index d0d6156c..7f75c4ad 100644 --- a/app/api/repo/[project_id]/file/route.ts +++ b/app/api/repo/[project_id]/file/route.ts @@ -50,7 +50,7 @@ export async function GET(request: NextRequest, { params }: RouteContext) { export async function PUT(request: NextRequest, { params }: RouteContext) { try { const { project_id } = await params; - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const path = body?.path; const content = body?.content; diff --git a/app/api/settings/global/route.ts b/app/api/settings/global/route.ts index a3b7adb7..fd57d58c 100644 --- a/app/api/settings/global/route.ts +++ b/app/api/settings/global/route.ts @@ -20,7 +20,7 @@ export async function GET() { export async function PUT(request: NextRequest) { try { - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const candidate = body && typeof body === 'object' ? (body as Record) : {}; const update: Record = {}; diff --git a/app/api/supabase/create-project/route.ts b/app/api/supabase/create-project/route.ts index df8719bf..7c50b007 100644 --- a/app/api/supabase/create-project/route.ts +++ b/app/api/supabase/create-project/route.ts @@ -3,7 +3,7 @@ import { createSupabaseProject } from '@/lib/services/supabase'; export async function POST(request: NextRequest) { try { - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const projectId = typeof body?.project_id === 'string' ? body.project_id diff --git a/app/api/tokens/[...segments]/route.ts b/app/api/tokens/[...segments]/route.ts index 6c2a0cd8..3226328e 100644 --- a/app/api/tokens/[...segments]/route.ts +++ b/app/api/tokens/[...segments]/route.ts @@ -31,7 +31,10 @@ export async function GET(request: NextRequest, { params }: RouteContext) { return NextResponse.json({ success: false, error: 'Token not found' }, { status: 404 }); } - return NextResponse.json(record); + // Never expose the secret value on the public metadata endpoint — only the + // internal `.../token` path (below) returns the plaintext. + const { token: _secret, ...safe } = record; + return NextResponse.json(safe); } if (segments.length === 3 && segments[0] === 'internal' && segments[2] === 'token') { diff --git a/app/api/tokens/route.ts b/app/api/tokens/route.ts index 8265c250..b1fdf762 100644 --- a/app/api/tokens/route.ts +++ b/app/api/tokens/route.ts @@ -4,7 +4,7 @@ import { createSuccessResponse, handleApiError } from '@/lib/utils/api-response' export async function POST(request: NextRequest) { try { - const body = await request.json(); + const body = (await request.json().catch(() => null)) ?? {}; const provider = typeof body?.provider === 'string' ? body.provider : ''; const token = typeof body?.token === 'string' ? body.token : ''; const name = typeof body?.name === 'string' ? body.name : ''; diff --git a/components/chat/ChatInput.tsx b/components/chat/ChatInput.tsx index b3690ce2..ede372e5 100644 --- a/components/chat/ChatInput.tsx +++ b/components/chat/ChatInput.tsx @@ -37,8 +37,8 @@ interface ChatInputProps { projectId?: string; preferredCli?: string; selectedModel?: string; - thinkingMode?: boolean; - onThinkingModeChange?: (enabled: boolean) => void; + thinkingMode?: 'off' | 'auto' | 'forced'; + onThinkingModeChange?: (mode: 'off' | 'auto' | 'forced') => void; modelOptions?: ModelPickerOption[]; onModelChange?: (option: ModelPickerOption) => void; modelChangeDisabled?: boolean; @@ -57,7 +57,7 @@ export default function ChatInput({ projectId, preferredCli = 'claude', selectedModel = '', - thinkingMode = false, + thinkingMode = 'auto', onThinkingModeChange, modelOptions = [], onModelChange, @@ -77,22 +77,6 @@ export default function ChatInput({ const submissionLockRef = useRef(false); const supportsImageUpload = preferredCli !== 'cursor' && preferredCli !== 'qwen' && preferredCli !== 'glm'; - // Log CLI compatibility details - console.log('🔧 CLI Compatibility Check:', { - preferredCli, - supportsImageUpload, - projectId: projectId ? 'valid' : 'missing', - uploadButtonAvailable: supportsImageUpload && !!projectId - }); - - // Inform the user about the current state - if (supportsImageUpload && projectId) { - console.log('✅ Image upload is ready! Click the upload button or drag in a file.'); - } else if (!supportsImageUpload) { - console.log('❌ The current CLI does not support image uploads. Please switch to Claude CLI.'); - } else { - console.log('❌ Please select a project.'); - } const modelOptionsForCli = useMemo( () => modelOptions.filter(option => option.cli === preferredCli), @@ -439,22 +423,13 @@ export default function ChatInput({
) : ( -
{ - console.log('📸 Upload button clicked:', { - projectId, - supportsImageUpload, - isUploading, - disabled - }); - if (fileInputRef.current) { - console.log('📸 Triggering file input click'); - fileInputRef.current.click(); - } else { - console.error('📸 fileInputRef is null'); - } + fileInputRef.current?.click(); }} > @@ -467,7 +442,7 @@ export default function ChatInput({ disabled={isUploading || disabled} className="hidden" /> -
+ ) )}
@@ -517,6 +492,25 @@ export default function ChatInput({ ))}
+ {preferredCli === 'claude' && ( +
+ Thinking + +
+ )}
diff --git a/components/chat/ChatLog.tsx b/components/chat/ChatLog.tsx index 693b54aa..00f966c0 100644 --- a/components/chat/ChatLog.tsx +++ b/components/chat/ChatLog.tsx @@ -10,7 +10,7 @@ import type { ChatMessage, RealtimeEvent, RealtimeStatus } from '@/types'; import { toChatMessage, normalizeChatContent } from '@/lib/serializers/client/chat'; import { toRelativePath } from '@/lib/utils/path'; -type ToolAction = 'Edited' | 'Created' | 'Read' | 'Deleted' | 'Generated' | 'Searched' | 'Executed'; +import { type ToolAction, normalizeAction, inferActionFromToolName, pickFirstString, extractPathFromInput } from '@/lib/services/cli/tool-metadata'; type ToolExpansionState = { expanded: boolean; @@ -18,183 +18,6 @@ type ToolExpansionState = { toolCallId?: string | null; }; -const TOOL_NAME_ACTION_MAP: Record = { - read: 'Read', - read_file: 'Read', - 'read-file': 'Read', - write: 'Created', - write_file: 'Created', - 'write-file': 'Created', - create_file: 'Created', - edit: 'Edited', - edit_file: 'Edited', - 'edit-file': 'Edited', - update_file: 'Edited', - apply_patch: 'Edited', - patch_file: 'Edited', - remove_file: 'Deleted', - delete_file: 'Deleted', - delete: 'Deleted', - remove: 'Deleted', - list_files: 'Searched', - list: 'Searched', - ls: 'Searched', - glob: 'Searched', - glob_files: 'Searched', - search_files: 'Searched', - grep: 'Searched', - bash: 'Executed', - run: 'Executed', - run_bash: 'Executed', - shell: 'Executed', - todo_write: 'Generated', - todo: 'Generated', - plan_write: 'Generated', -}; - -const normalizeAction = (value: unknown): ToolAction | undefined => { - if (typeof value !== 'string') return undefined; - const candidate = value.trim().toLowerCase(); - if (!candidate) return undefined; - if (candidate.includes('edit') || candidate.includes('modify') || candidate.includes('update') || candidate.includes('patch')) { - return 'Edited'; - } - if (candidate.includes('write') || candidate.includes('create') || candidate.includes('add') || candidate.includes('append')) { - return 'Created'; - } - if (candidate.includes('read') || candidate.includes('open') || candidate.includes('view')) { - return 'Read'; - } - if (candidate.includes('delete') || candidate.includes('remove')) { - return 'Deleted'; - } - if ( - candidate.includes('search') || - candidate.includes('find') || - candidate.includes('list') || - candidate.includes('glob') || - candidate.includes('ls') || - candidate.includes('grep') - ) { - return 'Searched'; - } - if (candidate.includes('generate') || candidate.includes('todo') || candidate.includes('plan')) { - return 'Generated'; - } - if ( - candidate.includes('execute') || - candidate.includes('exec') || - candidate.includes('run') || - candidate.includes('bash') || - candidate.includes('shell') || - candidate.includes('command') - ) { - return 'Executed'; - } - return undefined; -}; - -const inferActionFromToolName = (toolName: unknown): ToolAction | undefined => { - if (typeof toolName !== 'string') return undefined; - const normalized = toolName.trim().toLowerCase(); - if (!normalized) return undefined; - if (TOOL_NAME_ACTION_MAP[normalized]) { - return TOOL_NAME_ACTION_MAP[normalized]; - } - const suffix = normalized.split(':').pop() ?? normalized; - if (suffix && TOOL_NAME_ACTION_MAP[suffix]) { - return TOOL_NAME_ACTION_MAP[suffix]; - } - return normalizeAction(normalized); -}; - -const pickFirstString = (value: unknown): string | undefined => { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - if (Array.isArray(value)) { - for (const entry of value) { - const candidate = pickFirstString(entry); - if (candidate) return candidate; - } - return undefined; - } - if (value && typeof value === 'object') { - const obj = value as Record; - const nestedKeys = ['path', 'filepath', 'filePath', 'file_path', 'target', 'value']; - for (const key of nestedKeys) { - if (key in obj) { - const candidate = pickFirstString(obj[key]); - if (candidate) return candidate; - } - } - } - return undefined; -}; - -const extractPathFromInput = (input: unknown, action?: ToolAction): string | undefined => { - if (!input || typeof input !== 'object') return undefined; - const record = input as Record; - const candidateKeys = [ - 'filePath', - 'file_path', - 'filepath', - 'path', - 'targetPath', - 'target_path', - 'target', - 'targets', - 'fullPath', - 'full_path', - 'destination', - 'destinationPath', - 'outputPath', - 'output_path', - 'glob', - 'pattern', - 'directory', - 'dir', - 'filename', - 'name', - ]; - - for (const key of candidateKeys) { - if (key in record) { - const candidate = record[key]; - const result = pickFirstString(candidate); - if (result) { - return result; - } - } - } - - if (Array.isArray(record.targets)) { - for (const target of record.targets as unknown[]) { - const candidate = pickFirstString(target); - if (candidate) { - return candidate; - } - } - } - - if (!action || action === 'Executed') { - const commandKeys = ['command', 'cmd', 'shellCommand', 'shell_command']; - for (const key of commandKeys) { - if (key in record) { - const candidate = pickFirstString(record[key]); - if (candidate) { - return candidate; - } - } - } - } - - return undefined; -}; const extractToolCallId = ( metadata?: Record | null @@ -995,9 +818,13 @@ interface ChatLogProps { add: (message: ChatMessage) => void; remove: (messageId: string) => void; }) => void; + /** Authoritative "agent is running" signal from the server (DB-backed + * /requests/active poll). Drives the busy indicator even if a live status + * event was missed (reconnect / late join). */ + serverBusy?: boolean; } -export default function ChatLog({ projectId, onSessionStatusChange, onProjectStatusUpdate, onSseFallbackActive, startRequest, completeRequest, onAddUserMessage }: ChatLogProps) { +export default function ChatLog({ projectId, onSessionStatusChange, onProjectStatusUpdate, onSseFallbackActive, startRequest, completeRequest, onAddUserMessage, serverBusy = false }: ChatLogProps) { const [messages, setMessages] = useState([]); const [logs, setLogs] = useState([]); const [selectedLog, setSelectedLog] = useState(null); @@ -1007,13 +834,18 @@ export default function ChatLog({ projectId, onSessionStatusChange, onProjectSta const [errorMessage, setErrorMessage] = useState(null); const [activeSession, setActiveSession] = useState(null); const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + // Short label describing what the agent is currently doing (from real status + // / tool-use events), shown next to the busy indicator. + const [busyDetail, setBusyDetail] = useState(null); const [needsHistoryRefresh, setNeedsHistoryRefresh] = useState(false); const logsEndRef = useRef(null); const pollIntervalRef = useRef(null); const hasLoadedInitialDataRef = useRef(false); const sseFallbackTimerRef = useRef | null>(null); const hasLoggedSseFallbackRef = useRef(false); - const [enableSseFallback, setEnableSseFallback] = useState(false); + // SSE is the realtime transport (no WebSocket server in this deployment), so + // keep it always-on and stable rather than toggling it off WebSocket state. + const [enableSseFallback, setEnableSseFallback] = useState(true); const [isSseConnected, setIsSseConnected] = useState(false); const [failedImageUrls, setFailedImageUrls] = useState>(new Set()); const [expandedToolMessages, setExpandedToolMessages] = useState>({}); @@ -1403,14 +1235,18 @@ export default function ChatLog({ projectId, onSessionStatusChange, onProjectSta onProjectStatusUpdate?.(statusData.status, statusData.message); } - if (resolvedStatus === 'completed') { + if (resolvedStatus === 'completed' || resolvedStatus === 'error') { setActiveSession(null); onSessionStatusChange?.(false); setIsWaitingForResponse(false); + setBusyDetail(null); } if (resolvedStatus === 'starting' || resolvedStatus === 'running') { setIsWaitingForResponse(true); + if (typeof statusData?.message === 'string' && statusData.message.trim()) { + setBusyDetail(statusData.message.trim()); + } } const requestKey = statusData?.requestId ?? requestId; @@ -1509,6 +1345,7 @@ export default function ChatLog({ projectId, onSessionStatusChange, onProjectSta // Use the centralized WebSocket hook (with SSE fallback defined below) const { isConnected, isConnecting } = useWebSocket({ projectId, + enabled: false, // No WebSocket server in this deployment; SSE handles realtime. onMessage: handleRealtimeMessage, onStatus: handleRealtimeStatus, onConnect: () => { @@ -1573,6 +1410,13 @@ export default function ChatLog({ projectId, onSessionStatusChange, onProjectSta }; }, [isConnected, isConnecting, onSseFallbackActive]); + // Keep the SSE handlers in refs so the EventSource effect doesn't re-mount + // (and thrash the connection) every time these callbacks are recreated. + const sseHandlersRef = useRef({ handleRealtimeEnvelope, onSseFallbackActive, recoverMissingMessages }); + useEffect(() => { + sseHandlersRef.current = { handleRealtimeEnvelope, onSseFallbackActive, recoverMissingMessages }; + }, [handleRealtimeEnvelope, onSseFallbackActive, recoverMissingMessages]); + useEffect(() => { if (!projectId) return; if (!enableSseFallback) return; @@ -1632,9 +1476,9 @@ export default function ChatLog({ projectId, onSessionStatusChange, onProjectSta source.onopen = () => { console.log('🔄 [Transport] SSE connection established'); setIsSseConnected(true); - onSseFallbackActive?.(true); + sseHandlersRef.current.onSseFallbackActive?.(true); // Recover any missing messages that might have been lost during SSE disconnection - recoverMissingMessages(); + sseHandlersRef.current.recoverMissingMessages(); }; source.onmessage = (event) => { @@ -1643,23 +1487,27 @@ export default function ChatLog({ projectId, onSessionStatusChange, onProjectSta } try { const envelope = JSON.parse(event.data) as RealtimeEvent; - handleRealtimeEnvelope(envelope); + sseHandlersRef.current.handleRealtimeEnvelope(envelope); } catch (error) { console.error('🔄 [Realtime] Failed to parse SSE message:', error); } }; source.onerror = () => { - setIsSseConnected(false); if (disposed) { return; } - if (reconnectTimer) { - clearTimeout(reconnectTimer); + // EventSource auto-reconnects while readyState === CONNECTING — don't + // fight it (that caused a close/reopen thrash). Only re-create the + // connection ourselves if it has fully CLOSED. + if (source.readyState === EventSource.CLOSED) { + setIsSseConnected(false); + console.warn('🔄 [Realtime] SSE connection closed, reconnecting...'); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + reconnectTimer = setTimeout(connectSse, 2000); } - console.warn('🔄 [Realtime] SSE connection lost, retrying...'); - source.close(); - reconnectTimer = setTimeout(connectSse, 2000); }; } catch (error) { setIsSseConnected(false); @@ -1680,7 +1528,9 @@ export default function ChatLog({ projectId, onSessionStatusChange, onProjectSta clearTimeout(reconnectTimer); } }; - }, [projectId, enableSseFallback, handleRealtimeEnvelope, onSseFallbackActive, recoverMissingMessages]); + // Intentionally only re-run when the project/fallback flag changes — handlers + // are read from a ref so the SSE connection stays stable across re-renders. + }, [projectId, enableSseFallback]); useEffect(() => { return () => { @@ -3034,15 +2884,23 @@ const ToolResultMessage = ({ ))} - {/* Loading indicator for waiting response */} - {isWaitingForResponse && ( -
-
- ... -
+ {/* Busy indicator — driven by real status: the live SSE run status OR + the authoritative server-side active-request flag (so it still shows + after a reconnect/late join, not just when the start event is caught). */} + {(isWaitingForResponse || serverBusy) && ( +
+
)} - +
diff --git a/components/chat/DesignImportModal.tsx b/components/chat/DesignImportModal.tsx new file mode 100644 index 00000000..d549a4c6 --- /dev/null +++ b/components/chat/DesignImportModal.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { FaFileImport, FaTimes, FaCheckCircle, FaMagic } from 'react-icons/fa'; + +interface DesignImportManifest { + dir: string; + screens: string[]; + designSystemPresent: boolean; + assetCount: number; + fontCount: number; + fileCount: number; + totalBytes: number; + skippedNoise: number; +} + +interface DesignImportModalProps { + projectId: string; + isOpen: boolean; + onClose: () => void; + /** Called when the user chooses to send the port instruction to the agent. */ + onApply: (prompt: string) => void; +} + +type Phase = 'idle' | 'uploading' | 'done' | 'error'; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ''; + +export default function DesignImportModal({ + projectId, + isOpen, + onClose, + onApply, +}: DesignImportModalProps) { + const [phase, setPhase] = useState('idle'); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + const [manifest, setManifest] = useState(null); + const [prompt, setPrompt] = useState(''); + const [fileName, setFileName] = useState(''); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(null); + + const reset = useCallback(() => { + setPhase('idle'); + setProgress(0); + setError(null); + setManifest(null); + setPrompt(''); + setFileName(''); + setDragOver(false); + }, []); + + const handleClose = useCallback(() => { + reset(); + onClose(); + }, [reset, onClose]); + + const upload = useCallback( + (file: File) => { + if (!file.name.toLowerCase().endsWith('.zip')) { + setPhase('error'); + setError('Please choose a .zip export from Claude Design.'); + return; + } + setFileName(file.name); + setPhase('uploading'); + setProgress(0); + setError(null); + + const form = new FormData(); + form.append('file', file); + + // XHR gives upload progress, which matters for large design exports. + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/api/projects/${projectId}/design-import`); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => { + let payload: any = null; + try { + payload = JSON.parse(xhr.responseText); + } catch { + /* ignore */ + } + if (xhr.status >= 200 && xhr.status < 300 && payload?.success) { + setManifest(payload.data.manifest); + setPrompt(payload.data.suggestedPrompt || ''); + setPhase('done'); + } else { + setPhase('error'); + setError(payload?.error || `Upload failed (${xhr.status})`); + } + }; + xhr.onerror = () => { + setPhase('error'); + setError('Network error during upload.'); + }; + xhr.send(form); + }, + [projectId] + ); + + const onPick = useCallback( + (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) upload(f); + if (inputRef.current) inputRef.current.value = ''; + }, + [upload] + ); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) upload(f); + }, + [upload] + ); + + if (!isOpen) return null; + + return ( +
+
+
+ {/* Header */} +
+
+ +

Import from Claude Design

+
+ +
+ +
+ {/* Idle / dropzone */} + {phase === 'idle' && ( + <> +

+ Upload a .zip export from{' '} + claude.ai/design. The screens, fonts and + assets are staged into design-reference/, + then the AI ports them into this app — keeping your current framework and structure. +

+
inputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + className={`cursor-pointer rounded-xl border-2 border-dashed px-6 py-10 text-center transition-colors ${ + dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400 bg-gray-50' + }`} + > + +

Drop the zip here, or click to choose

+

Design-process noise (screenshots, raw uploads) is skipped automatically.

+
+ + + )} + + {/* Uploading */} + {phase === 'uploading' && ( +
+

Uploading {fileName}…

+
+
+
+

{progress}%{progress === 100 ? ' · extracting…' : ''}

+
+ )} + + {/* Error */} + {phase === 'error' && ( +
+

{error}

+ +
+ )} + + {/* Done */} + {phase === 'done' && manifest && ( +
+
+ + + Staged {manifest.screens.length} screen{manifest.screens.length === 1 ? '' : 's'} ·{' '} + {manifest.assetCount} assets · {manifest.fontCount} fonts + +
+ +
+ {manifest.screens.map((s) => ( + + {s} + + ))} +
+ + +