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..1dbef8fa --- /dev/null +++ b/.env.example @@ -0,0 +1,55 @@ +# 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 preview URLs. Prefer {project} (a STABLE per-project subdomain whose +# Traefik route is rewritten to the live port each start — a reused port can never +# cross projects). Requires TRAEFIK_DYNAMIC_DIR (below) mounted + writable. The +# legacy {port} form works too but is port-keyed (guessable, cross-project risk). +PREVIEW_URL_TEMPLATE=https://preview-{project}.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 + +# --- Auth (Google OAuth via Auth.js) --- +# Create an OAuth client at console.cloud.google.com (Web app), redirect URI: +# https:///api/auth/callback/google +AUTH_SECRET= # openssl rand -hex 32 +AUTH_URL=https://claudable.example.com +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +ALLOWED_EMAIL_DOMAINS=example.com # comma-separated; these domains auto-provision users +BOOTSTRAP_ADMIN_EMAIL= # this email becomes admin on first sign-in +AUTH_ENABLED=false # flip to true to enforce the login gate + +# --- it-ops broker (admin-enabled per project; tools run in THIS process, never +# in the agent). Each block is opt-in: unset = that tool reports "not +# configured". Gitea write tools reuse GIT_TOKEN above. AWS stays propose-only. +# Coolify: scoped list/restart/deploy/env tools. Token: Coolify → Keys & Tokens. +COOLIFY_API_BASE=http://localhost:8000 +COOLIFY_API_TOKEN= +# Traefik: publish dynamic routes. Mount the host's proxy dynamic dir (set the +# host path) AND point the in-container path at the mount. +TRAEFIK_DYNAMIC_HOST_DIR= # e.g. /data/coolify/proxy/dynamic (host path) +TRAEFIK_DYNAMIC_DIR= # e.g. /traefik-dynamic (in-container mount path) + +# --- One-click Postgres backend (provisioned via Coolify) --- +COOLIFY_SERVER_UUID= # the Coolify server uuid to place databases on +PREVIEW_DB_HOST=127.0.0.1 # how preview/deploy reach the DB (host-networked) diff --git a/.gitignore b/.gitignore index 94b0dad2..212963db 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# local env files -.env*.local +# local env files — ignore every .env variant, keep only the example template .env +.env.* +!.env.example # vercel .vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4a41b87c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# 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. +# chromium + fonts-liberation power server-side project thumbnails (headless capture). +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates ripgrep curl chromium fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +# Path to the headless browser used for thumbnail capture (lib/services/thumbnail.ts). +ENV CHROMIUM_PATH=/usr/bin/chromium + +# Go toolchain — lets the preview build+run a project's Go backend (the `static` +# import mode's backend sidecar). Pinned; copied from the official image. +COPY --from=golang:1.25-bookworm /usr/local/go /usr/local/go +ENV PATH="/usr/local/go/bin:${PATH}" \ + GOTOOLCHAIN=local \ + GOFLAGS=-buildvcs=false + +# Docker CLI ONLY (not the daemon) — for project-environment isolation, Claudable +# builds/runs hardened sibling containers via the socket-proxy (DOCKER_HOST). Just +# the static client binary; talks to a remote daemon, runs nothing locally. +RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz \ + | tar -xz -C /usr/local/bin --strip-components=1 docker/docker \ + && docker --version + +# 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/README.md b/README.md index 725f8b9a..cd9e90eb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,40 @@

