feat(editor): view-mode toggle, agent-chat slot, toolbar polish

Changes that ship to OSS — all benign for self-hosters, but most are
extension points the velxio-prod overlay (and any private fork) needs to
plug an in-editor AI chat into the page.

Editor:
- 3-way view-mode toggle (code / both / circuit) in the unified toolbar.
  Lets users hide a pane to give a right-docked sidebar (e.g. the AI
  chat overlay) more breathing room. Persisted in useEditorStore.
- Default file explorer narrower (210 → 165 px); min 110.
- Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip);
  the BoardSelector dropdown elsewhere already shows the active board.
- Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow
  menu gave up too much discoverability. Removed dead overflow state.

Simulator:
- Fix: global Delete/Backspace handler in SimulatorCanvas no longer
  fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable
  — affected any in-page text field, not just the chat overlay.

Overlay extensibility:
- New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so
  pro overlays can portal a chat panel into the editor without
  forking the page.
- vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set.
  Lets local-dev junctions (overlay tree → frontend/src/pro) resolve
  bare imports back to the OSS node_modules without resolving symlinks.

Deps:
- Added react-markdown + remark-gfm (rendered chat output) and
  @google/genai + zod (overlay agent loop). Tree-shaken from the OSS
  bundle when no pro code imports them.

gitignore:
- Ignore backend/app/pro/ and frontend/src/pro/ junctions used by
  developers running a private overlay against the OSS dev server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-08 00:53:27 -03:00
parent bb14ab663a
commit 70cc8c2e11
8 changed files with 207 additions and 157 deletions

11
.gitignore vendored
View File

@ -40,6 +40,17 @@ frontend/dist/
frontend/.vite/
backend/dist/
# Local-dev junctions to a private overlay repo (e.g. velxio-prod). Created
# by the user when running the pro stack against the OSS dev server; never
# committed because they point at out-of-tree code that isn't part of the
# open-source project. The OSS Vite alias falls back to a no-op stub when
# these aren't present.
backend/app/pro/
frontend/src/pro/
# Temporary vitest config used to run pro overlay tests locally.
frontend/pro-vitest.config.ts
# Logs
*.log
npm-debug.log*

View File

