import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { useElectricalStore } from '../../store/useElectricalStore'; import { type VerificationResult } from '../../simulation/verify/circuitVerifier'; import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore'; import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal'; import type { BoardKind, LanguageMode } from '../../types/board'; import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board'; import { compileCode } from '../../services/compilation'; import { compileRom, isChipProgramFile, formatForFile, targetForChip, } from '../../services/romCompileService'; import { compileChip } from '../../services/chipCompileService'; import { clearChipDrives } from '../../simulation/customChips/chipPinDrives'; import { requestElectricalResolve } from '../../simulation/spice/electricalResolveHook'; import { reportRunEvent } from '../../services/metricsService'; import { useProjectStore } from '../../store/useProjectStore'; import { LibraryManagerModal } from '../simulator/LibraryManagerModal'; import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal'; import { parseCompileResult } from '../../utils/compilationLogger'; import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger'; import { exportToWokwiZip } from '../../utils/wokwiZip'; import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; import { readFirmwareFile } from '../../utils/firmwareLoader'; import { trackCompileCode, trackRunSimulation, trackStopSimulation, trackResetSimulation, trackOpenLibraryManager, } from '../../utils/analytics'; import './EditorToolbar.css'; /** * Output-console group for circuit pre-flight + runtime faults. Routing these * into the compile console (instead of an inline toolbar toast that overlapped * the Run/Stop buttons) gives one unified, red-coloured diagnostics log — * Proteus-style. id is matched when clearing so the findings survive an * auto-compile triggered by the same Run. */ const CIRCUIT_CHECK_TARGET: CompileTarget = { id: 'circuit-check', label: 'Circuit check', kind: 'board', }; /** * Clear the output drives of every custom chip on the canvas and re-solve, so * chip-driven LEDs go dark on Stop. A chip drives its nets via its own SPICE * voltage sources (registered in chipPinDrives); stopBoard / electrical-pause * don't touch those, so without this the LEDs would freeze at their last frame. */ function clearAllChipDrives(): void { const comps = useSimulatorStore.getState().components; let any = false; for (const c of comps) { if (c.metadataId === 'custom-chip') { clearChipDrives(c.id); any = true; } } if (any) requestElectricalResolve(); } /** * Boards whose firmware runs in a QEMU worker rather than a client-side AVR * core. They can start without a pre-stored `compiledProgram`. Shared by * handleRun and handleRunAll so the two paths can't drift. */ function isQemuBoardKind(kind: BoardKind | undefined): boolean { if (!kind) return false; return ( isPiBoardKind(kind) || kind === 'esp32' || kind === 'esp32-s3' || kind === 'esp32-cam' || kind === 'esp32-c3' || kind === 'esp32-devkit-c-v4' || kind === 'wemos-lolin32-lite' || kind === 'xiao-esp32-s3' || kind === 'arduino-nano-esp32' || kind === 'xiao-esp32-c3' || kind === 'aitewinrobot-esp32c3-supermini' ); } interface EditorToolbarProps { consoleOpen: boolean; setConsoleOpen: (open: boolean | ((v: boolean) => boolean)) => void; compileLogs: CompilationLog[]; setCompileLogs: (logs: CompilationLog[] | ((prev: CompilationLog[]) => CompilationLog[])) => void; /** * Optional element rendered between the left action group and the right * action group. Normally empty (the slot just acts as a flexible spacer * that keeps the right action icons pinned); private overlays may inject * deployment-specific content here without forking the toolbar. */ centerSlot?: React.ReactNode; /** * Optional extra elements rendered after the built-in right-group buttons * (Libraries / Import-Export / Output Console). Used by private overlays * to add deployment-specific actions without forking the toolbar. */ rightSlot?: React.ReactNode; } const BOARD_PILL_ICON: Record = { 'arduino-uno': '⬤', 'arduino-nano': '▪', 'arduino-mega': '▬', 'raspberry-pi-pico': '◆', 'raspberry-pi-3': '⬛', 'raspberry-pi-4': '⬛', 'raspberry-pi-5': '⬛', esp32: '⬡', 'esp32-s3': '⬡', 'esp32-c3': '⬡', 'stm32-bluepill': '◈', 'stm32-blackpill': '◈', 'stm32-bluepill-f103cb': '◈', 'stm32-blackpill-f401': '◈', 'stm32-f4-discovery': '◈', 'stm32-olimex-h405': '◈', 'stm32-netduino-plus2': '◈', 'stm32-netduino2': '◈', }; const BOARD_PILL_COLOR: Record = { 'arduino-uno': '#4fc3f7', 'arduino-nano': '#4fc3f7', 'arduino-mega': '#4fc3f7', 'raspberry-pi-pico': '#ce93d8', 'raspberry-pi-3': '#ef9a9a', 'raspberry-pi-4': '#ef9a9a', 'raspberry-pi-5': '#ef9a9a', esp32: '#a5d6a7', 'esp32-s3': '#a5d6a7', 'esp32-c3': '#a5d6a7', 'stm32-bluepill': '#80cbc4', 'stm32-blackpill': '#b0bec5', 'stm32-bluepill-f103cb': '#80cbc4', 'stm32-blackpill-f401': '#b0bec5', 'stm32-f4-discovery': '#90caf9', 'stm32-olimex-h405': '#a5d6a7', 'stm32-netduino-plus2': '#ce93d8', 'stm32-netduino2': '#ce93d8', }; export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compileLogs, setCompileLogs, centerSlot, rightSlot, }: EditorToolbarProps) => { const { t } = useTranslation(); const { files, codeChangedSinceLastCompile, markCompiled } = useEditorStore(); const { boards, activeBoardId, compileBoardProgram, loadMicroPythonProgram, setBoardLanguageMode, updateBoard, startBoard, stopBoard, resetBoard, // legacy compat startSimulation, stopSimulation, resetSimulation, running, compiledHex, } = useSimulatorStore(); const activeBoard = boards.find((b) => b.id === activeBoardId) ?? boards[0]; const currentProject = useProjectStore((s) => s.currentProject); // Board-less mode: digital / analog SPICE-only circuits. The Run / Stop // buttons toggle the SPICE solver's `paused` flag — pausing freezes every // LED at its current brightness so the user can inspect the state, and // resuming flushes the most recent switch toggle through the engine. const electricalPaused = useElectricalStore((s) => s.paused); const setElectricalPaused = useElectricalStore((s) => s.setPaused); const isBoardless = boards.length === 0; const digitalRunning = isBoardless && !electricalPaused; // Any board actually running — the correct multi-target signal for the // Run-All / Stop buttons (the flat `running` flag only tracks the ACTIVE // board, so it misreports a multi-board or non-active-board run). const anyBoardRunning = boards.some((b) => b.running); // Multi-board: the primary Run button runs ALL boards (the whole wired // project is one system — running a subset is almost never intended), with a // split-menu to still run just the active board. Single-board is unchanged. const isMultiBoard = boards.length > 1; // A "run target" is a board OR a programmable custom-chip (a CPU that runs a // ROM). When there is more than one target — two boards, a board + a chip, or // several chips — the unified Compile-All / Run-All buttons appear and act on // every target, the same way multiple Arduinos behave. Resolved as a number // so the toolbar only re-renders when the count changes. The predicate is a // cheap string test (no JSON.parse) since this selector runs on every store // change, including high-frequency simulation churn. (The compile/run paths // deliberately act on ALL custom chips, not just programmable ones.) const targetCount = useSimulatorStore((s) => { let chips = 0; for (const c of s.components) { if (c.metadataId !== 'custom-chip') continue; const p = c.properties as Record; if (String(p?.programFile ?? '').trim() || String(p?.chipJson ?? '').includes('"programTargets"')) chips++; } return s.boards.length + chips; }); // Circuit-verification modal state. When `pendingRun` is non-null we've // already paid the cost of solving + analysing — the user can either // bail out or proceed by running `pendingRun()`. const [verification, setVerification] = useState(null); const pendingRunRef = useRef<(() => void) | null>(null); // Helper: report a Run event to the backend for analytics. Resolves the // FQBN from the board kind so the backend can group by family/fqbn. const reportRun = useCallback( (boardKind: BoardKind | undefined) => { const fqbn = boardKind ? BOARD_KIND_FQBN[boardKind] : null; void reportRunEvent({ project_id: currentProject?.id ?? null, board_fqbn: fqbn ?? null, }); }, [currentProject], ); const [compiling, setCompiling] = useState(false); // True while the pre-flight circuit verification SPICE solve is running. // Drives the Run-button spinner so the user gets feedback during the // (sometimes multi-second, cold-worker) solve instead of a dead button. const [verifying, setVerifying] = useState(false); // Synchronous re-entrancy guard: a click while a run/verify is already in // flight is ignored, so rapid clicks can't stack multiple verifications. const runInFlightRef = useRef(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [libManagerOpen, setLibManagerOpen] = useState(false); const [pendingLibraries, setPendingLibraries] = useState([]); const [installModalOpen, setInstallModalOpen] = useState(false); const importInputRef = useRef(null); const firmwareInputRef = useRef(null); const toolbarRef = useRef(null); const [missingLibHint, setMissingLibHint] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const moreMenuRef = useRef(null); // Split-button menu for the multi-board Run control ("Run all" / "Run active only"). const [runMenuOpen, setRunMenuOpen] = useState(false); const runMenuRef = useRef(null); // Open the Library Manager when another component (e.g. the velxio.json entry // in the FileExplorer) asks for it via a window event. Avoids prop-drilling // the modal state down to the explorer. useEffect(() => { const open = () => setLibManagerOpen(true); window.addEventListener('velxio-open-library-manager', open); return () => window.removeEventListener('velxio-open-library-manager', open); }, []); // Surface a runtime circuit fault (e.g. an LED that burnt out from // overcurrent during the live SPICE solve) in the output console, in red, // under the "Circuit check" group — same place as the pre-flight findings. // (Previously an inline toolbar toast that overlapped the Run/Stop buttons.) // We do NOT auto-open the console here: the continuous solver can fault on // load, and popping the console open then would be intrusive. The pre-flight // (on Run) opens it; this entry then lands in the already-open log. useEffect(() => { const onFault = (e: Event) => { const detail = (e as CustomEvent).detail as { message?: string } | undefined; if (!detail?.message) return; const text = detail.message; setCompileLogs((prev) => [ ...prev, { timestamp: new Date(), type: 'error', message: text, target: CIRCUIT_CHECK_TARGET }, ]); }; window.addEventListener('velxio-circuit-fault', onFault); return () => window.removeEventListener('velxio-circuit-fault', onFault); }, [setCompileLogs]); useEffect(() => { if (!moreMenuOpen) return; const onClickOutside = (e: MouseEvent) => { if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) { setMoreMenuOpen(false); } }; const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') setMoreMenuOpen(false); }; document.addEventListener('mousedown', onClickOutside); document.addEventListener('keydown', onEsc); return () => { document.removeEventListener('mousedown', onClickOutside); document.removeEventListener('keydown', onEsc); }; }, [moreMenuOpen]); // Close the Run split-menu on outside click / Escape (mirrors the more-menu). useEffect(() => { if (!runMenuOpen) return; const onClickOutside = (e: MouseEvent) => { if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) { setRunMenuOpen(false); } }; const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') setRunMenuOpen(false); }; document.addEventListener('mousedown', onClickOutside); document.addEventListener('keydown', onEsc); return () => { document.removeEventListener('mousedown', onClickOutside); document.removeEventListener('keydown', onEsc); }; }, [runMenuOpen]); // Compile All / Run All — runs sequentially, logs to console (no dialog) const [compileAllRunning, setCompileAllRunning] = useState(false); const addLog = useCallback( (log: CompilationLog) => { setCompileLogs((prev: CompilationLog[]) => [...prev, log]); }, [setCompileLogs], ); /** * Make every custom-chip on the canvas runnable: compile its C source to * WASM (when it has none yet) and, for programmable CPU chips, assemble or * compile the program file it references into ROM bytes — stashing both on * the chip component's `properties` so the next simulation start picks them * up. Non-fatal by design: a chip that fails to compile is logged and * skipped so the board itself still runs. */ const prepareCustomChips = useCallback( async ( chips: { id: string; properties: Record }[], boardFiles: { name: string; content: string }[], ) => { const codeChanged = useEditorStore.getState().codeChangedSinceLastCompile; const updateComponent = useSimulatorStore.getState().updateComponent; let failed = 0; for (const chip of chips) { // Re-read the freshest properties each iteration (an earlier chip's // update doesn't touch this one, but be defensive). const live = useSimulatorStore.getState().components.find((c) => c.id === chip.id); const props = { ...(live?.properties ?? chip.properties) } as Record; const chipLabel = String(props.chipName ?? 'custom chip'); const sourceC = String(props.sourceC ?? ''); const chipJson = String(props.chipJson ?? '{}'); let changed = false; // Stamp every line for this chip with its target so the console groups // it under its own section (alongside the boards). const chipTarget: CompileTarget = { id: chip.id, label: chipLabel, kind: 'chip' }; const clog = (type: CompilationLog['type'], message: string) => addLog({ timestamp: new Date(), type, message, target: chipTarget }); // 1. C -> WASM. Only when missing — the chip designer fills this too. if (!String(props.wasmBase64 ?? '') && sourceC) { clog('info', `Compiling chip "${chipLabel}" to WASM...`); try { const r = await compileChip(sourceC, chipJson); if (r.success && r.wasm_base64) { props.wasmBase64 = r.wasm_base64; changed = true; clog('success', `Chip "${chipLabel}" compiled (${r.byte_size} B WASM).`); } else { clog( 'error', `Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`, ); failed++; } } catch (e) { clog( 'error', `Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`, ); failed++; } } // 2. program file -> ROM bytes (programmable CPU chips). Recompile // when there's no ROM yet or the user edited code since last build. const programFile = String(props.programFile ?? '').trim(); if (programFile && (!String(props.romBytes ?? '') || codeChanged)) { // The program lives in the chip's OWN editor group (its collapsible // section in the file explorer), separate from the board sketch. // Fall back to the board files for older projects that still carried // the program alongside sketch.ino in the board group. const chipGroupFiles = useEditorStore .getState() .getGroupFiles(chipFileGroupId(chip.id)); const file = chipGroupFiles.find((f) => f.name === programFile) ?? boardFiles.find((f) => f.name === programFile); if (!file) { clog('error', `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`); failed++; } else { const target = targetForChip(chipJson); const fmt = formatForFile(programFile); clog( 'info', `Assembling "${programFile}" (target=${target}, format=${fmt}) for chip "${chipLabel}"...`, ); try { const rr = await compileRom(file.content, target, fmt); if (rr.success && rr.rom_base64) { props.romBytes = rr.rom_base64; props.programFile = programFile; changed = true; clog('success', `ROM ready: ${rr.byte_size} B injected into "${chipLabel}".`); } else { clog( 'error', `ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`, ); failed++; } } catch (e) { clog( 'error', `ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`, ); failed++; } } } if (changed) { updateComponent(chip.id, { properties: props } as any); } } return { failed }; }, [addLog], ); const handleCompile = async () => { setCompiling(true); setMessage(null); setConsoleOpen(true); // Wipe the previous build's output before we append anything new. // Issue #209: lingering logs from prior compiles made it impossible // to tell the latest errors / warnings apart from stale ones. // Keep the "Circuit check" findings, though: a Run auto-compiles right // after the pre-flight verification logs them, and clearing here would // wipe a circuit warning the user just triggered. setCompileLogs((prev) => prev.filter((l) => l.target?.id === CIRCUIT_CHECK_TARGET.id)); trackCompileCode(); // ── Custom-chip preparation ───────────────────────────────────────── // Any custom-chip on the canvas is made "live" here so a single // Compile / Run is enough — no separate trip through the chip designer // or a manual ROM compile. For every custom-chip we: // 1. compile its C source to WASM (when it has none yet), and // 2. for programmable CPU chips, assemble/compile the program file it // points at (larson.s, chaser.c, …) into ROM bytes. // Both artefacts are stashed on the chip component's `properties`; // CustomChipPart reads wasmBase64 + romBytes at simulation start. // // The chip program files are ALSO kept out of the Arduino sketch compile // below (see `chipProgramFiles`) — otherwise arduino-cli/avr-gcc would // try to build e.g. chaser.c and choke on SDCC-only syntax such as // `__at(0xC000)`, which is exactly what broke the Z80 examples. const componentsForCompile = useSimulatorStore.getState().components; const customChips = componentsForCompile.filter((c) => c.metadataId === 'custom-chip'); const chipProgramFiles = new Set(); for (const chip of customChips) { const pf = String((chip.properties as any)?.programFile ?? '').trim(); if (pf) chipProgramFiles.add(pf); } if (customChips.length > 0) { const boardFiles = activeBoard?.activeFileGroupId ? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId) : files; await prepareCustomChips(customChips, boardFiles); } // ── End custom-chip preparation ───────────────────────────────────── const kind = activeBoard?.boardKind; // The active board's console target, defined up front so EVERY board path // (Pi, MicroPython, arduino-cli, errors) groups its lines under one section. const boardLabel = activeBoard ? boardDisplayName(activeBoard) : 'Unknown'; const boardTarget: CompileTarget | undefined = activeBoardId ? { id: activeBoardId, label: boardLabel, kind: 'board' } : undefined; const blog = (type: CompilationLog['type'], message: string) => addLog({ timestamp: new Date(), type, message, target: boardTarget }); // Raspberry Pi 3B doesn't need arduino-cli compilation if (isPiBoardKind(kind)) { blog('info', 'Raspberry Pi 3B: no compilation needed — run Python scripts directly.'); setMessage({ type: 'success', text: 'Ready (no compilation needed)' }); setCompiling(false); return; } // MicroPython mode — no backend compilation needed if (activeBoard?.languageMode === 'micropython' && activeBoardId) { blog('info', 'MicroPython: loading firmware and user files...'); try { const groupFiles = useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId); const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content })); await loadMicroPythonProgram(activeBoardId, pyFiles); blog('success', 'MicroPython firmware loaded successfully'); setMessage({ type: 'success', text: 'MicroPython ready' }); } catch (err) { const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython'; blog('error', errMsg); setMessage({ type: 'error', text: errMsg }); } finally { setCompiling(false); } return; } const fqbn = kind ? BOARD_KIND_FQBN[kind] : null; if (!fqbn) { blog('error', `No FQBN for board kind: ${kind}`); setMessage({ type: 'error', text: 'Unknown board' }); setCompiling(false); return; } blog('info', `Starting compilation for ${boardLabel} (${fqbn})...`); try { const groupFiles = activeBoard?.activeFileGroupId ? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId) : files; const sketchFiles = (groupFiles.length > 0 ? groupFiles : files) // Keep chip-program files (a chip's programFile, or .s/.asm/.hex/.bin) // out of the arduino-cli build — they're compiled to ROM above, not // Arduino sources, and avr-gcc chokes on e.g. SDCC's __at(). .filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name)) .map((f) => ({ name: f.name, content: f.content, })); // Stream live cmake + ninja output into the compilation console as // it arrives, instead of waiting for the whole build to finish. // Each poll the backend returns the cumulative stdout buffer; we // append only the delta since the previous call as 'info' lines. let lastStreamedLen = 0; const result = await compileCode( sketchFiles, fqbn, currentProject?.id ?? null, ({ stdout }) => { if (stdout.length <= lastStreamedLen) return; const delta = stdout.slice(lastStreamedLen); lastStreamedLen = stdout.length; const newLines = delta.split('\n').filter((s) => s.trim()); if (!newLines.length) return; const now = new Date(); setCompileLogs((prev: CompilationLog[]) => [ ...prev, ...newLines.map((line) => ({ timestamp: now, type: 'info' as const, message: line, target: boardTarget, })), ]); }, // Per-board ESP32 build options + SPIFFS uploads. Undefined for AVR // / RP2040 boards (ignored on those paths by the backend). { boardOptions: activeBoard?.boardOptions, spiffsFiles: activeBoard?.spiffsFiles, // P2.4 — THIS board's declared manifest (compile scope). Per-board so // two boards can use different libraries without clashing. libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null, // Pure ESP-IDF mode (issue #139): tell the backend to compile the // user's app_main() sources without the arduino-esp32 component. language: activeBoard?.languageMode === 'espidf' ? 'espidf' : undefined, }, ); // After the build settles, append the structured analysis on top of // the live stream — parseCompileResult highlights FAILED blocks and // tags compiler errors with type='error', which the console uses for // colour + the auto-switch-to-errors filter. const resultLogs = parseCompileResult(result, boardLabel, boardTarget); setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]); if (result.success) { const program = result.hex_content ?? result.binary_content ?? null; if (program && activeBoardId) { compileBoardProgram(activeBoardId, program); if (result.has_wifi !== undefined) { updateBoard(activeBoardId, { hasWifi: result.has_wifi }); } } setMessage({ type: 'success', text: 'Compiled successfully' }); markCompiled(); setMissingLibHint(false); } else { const errText = result.error || result.stderr || 'Compile failed'; setMessage({ type: 'error', text: errText }); // Issue #208: drop the previous successful program from this // board so a subsequent Run cannot silently execute stale code // that doesn't match the editor any more. The Run button gates // on `!compiledProgram` and will refuse + force a re-compile. if (activeBoardId) { updateBoard(activeBoardId, { compiledProgram: null }); } // Detect missing library errors — common patterns: // "No such file or directory" for #include, "fatal error: XXX.h" const looksLikeMissingLib = /No such file or directory|fatal error:.*\.h|library not found/i.test(errText); setMissingLibHint(looksLikeMissingLib); } } catch (err) { const errMsg = err instanceof Error ? err.message : 'Compile failed'; blog('error', errMsg); setMessage({ type: 'error', text: errMsg }); } finally { setCompiling(false); } }; // Track whether we should auto-run after compilation completes const autoRunAfterCompile = useRef(false); /** * Pre-flight safety check: solves the current circuit and flags shorts, * LED over-current and resistor over-power. Returns the result. When the * solver fails to converge (degenerate netlist, no power source, …) we * silently report a clean result so the user isn't blocked on circuits * that aren't physically meaningful yet. */ const runVerification = useCallback( (): Promise => verifyCircuitFromStore(), [], ); /** * Returns true if the caller should proceed inline. All findings are written * to the output console (red errors / orange warnings, "Circuit check" * group). If the verifier finds errors we also stash a resume callback in * `pendingRunRef` and pop the verification modal; the resume callback * re-enters `handleRun` with `skipVerify = true` so we don't loop. * Warnings-only results don't block — the console entry is enough. */ const checkOrBlock = useCallback( async (resume: () => void): Promise => { const result = await runVerification(); if (!result) return true; if (result.errors.length === 0 && result.warnings.length === 0) return true; // Write every finding to the output console under "Circuit check" — red // for errors, orange for warnings — so there's one persistent, unified // diagnostics log next to the compiler output (Proteus-style). Replace // any prior circuit-check entries so repeated runs stay clean, and open // the console so the findings are visible. const now = new Date(); setCompileLogs((prev) => [ ...prev.filter((l) => l.target?.id !== CIRCUIT_CHECK_TARGET.id), ...result.errors.map((e) => ({ timestamp: now, type: 'error' as const, message: e.message, target: CIRCUIT_CHECK_TARGET, })), ...result.warnings.map((w) => ({ timestamp: now, type: 'warning' as const, message: w.message, target: CIRCUIT_CHECK_TARGET, })), ]); setConsoleOpen(true); // Warnings only — non-blocking; the console entry is enough, run continues. if (result.errors.length === 0) return true; // Errors → also pop the modal so the user makes an explicit Run-anyway / // Cancel decision; the console keeps the persistent red record. pendingRunRef.current = resume; setVerification(result); return false; }, [runVerification, setCompileLogs, setConsoleOpen], ); const handleRun = async (skipVerify = false) => { console.log('[handleRun] click', { activeBoardId, running, codeChangedSinceLastCompile }); // Pre-flight: solve the circuit and check for shorts / overcurrent / // overpower. If anything trips we hand control to the modal, which // resumes by calling `handleRun(true)` for "Run anyway". if (!skipVerify) { // The verification solve can take a second or two (cold ngspice worker). // Show the Run-button spinner and ignore re-clicks while it runs — the // button otherwise looks idle and gets clicked repeatedly, stacking // multiple verifications. if (runInFlightRef.current) return; runInFlightRef.current = true; setVerifying(true); let ok = false; try { ok = await checkOrBlock(() => handleRun(true)); } finally { setVerifying(false); runInFlightRef.current = false; } if (!ok) return; } // Board-less circuits have no MCU to start. If there are custom-chip CPUs // on the canvas, compile them (WASM + ROM) and re-attach so they pick up // the fresh WASM — Velxio runs custom chips with no Arduino/ESP32 board, // as a general-purpose electronics simulator. Then resume the electrical // solver (replays any switch toggles captured while paused). if (isBoardless) { const customChips = useSimulatorStore .getState() .components.filter((c) => c.metadataId === 'custom-chip'); if (customChips.length > 0) { setCompiling(true); setConsoleOpen(true); // Fresh chip output, but keep the circuit pre-flight findings just // logged by checkOrBlock so they survive a "Run anyway". setCompileLogs((prev) => prev.filter((l) => l.target?.id === CIRCUIT_CHECK_TARGET.id)); try { await prepareCustomChips(customChips, files); } catch (e) { addLog({ timestamp: new Date(), type: 'error', message: e instanceof Error ? e.message : String(e), }); } setCompiling(false); // Force the chip parts to re-attach with their freshly compiled WASM. useSimulatorStore.getState().restartParts(); } setElectricalPaused(false); setMessage(null); return; } if (activeBoardId) { const board = boards.find((b) => b.id === activeBoardId); console.log('[handleRun] active board', { id: board?.id, kind: board?.boardKind, hasCompiledProgram: !!board?.compiledProgram, compiledProgramLen: board?.compiledProgram?.length ?? 0, }); // MicroPython mode: stop any running session first, then reload firmware + start if (board?.languageMode === 'micropython') { trackRunSimulation(board.boardKind); reportRun(board.boardKind); // Always stop the current session so the new run gets a clean QEMU boot. // This also prevents the double start_esp32 that occurs when the bridge // is already connected and startBoard() is called again. if (board.running) { stopBoard(activeBoardId); // Give the WebSocket a moment to close cleanly before reconnecting. await new Promise((resolve) => setTimeout(resolve, 300)); } setCompiling(true); setMessage(null); const mpyTarget: CompileTarget = { id: activeBoardId, label: boardDisplayName(board), kind: 'board', }; const mlog = (type: CompilationLog['type'], message: string) => addLog({ timestamp: new Date(), type, message, target: mpyTarget }); mlog('info', 'MicroPython: loading firmware and user files...'); try { const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId); const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content })); await loadMicroPythonProgram(activeBoardId, pyFiles); mlog('success', 'MicroPython firmware loaded'); } catch (err) { const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython'; mlog('error', errMsg); setMessage({ type: 'error', text: errMsg }); setCompiling(false); return; } setCompiling(false); startBoard(activeBoardId); setMessage(null); return; } const isQemuBoard = isQemuBoardKind(board?.boardKind); // QEMU boards: auto-compile if no firmware available yet if (isQemuBoard) { console.log('[handleRun] QEMU path'); // Clean restart when the board is already running. Esp32Bridge.connect() // is a no-op while the socket is non-CLOSED, so startBoard() on a live // session does NOTHING — and if the backend QEMU session has since died // but the frontend socket is still zombie (CONNECTING/OPEN/CLOSING), the // user sees a dead sim that only a page reload fixes. This is the exact // "el agente terminó, di Run y no funcionó; recargué y sí" report: the // agent's run_simulation left the board running, so the user's Run // no-op'd. Stop first (closes the WS), let it settle, then boot fresh — // mirrors what the MicroPython branch above already does. if (board?.running) { stopBoard(activeBoardId); await new Promise((resolve) => setTimeout(resolve, 300)); } if (!board?.compiledProgram || codeChangedSinceLastCompile) { console.log('[handleRun] auto-compile + run'); autoRunAfterCompile.current = true; await handleCompile(); const updatedBoard = useSimulatorStore .getState() .boards.find((b) => b.id === activeBoardId); console.log('[handleRun] after compile', { hasCompiledProgram: !!updatedBoard?.compiledProgram, compiledProgramLen: updatedBoard?.compiledProgram?.length ?? 0, autoRunFlag: autoRunAfterCompile.current, }); if (autoRunAfterCompile.current) { autoRunAfterCompile.current = false; if (updatedBoard?.compiledProgram) { trackRunSimulation(updatedBoard.boardKind); reportRun(updatedBoard.boardKind); console.log('[handleRun] → startBoard', activeBoardId); startBoard(activeBoardId); setMessage(null); } else { // handleCompile returned without producing a firmware/program. // Most common causes: arduino-cli unreachable, ESP-IDF compile // error in the user's sketch, MicroPython firmware download // failed, or the bridge rejected the load. handleCompile has // already addLog'd the underlying error — surface a top-level // toast too so the user knows their Run click didn't silently // succeed. const isMicropython = updatedBoard?.languageMode === 'micropython'; const errText = isMicropython ? 'MicroPython firmware did not load. Click "Load MicroPython" to retry, or check the console for the underlying error.' : 'Compilation produced no firmware. Check the output console for the underlying error.'; console.warn('[handleRun] compile finished but no compiledProgram — not starting'); setMessage({ type: 'error', text: errText }); addLog({ timestamp: new Date(), type: 'error', message: errText }); } } return; } trackRunSimulation(board?.boardKind); reportRun(board?.boardKind); console.log('[handleRun] → startBoard (already compiled)', activeBoardId); startBoard(activeBoardId); setMessage(null); return; } // Auto-compile if no program or code changed since last compile if (!board?.compiledProgram || codeChangedSinceLastCompile) { autoRunAfterCompile.current = true; await handleCompile(); // After compile, check if it succeeded and run const updatedBoard = useSimulatorStore .getState() .boards.find((b) => b.id === activeBoardId); if (autoRunAfterCompile.current && updatedBoard?.compiledProgram) { autoRunAfterCompile.current = false; trackRunSimulation(updatedBoard.boardKind); reportRun(updatedBoard.boardKind); startBoard(activeBoardId); setMessage(null); } else { autoRunAfterCompile.current = false; } return; } trackRunSimulation(board?.boardKind); reportRun(board?.boardKind); startBoard(activeBoardId); setMessage(null); return; } // Legacy fallback if (!compiledHex || codeChangedSinceLastCompile) { autoRunAfterCompile.current = true; await handleCompile(); const hex = useSimulatorStore.getState().compiledHex; if (autoRunAfterCompile.current && hex) { autoRunAfterCompile.current = false; trackRunSimulation(); reportRun(undefined); startSimulation(); setMessage(null); } else { autoRunAfterCompile.current = false; } } else { trackRunSimulation(); reportRun(undefined); startSimulation(); setMessage(null); } }; const handleStop = () => { trackStopSimulation(); if (isBoardless) { // Freeze the chip tick (the paused flag) AND clear the chip's output // drives so its LEDs go dark on Stop — not frozen at their last frame. setElectricalPaused(true); clearAllChipDrives(); setMessage(null); return; } // Stop EVERY running board — Run-All can start several, and leaving any // running keeps chips ticking (their gate is boards.some(running)). const runningBoards = useSimulatorStore.getState().boards.filter((b) => b.running); if (runningBoards.length > 0) runningBoards.forEach((b) => stopBoard(b.id)); else if (activeBoardId) stopBoard(activeBoardId); else stopSimulation(); // A chip wired to a board drives its LEDs via its own SPICE sources, which // stopBoard doesn't touch — clear them so those LEDs also go dark. clearAllChipDrives(); setMessage(null); }; const handleReset = () => { trackResetSimulation(); if (activeBoardId) resetBoard(activeBoardId); else resetSimulation(); setMessage(null); }; /** * Compile every board on the canvas sequentially. Progress + per-board * results stream to the existing compilation console — no separate dialog. * Returns the count of boards that ended up with a runnable program (so * Run All can use it to decide whether to proceed to start them). */ const compileAllBoards = async (): Promise<{ ok: number; failed: number }> => { const boardsList = useSimulatorStore.getState().boards; // Every custom-chip is a target too — Compile-All / Run-All build chips // (WASM + ROM) alongside boards, so the flow works for a board + chip, for // several chips with no board, etc. const allCustomChips = useSimulatorStore .getState() .components.filter((c) => c.metadataId === 'custom-chip'); if (boardsList.length === 0 && allCustomChips.length === 0) return { ok: 0, failed: 0 }; setCompileAllRunning(true); setConsoleOpen(true); const targetSummary = [ boardsList.length ? `${boardsList.length} board${boardsList.length === 1 ? '' : 's'}` : '', allCustomChips.length ? `${allCustomChips.length} chip${allCustomChips.length === 1 ? '' : 's'}` : '', ] .filter(Boolean) .join(' + '); addLog({ timestamp: new Date(), type: 'info', message: `Compiling all targets (${targetSummary})...`, }); // Make every custom-chip live (WASM + ROM) before compiling the boards, // mirroring the single-board Compile path, and collect their program file // names so they stay out of the arduino-cli builds below. const chipProgramFiles = new Set(); for (const chip of allCustomChips) { const pf = String((chip.properties as any)?.programFile ?? '').trim(); if (pf) chipProgramFiles.add(pf); } let chipFailed = 0; if (allCustomChips.length > 0) { const everyFile = boardsList.flatMap((b) => useEditorStore.getState().getGroupFiles(b.activeFileGroupId), ); chipFailed = (await prepareCustomChips(allCustomChips, everyFile)).failed; } let ok = 0; let boardFailed = 0; for (const board of boardsList) { const label = boardDisplayName(board); // Stamp this board's lines so the console groups them under its section. const boardTarget: CompileTarget = { id: board.id, label, kind: 'board' }; const blog = (type: CompilationLog['type'], message: string) => addLog({ timestamp: new Date(), type, message, target: boardTarget }); if (isPiBoardKind(board.boardKind)) { blog('info', 'skipped (no compilation needed)'); ok++; continue; } const fqbn = BOARD_KIND_FQBN[board.boardKind]; if (!fqbn) { blog('error', 'no FQBN configured'); boardFailed++; continue; } blog('info', 'compiling...'); try { const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId); const sketchFiles = groupFiles .filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name)) .map((f) => ({ name: f.name, content: f.content })); // Stream live cmake + ninja output per-board (Compile-All flow). let lastStreamedLen = 0; const result = await compileCode( sketchFiles, fqbn, currentProject?.id ?? null, ({ stdout }) => { if (stdout.length <= lastStreamedLen) return; const delta = stdout.slice(lastStreamedLen); lastStreamedLen = stdout.length; const newLines = delta.split('\n').filter((s) => s.trim()); if (!newLines.length) return; const now = new Date(); setCompileLogs((prev: CompilationLog[]) => [ ...prev, // No `${label}: ` prefix — the target section header carries it. ...newLines.map((line) => ({ timestamp: now, type: 'info' as const, message: line, target: boardTarget, })), ]); }, { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined }, ); const resultLogs = parseCompileResult(result, label, boardTarget); setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]); if (result.success) { const program = result.hex_content ?? result.binary_content ?? null; if (program) { compileBoardProgram(board.id, program); if (result.has_wifi !== undefined) { updateBoard(board.id, { hasWifi: result.has_wifi }); } } ok++; } else { boardFailed++; } } catch (err) { blog('error', err instanceof Error ? err.message : String(err)); boardFailed++; } } const failed = boardFailed + chipFailed; const chipOk = allCustomChips.length - chipFailed; const doneParts = []; if (boardsList.length) doneParts.push(`${ok} board${ok === 1 ? '' : 's'} ok${boardFailed > 0 ? `, ${boardFailed} failed` : ''}`); if (allCustomChips.length) doneParts.push(`${chipOk} chip${chipOk === 1 ? '' : 's'} ok${chipFailed > 0 ? `, ${chipFailed} failed` : ''}`); addLog({ timestamp: new Date(), type: failed > 0 ? 'error' : 'success', message: `Done — ${doneParts.join('; ')}`, }); if (failed === 0) markCompiled(); setCompileAllRunning(false); return { ok, failed }; }; const handleCompileAll = () => { trackCompileCode(); void compileAllBoards(); }; /** * Run All = compile every target (boards + chips) if needed, then start every * one: boards via startBoard, chips via restartParts (re-attach with the * fresh WASM/ROM) + resuming the electrical solver when there's no board. * Mirrors single Run, generalised across all targets. */ const handleRunAll = async (skipVerify = false) => { const sim = useSimulatorStore.getState(); const boardsList = sim.boards; const chips = sim.components.filter((c) => c.metadataId === 'custom-chip'); if (boardsList.length === 0 && chips.length === 0) return; // Same pre-flight safety check as handleRun — block on shorts / overcurrent // before starting every board, with a "Run anyway" escape. if (!skipVerify) { const ok = await checkOrBlock(() => handleRunAll(true)); if (!ok) return; } // A chip needs compiling when it has no WASM yet, or it references a program // file but hasn't been assembled to ROM. const chipNeedsCompile = chips.some((c) => { const p = c.properties as Record; const programFile = String(p?.programFile ?? '').trim(); return !String(p?.wasmBase64 ?? '') || (programFile && !String(p?.romBytes ?? '')); }); const needsCompile = codeChangedSinceLastCompile || chipNeedsCompile || boardsList.some( (b) => !isPiBoardKind(b.boardKind) && b.languageMode !== 'micropython' && !b.compiledProgram, ); if (needsCompile) { const { failed } = await compileAllBoards(); if (failed > 0) return; // a board failed — don't start anything } // Start every board (compiledProgram may have changed during compile). const refreshed = useSimulatorStore.getState().boards; for (const board of refreshed) { if (board.running) continue; if (isQemuBoardKind(board.boardKind) || board.compiledProgram || board.languageMode === 'micropython') { trackRunSimulation(board.boardKind); reportRun(board.boardKind); startBoard(board.id); } } // Run the chips: re-attach so they pick up the freshly compiled WASM/ROM. // The chip tick gates on a running board, so when NO board actually started // (board-less, or a board that compiled to nothing) resume the electrical // solver instead, otherwise the chips would stay frozen. if (chips.length > 0) { useSimulatorStore.getState().restartParts(); const anyBoardRunning = useSimulatorStore.getState().boards.some((b) => b.running); if (!anyBoardRunning) setElectricalPaused(false); } }; const handleExport = async () => { try { const { components, wires, boardPosition, boardType: legacyBoardType, } = useSimulatorStore.getState(); const projectName = files.find((f) => f.name.endsWith('.ino'))?.name.replace('.ino', '') || 'velxio-project'; await exportToWokwiZip(files, components, wires, legacyBoardType, projectName, boardPosition); } catch (err) { setMessage({ type: 'error', text: 'Export failed.' }); } }; // Phase 3 D3.2 — Schematic screenshot. Pro-tier-gated by the backend. // Same UX pattern as BOM export: everyone can click; 402 redirects to // /pricing. The server-side headless chromium renders the canvas and // returns a PNG, which we trigger a download for. const handleExportScreenshot = async () => { const projectId = currentProject?.id; if (!projectId) { setMessage({ type: 'error', text: 'Save the project before exporting an image.' }); return; } setMessage({ type: 'info', text: 'Rendering screenshot — may take 5-10 seconds…' }); try { const resp = await fetch(`/api/pro/projects/${projectId}/screenshot.png`, { credentials: 'include', }); if (resp.status === 402) { // Fire the in-place upgrade modal instead of bouncing to /pricing — // keeps the user in the editor with full context. The pro overlay's // UpgradeGate listens for this event and opens UpgradePromptModal. window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', { detail: { componentName: 'Schematic screenshot export' }, })); return; } if (resp.status === 401) { window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`; return; } if (resp.status === 422) { setMessage({ type: 'error', text: 'Add at least one component to export an image.' }); return; } if (!resp.ok) { setMessage({ type: 'error', text: 'Screenshot export failed.' }); return; } const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const cd = resp.headers.get('Content-Disposition') || ''; const m = /filename="?([^"]+)"?/.exec(cd); a.download = m ? m[1] : `velxio-${projectId}.png`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setMessage({ type: 'success', text: 'Screenshot downloaded.' }); } catch { setMessage({ type: 'error', text: 'Screenshot export failed.' }); } }; // Phase 3 D3.1 — BOM export. Pro-tier-gated by the backend (402 if not pro). // We let everyone click; the 402 response feeds the upgrade prompt below // so free/maker users hit the funnel naturally instead of an obviously- // locked button (which they'd just dismiss). const handleExportBom = async () => { const projectId = currentProject?.id; if (!projectId) { setMessage({ type: 'error', text: 'Save the project before exporting a BOM.' }); return; } try { const resp = await fetch(`/api/pro/projects/${projectId}/bom.csv`, { credentials: 'include', }); if (resp.status === 402) { // Fire the in-place upgrade modal instead of bouncing to /pricing — // keeps the user in the editor with full context. The pro overlay's // UpgradeGate listens for this event and opens UpgradePromptModal. window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', { detail: { componentName: 'BOM export' }, })); return; } if (resp.status === 401) { window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`; return; } if (!resp.ok) { setMessage({ type: 'error', text: 'BOM export failed.' }); return; } const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Filename comes from Content-Disposition; pick a fallback. const cd = resp.headers.get('Content-Disposition') || ''; const m = /filename="?([^"]+)"?/.exec(cd); a.download = m ? m[1] : `bom-${projectId}.csv`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch { setMessage({ type: 'error', text: 'BOM export failed.' }); } }; const handleFirmwareUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (firmwareInputRef.current) firmwareInputRef.current.value = ''; if (!file) return; setConsoleOpen(true); addLog({ timestamp: new Date(), type: 'info', message: `Loading firmware: ${file.name}...` }); try { const boardKind = activeBoard?.boardKind; if (!boardKind) { setMessage({ type: 'error', text: 'No board selected' }); return; } const result = await readFirmwareFile(file, boardKind); // Architecture mismatch warning for ELF files if (result.elfInfo?.suggestedBoard && result.elfInfo.suggestedBoard !== boardKind) { const detected = result.elfInfo.architectureName; const current = activeBoard ? boardDisplayName(activeBoard) : boardKind; addLog({ timestamp: new Date(), type: 'info', message: `Note: Detected ${detected} architecture, but current board is ${current}. Loading anyway.`, }); } if (activeBoardId) { compileBoardProgram(activeBoardId, result.program); markCompiled(); addLog({ timestamp: new Date(), type: 'info', message: result.message }); setMessage({ type: 'success', text: `Firmware loaded: ${file.name}` }); } } catch (err) { const errMsg = err instanceof Error ? err.message : 'Failed to load firmware'; addLog({ timestamp: new Date(), type: 'error', message: errMsg }); setMessage({ type: 'error', text: errMsg }); } }; const handleImportFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!importInputRef.current) return; importInputRef.current.value = ''; if (!file) return; try { const result = await importProjectFile(file); if (result.kind === 'vlx') { // importVlxFile already wrote into the stores. setMessage({ type: 'success', text: `Imported ${file.name}` }); return; } // .zip path: apply the parsed payload to the stores ourselves, then // surface any missing libraries via the existing install modal. const { loadFiles } = useEditorStore.getState(); const { setComponents, setWires, setBoardType, setBoardPosition, stopSimulation } = useSimulatorStore.getState(); stopSimulation(); if (result.boardType) setBoardType(result.boardType); setBoardPosition(result.boardPosition); setComponents(result.components); setWires(result.wires); if (result.files.length > 0) loadFiles(result.files); setMessage({ type: 'success', text: `Imported ${file.name}` }); if (result.libraries.length > 0) { setPendingLibraries(result.libraries); setInstallModalOpen(true); } } catch (err: any) { setMessage({ type: 'error', text: err?.message || 'Import failed.' }); } }; return ( <>
{/* Language selector — only when active board supports an alternative to Arduino C++ (MicroPython on Pico/ESP32 boards, pure ESP-IDF on the ESP32 family — issue #139). The board context pill that used to live here was removed: it duplicated the BoardSelector dropdown elsewhere in the toolbar. */} {activeBoard && BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && ( )}
{/* Compile */}
{/* Run — in a multi-board project this runs ALL boards (the wired boards are one system; running a subset is almost never intended), with a split-menu to still run only the active board. Single-board / board-less behaviour is unchanged. */}
{isMultiBoard && ( )} {isMultiBoard && runMenuOpen && (
)}
{/* Stop */} {/* Reset */} {targetCount > 1 && ( <>
{/* Compile All — boards + programmable chips */} {/* Run All — only when the primary Run isn't already the "run all boards" action (i.e. board + chip or chips-only projects). For 2+ boards the split Run button covers it. */} {!isMultiBoard && ( )} )}
{/* Center slot — a flexible spacer that keeps the right action group pinned to the far right. Rendered unconditionally so the layout holds even when no overlay supplies content here. */}
{centerSlot}
{/* Hidden file input for project import. Accepts both .vlx (Velxio native) and .zip (Wokwi bundle); the dispatcher in utils/importProject.ts picks the right loader by extension. */} {/* Hidden file input for firmware upload */} {/* Library Manager — always visible with label */} {/* Import zip — inline by default; container query at narrow widths swaps this for the corresponding overflow-menu item. */} {/* Overflow "More" menu — collects the secondary actions (BOM, Schematic image, Upload firmware) so the toolbar no longer overflows on narrow widths. The two Pro items show a small "PRO" pill in the menu so users know they're premium BEFORE clicking, instead of being surprised by an upgrade prompt. */}
{moreMenuOpen && (
{/* Responsive items — hidden by default, shown via container query when the toolbar is too narrow to keep their inline twins. Keeps mobile users from losing access to Import / Export entirely. */} {/* Sync to GitHub — Pro feature. Fires a window event the pro overlay listens for; if no overlay is loaded (OSS build) the click is a silent no-op which is fine — OSS users can't have linked repos anyway. */} {/* Share / Embed — free for all users with a public project. Watermark removal on the embed is the Pro perk; the Share modal itself is open to everyone so they can copy the link / iframe snippet. */} {/* Record simulation — Pro feature. Dispatches a toggle the pro overlay handles (plan check, board-type check, start/stop the recorder). OSS build → no listener → silent no-op. */}
)}
{/* Output Console toggle */} {rightSlot}
{/* Error detail bar */} {message?.type === 'error' && message.text.length > 40 && !consoleOpen && (
{message.text}
)} {/* Missing library hint */} {missingLibHint && (
{t('editor.toolbar.libHint.message')}
)} setLibManagerOpen(false)} /> setInstallModalOpen(false)} libraries={pendingLibraries} /> {verification && ( { pendingRunRef.current = null; setVerification(null); }} onRunAnyway={() => { const resume = pendingRunRef.current; pendingRunRef.current = null; setVerification(null); resume?.(); }} /> )} ); };