+--- + +## 🍴 About this fork + +This is a **self-hosted fork** of [opactorai/Claudable](https://github.com/opactorai/Claudable), +adapted to run entirely on **your own infrastructure** (no Vercel / GitHub / Supabase +required) and extended into a **multi-user, team review** tool. Everything upstream still +works; this fork adds: + +- **Self-hosting on your own box** — provider-aware Git (self-hosted **Gitea** as well as + GitHub), one-click publish + auto-deploy via **Gitea Actions**, and a shared **Traefik** + proxy. Each running preview gets a **stable per-project subdomain** with automatic HTTPS + (Let's Encrypt DNS-01), isolated so one project's preview can never show another's. +- **Multi-user** — Google login with an auth gate, per-project access control, and + per-user Claude credentials (paste your own `claude setup-token`). +- **Visual editor** — an *Edit* mode to click elements in the live preview and tweak text + & CSS, then apply the change to code through the agent. +- **Preview comments** — Figma-style pinned, per-route review comments overlaid on the + preview (Claudable-only; never touches the app source), with author, resolve & clear-all. +- **Device preview** — a device selector (iPhone/iPad/Pixel/Surface…) with portrait ⇄ + landscape and always-fit scaling. +- **it-ops broker** *(admin-opt-in, per user)* — scoped, audited infrastructure tools the + agent can use (Gitea repo/admin, Coolify apps & projects, Traefik routes); AWS/IAM stays + propose-only, and credentials never enter the agent's context. +- **Agent sandboxing** — the agent's environment is scrubbed of Claudable's secrets and + guarded from escaping its own project. +- **Quality-of-life** — a large **design-style catalog** (skills), chunked large-file + uploads with progress, and a project dashboard. + +Deployment specifics live in `.env.example` and `docker-compose.yml`; no infrastructure +values or secrets are committed. It tracks upstream and is not intended for merge back. + +--- + ## 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. @@ -35,8 +69,6 @@ How to start? Simply login to Claude Code (or Cursor CLI), start Claudable, and ## Features -![Claudable Demo](assets/gif/Claudable_v2_cc_4_1080p.gif) - - **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 @@ -47,16 +79,6 @@ How to start? Simply login to Claude Code (or Cursor CLI), start Claudable, and - **Supabase Database**: Connect production PostgreSQL with authentication ready to use - **Desktop App**: Available as Electron desktop application for Mac, Windows, and Linux -## Demo Examples - -### Codex CLI Example - -![Codex CLI Demo](assets/gif/Claudable_v2_codex_1_1080p.gif) - -### Qwen Code Example - -![Qwen Code Demo](assets/gif/Claudable_v2_qwen_1_1080p.gif) - ## Supported AI Coding Agents Claudable supports multiple AI coding agents, giving you the flexibility to choose the best tool for your needs: diff --git a/app/[project_id]/chat/page.tsx b/app/[project_id]/chat/page.tsx index 28523312..2121abaa 100644 --- a/app/[project_id]/chat/page.tsx +++ b/app/[project_id]/chat/page.tsx @@ -4,12 +4,22 @@ 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 UserMenu from '@/components/layout/UserMenu'; +import VisualEditorPanel, { type SelectedElement } from '@/components/chat/VisualEditorPanel'; +import CommentsLayer, { type CommentPin, type ComposeAnchor } from '@/components/chat/CommentsLayer'; +import CommentsListPanel from '@/components/chat/CommentsListPanel'; +import { useToast } from '@/components/ui/Toast'; +import ThemeToggle from '@/components/ui/ThemeToggle'; +import ArchitectureModal from '@/components/chat/ArchitectureModal'; 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 +41,24 @@ import { const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ''; +/** Named device presets for the preview device selector (portrait dimensions). */ +type DevicePreset = { id: string; name: string; w?: number; h?: number; desktop?: boolean }; +const DEVICE_PRESETS: DevicePreset[] = [ + { id: 'desktop', name: 'Responsive · Desktop', desktop: true }, + { id: 'iphone-se', name: 'iPhone SE', w: 375, h: 667 }, + { id: 'iphone-14', name: 'iPhone 14 / 15', w: 390, h: 844 }, + { id: 'iphone-15-pro-max', name: 'iPhone 15 Pro Max', w: 430, h: 932 }, + { id: 'pixel-7', name: 'Pixel 7', w: 412, h: 915 }, + { id: 'galaxy-s20', name: 'Samsung Galaxy S20', w: 360, h: 800 }, + { id: 'galaxy-fold', name: 'Galaxy Z Fold', w: 344, h: 882 }, + { id: 'ipad-mini', name: 'iPad Mini', w: 768, h: 1024 }, + { id: 'ipad-air', name: 'iPad Air', w: 820, h: 1180 }, + { id: 'ipad-pro-11', name: 'iPad Pro 11"', w: 834, h: 1194 }, + { id: 'ipad-pro-129', name: 'iPad Pro 12.9"', w: 1024, h: 1366 }, + { id: 'surface-pro', name: 'Surface Pro', w: 912, h: 1368 }, +]; + +/** 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; @@ -123,7 +151,7 @@ function TreeView({ entries, selectedFile, expandedFolders, folderContents, onTo className={`group flex items-center h-[22px] px-2 cursor-pointer ${ selectedFile === fullPath ? 'bg-blue-100 ' - : 'hover:bg-gray-100 ' + : 'hover:bg-gray-100 dark:hover:bg-gray-800 ' }`} style={{ paddingLeft: `${8 + indent}px` }} onClick={async () => { @@ -142,8 +170,8 @@ function TreeView({ entries, selectedFile, expandedFolders, folderContents, onTo
{entry.type === 'dir' && ( isExpanded ? - : - + : + )}
@@ -160,7 +188,7 @@ function TreeView({ entries, selectedFile, expandedFolders, folderContents, onTo {/* File/Folder name */} {level === 0 ? (entry.path.split('/').pop() || entry.path) : (entry.path.split('/').pop() || entry.path)} @@ -238,9 +266,63 @@ export default function ChatPage() { const optimisticMessagesRef = useRef>(new Map()); const [mode, setMode] = useState<'act' | 'chat'>('act'); const [isRunning, setIsRunning] = useState(false); + // CLI-style message queue: messages typed while a turn is running wait here and + // auto-send (one per turn) when the current turn finishes. + const [queuedMessages, setQueuedMessages] = useState>([]); + const prevBusyRef = useRef(false); const [isSseFallbackActive, setIsSseFallbackActive] = useState(false); const [showPreview, setShowPreview] = useState(true); - const [deviceMode, setDeviceMode] = useState<'desktop'|'mobile'>('desktop'); + const toast = useToast(); + // --- Visual editor (inline edit mode) --- + const [editMode, setEditMode] = useState(false); + const [selectedEl, setSelectedEl] = useState(null); + const [styleEdits, setStyleEdits] = useState>({}); + const [textEdit, setTextEdit] = useState(null); + const [persistingEdit, setPersistingEdit] = useState(false); + // --- Comments (pinned review annotations) --- + const [commentMode, setCommentMode] = useState(false); + const [comments, setComments] = useState([]); + const [pinPositions, setPinPositions] = useState>({}); + const [activePinId, setActivePinId] = useState(null); + const [composeAnchor, setComposeAnchor] = useState(null); + // True once the preview iframe's plugin has reported in for the current load. + // Reset on navigation so we don't push pins/scroll into a still-loading page. + const [previewReady, setPreviewReady] = useState(false); + const previewReadyRef = useRef(false); + previewReadyRef.current = previewReady; + // The injected review bridge is Nuxt-only. If the iframe loads but never + // reports in, the stack has no bridge (Next.js/Angular) → gate the review + // tools with a hint instead of leaving silently-dead buttons. + const [bridgeAbsent, setBridgeAbsent] = useState(false); + const bridgeTimerRef = useRef | null>(null); + // Comments overview list (left pane): ALL comments across every route. + const [showCommentsList, setShowCommentsList] = useState(false); + const [allComments, setAllComments] = useState<(CommentPin & { route: string })[]>([]); + const commentModeRef = useRef(false); + commentModeRef.current = commentMode; + const editModeRef = useRef(false); + editModeRef.current = editMode; + // Latest pins/active id, read by the stable claudable-preview handler so it can + // re-push them after ANY iframe (re)load — not just parent-initiated ones. + const commentsRef = useRef([]); + commentsRef.current = comments; + const activePinIdRef = useRef(null); + activePinIdRef.current = activePinId; + // A comment we're navigating to (may be on another route): fired once its + // route's pins have loaded so the preview can scroll to it. + const pendingScrollRef = useRef<{ id: string; anchorSelector: string; route: string } | null>(null); + // Runtime errors reported by the preview (for one-click "fix with AI"). + const [previewErrors, setPreviewErrors] = useState<{ kind: string; message: string; at: string }[]>([]); + const [shareCopied, setShareCopied] = useState(false); + const [deviceId, setDeviceId] = useState('desktop'); + const [orientation, setOrientation] = useState<'portrait'|'landscape'>('portrait'); + const [deviceScale, setDeviceScale] = useState(1); + const [deviceMenuOpen, setDeviceMenuOpen] = useState(false); + const [deviceViewport, setDeviceViewport] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); + const deviceViewportRef = useRef(null); + // Always points at the latest runAct closure (used by persistEdits). + const runActRef = useRef<((m?: string, i?: any[]) => Promise) | null>(null); + const currentRouteRef = useRef('/'); const [showGlobalSettings, setShowGlobalSettings] = useState(false); const [uploadedImages, setUploadedImages] = useState<{name: string; url: string; base64?: string; path?: string}[]>([]); const [isInitializing, setIsInitializing] = useState(true); @@ -252,13 +334,70 @@ 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); + const [showArchitecture, setShowArchitecture] = 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 +410,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); @@ -313,6 +452,44 @@ export default function ChatPage() { previewUrlRef.current = previewUrl; }, [previewUrl]); + // Capture a dashboard thumbnail once the preview is up (best-effort, once per + // project). Delayed so the dev server has rendered before the screenshot. + const thumbCapturedForRef = useRef(null); + useEffect(() => { + if (!previewUrl || thumbCapturedForRef.current === projectId) return; + const t = setTimeout(() => { + thumbCapturedForRef.current = projectId; + fetch(`${API_BASE}/api/projects/${projectId}/thumbnail`, { method: 'POST' }).catch(() => {}); + }, 7000); + return () => clearTimeout(t); + }, [previewUrl, projectId]); + + // Re-capture the thumbnail whenever an agent run completes, so the dashboard + // tile always reflects the latest version of the site. Fires on the + // running -> not-running transition, after the preview has hot-reloaded. + const wasRunningRef = useRef(false); + useEffect(() => { + const justFinished = wasRunningRef.current && !isRunning; + wasRunningRef.current = isRunning; + if (!justFinished || !previewUrlRef.current) return; + const t = setTimeout(() => { + fetch(`${API_BASE}/api/projects/${projectId}/thumbnail`, { method: 'POST' }).catch(() => {}); + }, 6000); + return () => clearTimeout(t); + }, [isRunning, projectId]); + + // Flush the queued messages one at a time: when a turn finishes (busy -> idle), + // send the next queued message. Edge-detected so we send exactly one per turn. + useEffect(() => { + const busy = isRunning || hasActiveRequests; + if (prevBusyRef.current && !busy && queuedMessages.length > 0) { + const next = queuedMessages[0]; + setQueuedMessages((q) => q.slice(1)); + runActRef.current?.(next.message, next.images); + } + prevBusyRef.current = busy; + }, [isRunning, hasActiveRequests, queuedMessages]); + const sendInitialPrompt = useCallback(async (initialPrompt: string) => { if (initialPromptSent) { return; @@ -335,6 +512,7 @@ export default function ChatPage() { conversationId: conversationId || undefined, requestId, selectedModel, + thinkingMode, }; const r = await fetch(`${API_BASE}/api/chat/${projectId}/act`, { @@ -386,7 +564,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 +782,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 +821,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 +1050,461 @@ 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); - + setPreviewInitializationMessage('Starting development server…'); + + // Heuristic fallback messages for the install phase (before the dev-server + // process registers, its logs aren't queryable yet). + const t1 = setTimeout(() => setPreviewInitializationMessage('Installing dependencies…'), 3000); + const t2 = setTimeout(() => setPreviewInitializationMessage('Building your application…'), 9000); + + // Live progress: poll the preview status and surface the REAL latest + // dev-server line (e.g. "Nuxt … ready", "Local: …") once it appears, so the + // 20-30s cold start shows genuine activity instead of a blind spinner. + const cleanLine = (s: string) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/gu, '').replace(/[│─╭╮╰╯]/gu, '').trim(); + const poll = setInterval(async () => { + try { + const sr = await fetch(`${API_BASE}/api/projects/${projectId}/preview/status`, { cache: 'no-store' }); + const sj = await sr.json(); + const logs: string[] = sj?.data?.logs ?? []; + for (let i = logs.length - 1; i >= 0 && i > logs.length - 8; i--) { + const line = cleanLine(String(logs[i] || '')); + if (line && /ready|local:|listening|compiled|nuxt|vite|localhost|building|routes|warming/i.test(line)) { + clearTimeout(t1); clearTimeout(t2); + setPreviewInitializationMessage(line.slice(0, 90)); + break; + } + } + } catch { /* ignore poll errors */ } + }, 1500); + const r = await fetch(`${API_BASE}/api/projects/${projectId}/preview/start`, { method: 'POST' }); + clearTimeout(t1); + clearTimeout(t2); + clearInterval(poll); 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}`); + // The plugin has reported in → the (re)loaded page is ready. Flipping this + // re-fires the renderPins + pending-scroll effects with a live listener, + // so navigating to a comment shows/scrolls to it on the FIRST click. + setPreviewReady(true); + setBridgeAbsent(false); // a report proves the bridge is present + if (bridgeTimerRef.current) { clearTimeout(bridgeTimerRef.current); bridgeTimerRef.current = null; } + // Re-arm on EVERY report (dev-server reload, error-overlay refresh, + // stop→start, idle rebuild) — not just parent-initiated navigation. A + // freshly (re)loaded plugin starts with pins=[] and no active mode; if we + // don't re-push here the dots vanish while the mode stays "on" (B2), and + // edit mode's click-to-select goes dead. Mirrors the share page. + const win = iframeRef.current?.contentWindow; + try { + if (commentModeRef.current) { + win?.postMessage({ source: 'claudable-comments-cmd', type: 'enter' }, previewOrigin); + win?.postMessage({ + source: 'claudable-comments-cmd', + type: 'renderPins', + activeId: activePinIdRef.current, + pins: commentsRef.current.map((c) => ({ id: c.id, index: c.index, anchorSelector: c.anchorSelector, relX: c.relX, relY: c.relY, resolved: c.resolved })), + }, previewOrigin); + } + if (editModeRef.current) { + win?.postMessage({ source: 'claudable-editor-cmd', type: 'enter' }, previewOrigin); + } + } catch { /* iframe not ready */ } + } + }; + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [previewUrl]); + + // --- Visual editor bridge (parent side) --------------------------------- + const postToPreview = useCallback((msg: Record) => { + if (!previewUrl || !iframeRef.current?.contentWindow) return; + try { + iframeRef.current.contentWindow.postMessage({ source: 'claudable-editor-cmd', ...msg }, new URL(previewUrl).origin); + } catch { /* iframe not ready */ } + }, [previewUrl]); + + // Toggle the preview into/out of click-to-select edit mode. + useEffect(() => { + postToPreview({ type: editMode ? 'enter' : 'exit' }); + if (editMode) setCommentMode(false); // edit + comment modes are mutually exclusive + if (!editMode) { setSelectedEl(null); setStyleEdits({}); setTextEdit(null); } + }, [editMode, postToPreview]); + + // Receive the selected element from the preview editor bridge. + useEffect(() => { + if (!previewUrl) return; + let origin: string; + try { origin = new URL(previewUrl).origin; } catch { return; } + const onMsg = (e: MessageEvent) => { + if (e.origin !== origin) return; + const d = e.data as { source?: string; type?: string; element?: SelectedElement } | null; + if (d?.source === 'claudable-editor' && d.type === 'selected' && d.element) { + setSelectedEl(d.element); + setStyleEdits({}); + setTextEdit(null); + } + }; + window.addEventListener('message', onMsg); + return () => window.removeEventListener('message', onMsg); + }, [previewUrl]); + + const applyStyle = useCallback((prop: string, value: string) => { + setStyleEdits((prev) => ({ ...prev, [prop]: value })); + postToPreview({ type: 'applyStyle', prop, value }); + }, [postToPreview]); + + const applyText = useCallback((value: string) => { + setTextEdit(value); + postToPreview({ type: 'applyText', value }); + }, [postToPreview]); + + const persistEdits = useCallback(async () => { + if (!selectedEl) return; + // "Apply to code" launches an agent turn — refuse while one is running so it + // doesn't race (the server would 409 anyway). The button is also disabled. + if (hasActiveRequests) return; + const styleLines = Object.entries(styleEdits).map(([k, v]) => ` - ${k}: ${v}`); + const instruction = [ + `Visual edit — persist these preview-only changes into the source code:`, + ``, + `Element: <${selectedEl.tag}>${selectedEl.id ? ` #${selectedEl.id}` : ''}${selectedEl.classes.length ? ` .${selectedEl.classes.join('.')}` : ''}`, + `CSS selector: ${selectedEl.selector}`, + selectedEl.text ? `Current text: "${selectedEl.text.slice(0, 100)}"` : '', + textEdit !== null && textEdit !== selectedEl.text ? `New text: "${textEdit}"` : '', + styleLines.length ? `Style changes:\n${styleLines.join('\n')}` : '', + ``, + `Locate this element in the source (match the selector / tag / classes) and apply the change idiomatically — prefer Tailwind classes or scoped styles as fits the codebase. Keep the diff minimal.`, + ].filter(Boolean).join('\n'); + setPersistingEdit(true); + try { + // Always call the LATEST runAct (a fresh closure each render) via a ref, so + // the persisted edit uses the current model/mode — not a stale captured one. + await runActRef.current?.(instruction, []); + setStyleEdits({}); + setTextEdit(null); + setEditMode(false); + } finally { + setPersistingEdit(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedEl, styleEdits, textEdit]); + + // --- Comments (pinned review annotations, Claudable-only) ----------------- + const postComments = useCallback((msg: Record) => { + if (!previewUrl || !iframeRef.current?.contentWindow) return; + try { iframeRef.current.contentWindow.postMessage({ source: 'claudable-comments-cmd', ...msg }, new URL(previewUrl).origin); } catch { /* not ready */ } + }, [previewUrl]); + + const loadComments = useCallback(async (route: string) => { + try { + const r = await fetch(`${API_BASE}/api/projects/${projectId}/comments?route=${encodeURIComponent(route)}`); + const j = await r.json(); + if (j.success) setComments((j.data as any[]).map((c, i) => ({ ...c, index: i + 1 }))); + } catch { /* ignore */ } + }, [projectId]); + + // ALL comments across every route (powers the overview list). Omitting `route` + // returns the whole project's comments. + const loadAllComments = useCallback(async () => { + try { + const r = await fetch(`${API_BASE}/api/projects/${projectId}/comments`); + const j = await r.json(); + if (j.success) setAllComments(j.data as (CommentPin & { route: string })[]); + } catch { /* ignore */ } + }, [projectId]); + + // Jump to a comment: switch route if needed (the preview reloads and its pins + // reload), then scroll to + highlight the anchor. Same-route jumps are instant. + const goToComment = useCallback((c: CommentPin & { route: string }) => { + const route = c.route || '/'; + setShowPreview(true); + if (!commentMode) setCommentMode(true); + if (route !== (currentRouteRef.current || '/')) { + pendingScrollRef.current = { id: c.id, anchorSelector: c.anchorSelector, route }; + navigateToRoute(route); + } else { + setActivePinId(c.id); + postComments({ type: 'scrollTo', anchorSelector: c.anchorSelector }); + } + }, [commentMode, postComments]); + + // Enter/exit comment mode (mutually exclusive with edit mode). + useEffect(() => { + postComments({ type: commentMode ? 'enter' : 'exit' }); + if (commentMode) { setEditMode(false); loadComments(currentRouteRef.current || '/'); } + else { + setComposeAnchor(null); setActivePinId(null); setShowCommentsList(false); + // Wipe the in-iframe pin dots — otherwise they linger with pointer-events + // and swallow clicks while browsing / get selected in edit mode (B6). + postComments({ type: 'renderPins', pins: [] }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commentMode, postComments]); + + // Reload the pin set when the previewed route changes while commenting. + useEffect(() => { + if (!commentMode) return; + // Clear stale pins/positions so the old route's dots don't flash on the new page. + setActivePinId(null); setComposeAnchor(null); setComments([]); setPinPositions({}); + loadComments(currentRoute || '/'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentRoute]); + + // Push the current pins to the in-iframe bridge whenever they change OR once + // the (re)loaded preview reports ready — so pins land even after a navigation. + useEffect(() => { + if (!commentMode || !previewReady) return; + postComments({ type: 'renderPins', activeId: activePinId, pins: comments.map((c) => ({ id: c.id, index: c.index, anchorSelector: c.anchorSelector, relX: c.relX, relY: c.relY, resolved: c.resolved })) }); + }, [comments, activePinId, commentMode, previewReady, postComments]); + + // After navigating to another route for goToComment, wait until the preview is + // ready AND that route's comments have loaded (the pin exists), then scroll to + // + activate it. Gating on previewReady is what makes the FIRST click work. + useEffect(() => { + const p = pendingScrollRef.current; + if (!p || !previewReady || (currentRoute || '/') !== (p.route || '/')) return; + if (!comments.some((c) => c.id === p.id)) return; // pins for the new route not loaded yet + pendingScrollRef.current = null; + setActivePinId(p.id); + const t = setTimeout(() => postComments({ type: 'scrollTo', anchorSelector: p.anchorSelector }), 250); + return () => clearTimeout(t); + }, [comments, currentRoute, previewReady, postComments]); + + // Keep the overview list fresh: reload it when open, or when the current + // route's comments change (add/resolve/delete) while it's open. + useEffect(() => { + if (showCommentsList) loadAllComments(); + }, [showCommentsList, comments, loadAllComments]); + + // Receive events from the comments bridge. + useEffect(() => { + if (!previewUrl) return; + let origin: string; + try { origin = new URL(previewUrl).origin; } catch { return; } + const onMsg = (e: MessageEvent) => { + if (e.origin !== origin) return; + const d = e.data as any; + if (!d || d.source !== 'claudable-comments') return; + if (d.type === 'placed') { setActivePinId(null); setComposeAnchor({ anchorSelector: d.anchorSelector, relX: d.relX, relY: d.relY, x: d.x, y: d.y }); } + else if (d.type === 'pinPositions') { const m: Record = {}; (d.positions || []).forEach((p: any) => { m[p.id] = { x: p.x, y: p.y }; }); setPinPositions(m); } + else if (d.type === 'pinClicked') { setComposeAnchor(null); setActivePinId(d.id); } + }; + window.addEventListener('message', onMsg); + return () => window.removeEventListener('message', onMsg); + }, [previewUrl]); + + // Collect runtime errors the preview reports (deduped, capped). + useEffect(() => { + if (!previewUrl) return; + let origin: string; + try { origin = new URL(previewUrl).origin; } catch { return; } + const onMsg = (e: MessageEvent) => { + if (e.origin !== origin) return; + const d = e.data as any; + if (d?.source === 'claudable-errors' && d.type === 'error' && d.error?.message) { + setPreviewErrors((prev) => (prev.some((x) => x.message === d.error.message) ? prev : [...prev.slice(-9), d.error])); + } + }; + window.addEventListener('message', onMsg); + return () => window.removeEventListener('message', onMsg); + }, [previewUrl]); + // Clear on route change or when a new turn starts (errors get re-reported if still present). + useEffect(() => { setPreviewErrors([]); }, [currentRoute]); + useEffect(() => { if (hasActiveRequests) setPreviewErrors([]); }, [hasActiveRequests]); + + const fixPreviewErrors = useCallback(() => { + if (!previewErrors.length) return; + const list = previewErrors.map((e, i) => `${i + 1}. [${e.kind}] ${e.message}${e.at ? ` (${e.at})` : ''}`).join('\n'); + const instruction = `The live preview is throwing these runtime errors on route "${currentRouteRef.current || '/'}":\n\n${list}\n\nFind the cause in the source and fix it. Keep the change minimal and don't introduce new behavior.`; + setPreviewErrors([]); + runActRef.current?.(instruction, []); + }, [previewErrors]); + + const submitNewComment = useCallback(async (body: string): Promise => { + if (!composeAnchor) return false; + const route = currentRoute || '/'; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${API_BASE}/api/projects/${projectId}/comments`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ctrl.signal, + body: JSON.stringify({ route, anchorSelector: composeAnchor.anchorSelector, relX: composeAnchor.relX, relY: composeAnchor.relY, body }), + }); + const j = await r.json().catch(() => null); + if (j?.success) { setComposeAnchor(null); await loadComments(route); return true; } + return false; + } catch { + return false; + } finally { + clearTimeout(timer); + } + }, [composeAnchor, currentRoute, projectId, loadComments]); + + const resolveCommentById = useCallback(async (id: string, resolved: boolean) => { + await fetch(`${API_BASE}/api/projects/${projectId}/comments/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }) }).catch(() => {}); + await loadComments(currentRoute || '/'); + }, [projectId, currentRoute, loadComments]); + + const deleteCommentById = useCallback(async (id: string) => { + await fetch(`${API_BASE}/api/projects/${projectId}/comments/${id}`, { method: 'DELETE' }).catch(() => {}); + setActivePinId(null); + await loadComments(currentRoute || '/'); + }, [projectId, currentRoute, loadComments]); + + const clearAllComments = useCallback(async () => { + if (typeof window !== 'undefined' && !window.confirm('Delete ALL comments in this project (every route)?')) return; + await fetch(`${API_BASE}/api/projects/${projectId}/comments`, { method: 'DELETE' }).catch(() => {}); + setActivePinId(null); setComposeAnchor(null); + await loadComments(currentRoute || '/'); + }, [projectId, currentRoute, loadComments]); + + // Create (or reuse) a public review link and copy it to the clipboard. + const shareReviewLink = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/share`, { method: 'POST' }); + const j = await res.json(); + if (!j.success || !j.data?.token) throw new Error(j.message || 'Could not create share link'); + const link = `${window.location.origin}/share/${j.data.token}`; + try { + await navigator.clipboard.writeText(link); + toast.success('Review link copied to clipboard'); + } catch { + toast.info(`Review link: ${link}`); + } + setShareCopied(true); + setTimeout(() => setShareCopied(false), 2000); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Could not create share link'); + } + }, [projectId, toast]); + + // --- Device frame fit-scaling: keep any device frame inside the pane --- + const currentDevice = DEVICE_PRESETS.find((d) => d.id === deviceId) ?? DEVICE_PRESETS[0]; + const deviceDims = currentDevice.desktop + ? null + : orientation === 'landscape' + ? { w: currentDevice.h!, h: currentDevice.w! } + : { w: currentDevice.w!, h: currentDevice.h! }; + const ddW = deviceDims?.w ?? 0; + const ddH = deviceDims?.h ?? 0; + useEffect(() => { + // Re-run on previewUrl change: the observed element only mounts once the + // preview is up, so without previewUrl in deps the observer would never attach. + const el = deviceViewportRef.current; + if (!el) return; + const compute = () => { + const w = el.clientWidth, h = el.clientHeight; + setDeviceViewport({ w, h }); + if (!ddW || !ddH) { setDeviceScale(1); return; } + const pad = 32; // breathing room around the frame + const s = Math.min(1, (w - pad) / ddW, (h - pad) / ddH); + setDeviceScale(s > 0 ? s : 1); + }; + compute(); + const ro = new ResizeObserver(compute); + ro.observe(el); + return () => ro.disconnect(); + }, [ddW, ddH, previewUrl]); + + // Convert iframe-space pin coords to CONTAINER (screen) coords, accounting for + // the centered, scaled device frame. This lets the comment popovers live OUTSIDE + // the scaled frame (so they render full-size and aren't clipped) while staying + // aligned with the pin dots inside the iframe. + const toScreen = useCallback((x: number, y: number) => { + if (!ddW || !ddH) return { x, y }; // desktop: iframe fills the container 1:1 + const frameLeft = deviceViewport.w / 2 - (ddW * deviceScale) / 2; + const frameTop = deviceViewport.h / 2 - (ddH * deviceScale) / 2; + return { x: frameLeft + x * deviceScale, y: frameTop + y * deviceScale }; + }, [ddW, ddH, deviceViewport.w, deviceViewport.h, deviceScale]); + const screenPinPositions = useMemo(() => { + const out: Record = {}; + for (const [id, p] of Object.entries(pinPositions)) { + out[id] = p.x == null || p.y == null ? { x: null, y: null } : toScreen(p.x, p.y); + } + return out; + }, [pinPositions, toScreen]); + const screenCompose = useMemo(() => (composeAnchor ? { ...composeAnchor, ...toScreen(composeAnchor.x, composeAnchor.y) } : null), [composeAnchor, toScreen]); + + // 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). Also ping the moment + // the tab is refocused — background tabs get their timers throttled/frozen, so + // this both resets the idle clock on return AND proactively rebuilds a preview + // that was already evicted, so it's warming while you look at it. + useEffect(() => { + if (!previewUrl) return; + const ping = () => { + fetch(`${API_BASE}/api/projects/${projectId}/preview/status`, { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + const st = j?.data?.status; + if (st && st !== 'running' && st !== 'starting') start(); // evicted → rebuild now + }) + .catch(() => {}); + }; + const id = setInterval(ping, 5 * 60 * 1000); + const onVisible = () => { if (document.visibilityState === 'visible') ping(); }; + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', onVisible); + return () => { + clearInterval(id); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', onVisible); + }; + }, [previewUrl, projectId, start]); + // Navigate to specific route in iframe const navigateToRoute = (route: string) => { if (previewUrl && iframeRef.current) { @@ -802,6 +1512,8 @@ const persistProjectPreferences = useCallback( // Ensure route starts with / const normalizedRoute = route.startsWith('/') ? route : `/${route}`; const newUrl = `${baseUrl}${normalizedRoute}`; + setPreviewReady(false); // the new page's plugin hasn't reported yet + setBridgeAbsent(false); iframeRef.current.src = newUrl; setCurrentRoute(normalizedRoute); } @@ -820,6 +1532,8 @@ const persistProjectPreferences = useCallback( const baseUrl = previewUrl.split('?')[0] || previewUrl; const url = new URL(baseUrl + normalizedRoute); url.searchParams.set('_ts', Date.now().toString()); + setPreviewReady(false); // wait for the reloaded page's plugin to report before pushing pins + setBridgeAbsent(false); iframeRef.current.src = url.toString(); } catch (error) { console.warn('Failed to refresh preview iframe:', error); @@ -1208,83 +1922,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 { @@ -1299,8 +1937,8 @@ const persistProjectPreferences = useCallback( if (filename === 'package.json') return ; if (filename === 'dockerfile') return ; if (filename?.startsWith('.env')) return ; - if (filename === 'readme.md') return ; - if (filename?.includes('config')) return ; + if (filename === 'readme.md') return ; + if (filename?.includes('config')) return ; switch (ext) { case 'tsx': @@ -1324,7 +1962,7 @@ const persistProjectPreferences = useCallback( return ; case 'md': case 'markdown': - return ; + return ; case 'py': return ; case 'sh': @@ -1363,9 +2001,9 @@ const persistProjectPreferences = useCallback( case 'ini': case 'conf': case 'config': - return ; + return ; default: - return ; + return ; } } @@ -1678,6 +2316,8 @@ const persistProjectPreferences = useCallback( }); }; + runActRef.current = (m, i) => runAct(m, i); + currentRouteRef.current = currentRoute; async function runAct(messageOverride?: string, externalImages?: any[]) { let finalMessage = messageOverride || prompt; const imagesToUse = externalImages || uploadedImages; @@ -1819,6 +2459,7 @@ const persistProjectPreferences = useCallback( conversationId: conversationId || undefined, requestId, selectedModel, + thinkingMode, }; console.log('📸 Sending request to act API:', { @@ -2069,7 +2710,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 +2719,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,22 +2773,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` OR on + // unmount/navigation. Killing the dev server forced a ~15-30s cold recompile + // every time you returned to (or refreshed) a project. Leaving previews + // running keeps multiple projects warm, so switching back is instant. The + // PreviewManager bounds resource use on its own: it reaps idle previews + // (PREVIEW_IDLE_TIMEOUT_MS) and LRU-evicts when the port pool is full. 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(() => {}); - } + // Stop deploy/publish pollers so they don't keep hitting the API after the + // chat page unmounts (e.g. navigating back to the dashboard). The preview + // itself is deliberately left running (see note above) so it stays warm. + if (deployPollRef.current) { clearInterval(deployPollRef.current); deployPollRef.current = null; } + if (giteaPollRef.current) { clearInterval(giteaPollRef.current); giteaPollRef.current = null; } }; }, [projectId]); @@ -2235,19 +2883,46 @@ const persistProjectPreferences = useCallback( `} -
-
- {/* Left: Chat window */} + {/* 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: Visual editor inspector (edit mode) or Chat window */}
+ {editMode ? ( + setEditMode(false)} + persisting={persistingEdit} + busy={hasActiveRequests} + /> + ) : showCommentsList ? ( + setShowCommentsList(false)} + /> + ) : ( + <> {/* Chat header */} -
+
-

{projectName || 'Loading...'}

+

{projectName || 'Loading...'}

{projectDescription && ( -

+

{projectDescription}

)} @@ -2270,6 +2945,8 @@ const persistProjectPreferences = useCallback( { setTimeout(() => refreshPreview(), 400); }} onAddUserMessage={(handlers) => { console.log('🔄 [HandlerSetup] ChatLog provided new handlers, updating references'); messageHandlersRef.current = handlers; @@ -2306,12 +2983,25 @@ const persistProjectPreferences = useCallback( {/* Simple input area */}
+ {queuedMessages.length > 0 && ( +
+ {queuedMessages.length} message{queuedMessages.length > 1 ? 's' : ''} queued — will send after the current turn. + +
+ )} { - // Pass images to runAct - runAct(message, images); + // CLI-style: you can always type. If a turn is in progress, + // QUEUE the message (it runs when the current turn finishes) + // instead of blocking. Use Stop to interrupt the current turn. + if (isRunning || hasActiveRequests) { + setQueuedMessages((q) => [...q, { message, images: images || [] }]); + } else { + runAct(message, images); + } }} - disabled={isRunning} + // Never disabled — always allow typing/sending (queued while busy). + disabled={false} placeholder={mode === 'act' ? "Ask Claudable..." : "Chat with Claudable..."} mode={mode} onModeChange={setMode} @@ -2328,48 +3018,135 @@ const persistProjectPreferences = useCallback( cliChangeDisabled={isUpdatingModel} />
+ + )} +
+ + {/* Draggable divider to resize the chat / preview split */} +
+ {/* wider invisible hit area for easier grabbing */} +
+ {/* visible grip so the divider is discoverable */} +
+ + + +
{/* Right: Preview/Code area */} -
+
{/* Content area */}
{/* Controls Bar */} -
+
{/* Toggle switch */} -
+
- + + {/* Inline visual editor toggle — only meaningful with a live preview */} + {previewUrl && ( + + )} + + {/* Comment mode toggle */} + {previewUrl && ( + + )} + + {/* Show all comments in a left-pane list */} + {commentMode && previewUrl && ( + + )} + + {/* Clear all comments (project-wide) */} + {commentMode && previewUrl && ( + + )} + {/* Center Controls */} - {showPreview && previewUrl && ( + {showPreview && !editMode && !commentMode && previewUrl && (
{/* Route Navigation */} -
- +
+ - / + / @@ -2396,42 +3173,79 @@ const persistProjectPreferences = useCallback( {/* Action Buttons Group */}
- - {/* Device Mode Toggle */} -
- - + + {/* Open preview in a new tab */} + + + {/* Device selector dropdown */} +
+
+ + {!currentDevice.desktop && ( + + )} +
+ {deviceMenuOpen && ( + <> +
setDeviceMenuOpen(false)} /> +
+ {DEVICE_PRESETS.map((d) => ( + + ))} +
+ + )}
@@ -2439,201 +3253,95 @@ const persistProjectPreferences = useCallback(
+ + {/* Architecture info */} + {/* Settings Button */} - - + + {/* Skills */} + + + {/* Import from Claude Design */} + + {/* Stop Button */} {showPreview && previewUrl && ( - )} + {/* Share a review link */} + {showPreview && previewUrl && ( + + )} + {/* Publish/Update */} {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} - - -
-
- )}
)} + + {/* My account — rightmost, so Publish stays to its left */} +
@@ -2649,17 +3357,22 @@ const persistProjectPreferences = useCallback( style={{ height: '100%' }} > {previewUrl ? ( -
-
+
-