@ -25,6 +25,7 @@
"test:ui": "vitest --ui"
},
"dependencies": {
"@google/genai": "^0.6.0",
"@monaco-editor/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@wokwi/elements": "1.9.2",
@ -39,10 +40,13 @@
"lucide-react": "^0.460.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"rp2040js": "1.3.2",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"rp2040js": "1.3.2",
"zod": "^3.23.8",
"zustand": "^5.0.11"
},
"devDependencies": {

View File

@ -367,6 +367,13 @@ body {
}
}
/* ── View-mode toggle: desktop only (mobile uses bottom-nav) ─────── */
@media (max-width: 768px) {
.view-mode-toggle {
display: none !important;
}
}
/* ── Header responsive ───────────────────────────── */
@media (max-width: 768px) {
.app-header {

View File

@ -113,25 +113,8 @@ export const EditorToolbar = ({
const importInputRef = useRef<HTMLInputElement>(null);
const firmwareInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowMenuRef = useRef<HTMLDivElement>(null);
const [missingLibHint, setMissingLibHint] = useState(false);
// (ResizeObserver removed — Library Manager is always visible now,
// only import/export live in the overflow menu)
// Close overflow dropdown on outside click
useEffect(() => {
if (!overflowOpen) return;
const handler = (e: MouseEvent) => {
if (overflowMenuRef.current && !overflowMenuRef.current.contains(e.target as Node)) {
setOverflowOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [overflowOpen]);
// Compile All / Run All — runs sequentially, logs to console (no dialog)
const [compileAllRunning, setCompileAllRunning] = useState(false);
@ -635,48 +618,33 @@ export const EditorToolbar = ({
<>
<div className="editor-toolbar-wrapper" style={{ position: 'relative' }}>
<div className="editor-toolbar" ref={toolbarRef}>
{/* Active board context pill */}
{activeBoard && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div
className="tb-board-pill"
style={{
borderColor: BOARD_PILL_COLOR[activeBoard.boardKind],
color: BOARD_PILL_COLOR[activeBoard.boardKind],
}}
title={`Editing: ${BOARD_KIND_LABELS[activeBoard.boardKind]}`}
>
<span className="tb-board-pill-icon">{BOARD_PILL_ICON[activeBoard.boardKind]}</span>
<span className="tb-board-pill-label">
{BOARD_KIND_LABELS[activeBoard.boardKind]}
</span>
{activeBoard.running && <span className="tb-board-pill-running" title="Running" />}
</div>
{BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && (
<select
className="tb-lang-select"
value={activeBoard.languageMode ?? 'arduino'}
onChange={(e) => {
if (activeBoardId)
setBoardLanguageMode(activeBoardId, e.target.value as LanguageMode);
}}
title="Language mode"
style={{
background: '#2d2d2d',
color: '#ccc',
border: '1px solid #444',
borderRadius: 4,
padding: '2px 4px',
fontSize: 11,
cursor: 'pointer',
outline: 'none',
}}
>
<option value="arduino">Arduino C++</option>
<option value="micropython">MicroPython</option>
</select>
)}
</div>
{/* MicroPython language selector only when active board supports it.
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) && (
<select
className="tb-lang-select"
value={activeBoard.languageMode ?? 'arduino'}
onChange={(e) => {
if (activeBoardId)
setBoardLanguageMode(activeBoardId, e.target.value as LanguageMode);
}}
title="Language mode"
style={{
background: '#2d2d2d',
color: '#ccc',
border: '1px solid #444',
borderRadius: 4,
padding: '2px 4px',
fontSize: 11,
cursor: 'pointer',
outline: 'none',
marginRight: 4,
}}
>
<option value="arduino">Arduino C++</option>
<option value="micropython">MicroPython</option>
</select>
)}
<div className="toolbar-group">
@ -868,95 +836,42 @@ export const EditorToolbar = ({
<span className="tb-libraries-label">Libraries</span>
</button>
{/* Import / Export — overflow menu */}
<div className="tb-overflow-wrap" ref={overflowMenuRef}>
<button
onClick={() => setOverflowOpen((v) => !v)}
className={`tb-btn tb-btn-overflow${overflowOpen ? ' tb-btn-overflow-active' : ''}`}
title="Import / Export"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
{overflowOpen && (
<div className="tb-overflow-menu">
<button
className="tb-overflow-item"
onClick={() => {
importInputRef.current?.click();
setOverflowOpen(false);
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Import zip
</button>
<button
className="tb-overflow-item"
onClick={() => {
handleExport();
setOverflowOpen(false);
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Export zip
</button>
<div style={{ borderTop: '1px solid #3c3c3c', margin: '4px 0' }} />
<button
className="tb-overflow-item"
onClick={() => {
firmwareInputRef.current?.click();
setOverflowOpen(false);
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<line x1="12" y1="15" x2="12" y2="22" />
<polyline points="8 18 12 22 16 18" />
</svg>
Upload firmware (.hex, .bin, .elf)
</button>
</div>
)}
</div>
{/* Import zip was previously hidden in a 3-dot overflow menu;
inlined since there's space and the discoverability cost
outweighed the toolbar savings. */}
<button
onClick={() => importInputRef.current?.click()}
className="tb-btn"
title="Import a project from a .zip file"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
onClick={() => handleExport()}
className="tb-btn"
title="Export the current project as a .zip file"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</button>
<button
onClick={() => firmwareInputRef.current?.click()}
className="tb-btn"
title="Upload firmware (.hex, .bin, .elf, .ihex) to bypass compilation"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<line x1="12" y1="15" x2="12" y2="22" />
<polyline points="8 18 12 22 16 18" />
</svg>
</button>
<div className="tb-divider" />

View File

@ -955,6 +955,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
// Handle keyboard delete for components and boards
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip when the user is typing in an input/textarea/contenteditable —
// otherwise Backspace inside the AI chat (or any future text field)
// also asks to delete the active board.
const t = e.target as HTMLElement | null;
if (t) {
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) {
return;
}
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedComponentId) {
removeComponent(selectedComponentId);

View File

@ -25,6 +25,7 @@ import { SaveProjectModal } from '../components/layout/SaveProjectModal';
import { LoginPromptModal } from '../components/layout/LoginPromptModal';
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
import { useEditorStore } from '../store/useEditorStore';
import { useOscilloscopeStore } from '../store/useOscilloscopeStore';
import { useAuthStore } from '../store/useAuthStore';
import { useProjectStore } from '../store/useProjectStore';
@ -38,9 +39,9 @@ const BOTTOM_PANEL_MIN = 80;
const BOTTOM_PANEL_MAX = 600;
const BOTTOM_PANEL_DEFAULT = 200;
const EXPLORER_MIN = 120;
const EXPLORER_MIN = 110;
const EXPLORER_MAX = 500;
const EXPLORER_DEFAULT = 210;
const EXPLORER_DEFAULT = 165;
const resizeHandleStyle: React.CSSProperties = {
height: 5,
@ -64,6 +65,10 @@ export const EditorPage: React.FC = () => {
const autoSave = useAutoSaveProject();
const [editorWidthPct, setEditorWidthPct] = useState(45);
// Desktop-only 3-way layout switch (code-only / circuit-only / both).
// Lets users hide a pane to give the right-docked chat more room.
const viewMode = useEditorStore((s) => s.viewMode);
const setViewMode = useEditorStore((s) => s.setViewMode);
const containerRef = useRef<HTMLDivElement>(null);
const resizingRef = useRef(false);
const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen);
@ -355,6 +360,66 @@ export const EditorPage: React.FC = () => {
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</button>
{/* View-mode toggle: Code / Both / Circuit. Lets users hide a
pane to give the right-docked AI chat more breathing room.
Hidden on mobile there's already a code/circuit toggle in
the mobile bottom-nav. */}
<div
role="group"
aria-label="View mode"
className="view-mode-toggle"
style={{
display: 'flex',
gap: 1,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 4,
overflow: 'hidden',
alignSelf: 'center',
margin: '0 6px',
}}
>
{(
[
{ key: 'code', label: 'Code', path: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
{ key: 'both', label: 'Both', path: 'M3 3h7v18H3zM14 3h7v18h-7z' },
{ key: 'circuit', label: 'Circuit', path: 'M5 12h14M12 5v14' },
] as const
).map((m) => (
<button
key={m.key}
onClick={() => setViewMode(m.key)}
aria-pressed={viewMode === m.key}
style={{
background: viewMode === m.key ? '#0e639c' : 'transparent',
color: viewMode === m.key ? 'white' : '#aaa',
border: 'none',
height: 28,
padding: '0 10px',
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
fontSize: 12,
fontFamily: 'inherit',
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={m.path} />
</svg>
<span>{m.label}</span>
</button>
))}
</div>
<div className="unified-toolbar-editor">
<EditorToolbar
consoleOpen={consoleOpen}
@ -373,8 +438,17 @@ export const EditorPage: React.FC = () => {
<div
className="editor-panel"
style={{
width: isMobile ? '100%' : `${editorWidthPct}%`,
display: isMobile && mobileView !== 'code' ? 'none' : 'flex',
width: isMobile
? '100%'
: viewMode === 'code'
? '100%'
: viewMode === 'circuit'
? '0%'
: `${editorWidthPct}%`,
display:
(isMobile && mobileView !== 'code') || (!isMobile && viewMode === 'circuit')
? 'none'
: 'flex',
flexDirection: 'row',
}}
>
@ -477,8 +551,8 @@ export const EditorPage: React.FC = () => {
</div>
</div>
{/* Resize handle (desktop only) */}
{!isMobile && (
{/* Resize handle (desktop only, and only when both panes are visible) */}
{!isMobile && viewMode === 'both' && (
<div className="resize-handle" onMouseDown={handleResizeMouseDown}>
<div className="resize-handle-grip" />
</div>
@ -488,8 +562,17 @@ export const EditorPage: React.FC = () => {
<div
className="simulator-panel"
style={{
width: isMobile ? '100%' : `${100 - editorWidthPct}%`,
display: isMobile && mobileView !== 'circuit' ? 'none' : 'flex',
width: isMobile
? '100%'
: viewMode === 'circuit'
? '100%'
: viewMode === 'code'
? '0%'
: `${100 - editorWidthPct}%`,
display:
(isMobile && mobileView !== 'circuit') || (!isMobile && viewMode === 'code')
? 'none'
: 'flex',
flexDirection: 'column',
}}
>
@ -526,6 +609,9 @@ export const EditorPage: React.FC = () => {
{saveModalOpen && <SaveProjectModal onClose={() => setSaveModalOpen(false)} />}
{loginPromptOpen && <LoginPromptModal onClose={() => setLoginPromptOpen(false)} />}
{showStarBanner && <GitHubStarBanner onClose={handleDismissStarBanner} />}
{/* Slot reserved for the private pro overlay (e.g. agent chat panel).
Self-hosted builds without an overlay see nothing here. */}
<div data-velxio-slot="agent-chat" />
</div>
);
};

View File

@ -78,12 +78,21 @@ const DEFAULT_FILE: WorkspaceFile = {
/** Default file group for the initial Arduino Uno board */
const DEFAULT_GROUP_ID = 'group-arduino-uno';
/**
* Editor view layout. Lets the user collapse either pane to give the chat
* (right-docked) more breathing room, or to focus on one half of the
* workflow.
*/
export type EditorViewMode = 'code' | 'circuit' | 'both';
interface EditorState {
files: WorkspaceFile[];
activeFileId: string;
openFileIds: string[];
theme: 'vs-dark' | 'light';
fontSize: number;
viewMode: EditorViewMode;
setViewMode: (mode: EditorViewMode) => void;
// ── File groups (one per board) ──────────────────────────────────────────
/** Map of groupId → WorkspaceFile[]. Stored as plain object for Zustand. */
@ -137,6 +146,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
openFileIds: [MAIN_ID],
theme: 'vs-dark',
fontSize: 14,
viewMode: 'both',
setViewMode: (mode) => set({ viewMode: mode }),
// File groups — initial state has one group for the default Arduino Uno board
fileGroups: {

View File

@ -21,6 +21,12 @@ export default defineConfig({
alias: {
'@pro': proOverlayPath,
},
// When the pro overlay is wired in via a junction (Windows local-dev
// pattern), Vite's default resolution walks symlinks to the real path,
// which breaks relative imports from pro into upstream sibling dirs.
// Keeping the symlink-as-path lets `../../store/...` from pro resolve
// back into the OSS tree's src/.
preserveSymlinks: !!process.env.VITE_PRO_BUILD,
},
server: {
proxy: {