From 70cc8c2e1190195393ec381361ae2b293a97abfa Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 8 May 2026 00:53:27 -0300 Subject: [PATCH] feat(editor): view-mode toggle, agent-chat slot, toolbar polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 11 + frontend/package.json | 6 +- frontend/src/App.css | 7 + .../src/components/editor/EditorToolbar.tsx | 211 ++++++------------ .../components/simulator/SimulatorCanvas.tsx | 10 + frontend/src/pages/EditorPage.tsx | 102 ++++++++- frontend/src/store/useEditorStore.ts | 11 + frontend/vite.config.ts | 6 + 8 files changed, 207 insertions(+), 157 deletions(-) diff --git a/.gitignore b/.gitignore index 4e91388b..61ddd11f 100644 --- a/.gitignore +++ b/.gitignore @@ -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* diff --git a/frontend/package.json b/frontend/package.json index 4c90068d..f500a2b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.css b/frontend/src/App.css index 88c7ecb8..84d6ebfa 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 517d728a..e1f14c94 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -113,25 +113,8 @@ export const EditorToolbar = ({ const importInputRef = useRef(null); const firmwareInputRef = useRef(null); const toolbarRef = useRef(null); - const [overflowOpen, setOverflowOpen] = useState(false); - const overflowMenuRef = useRef(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 = ({ <>
- {/* Active board context pill */} - {activeBoard && ( -
-
- {BOARD_PILL_ICON[activeBoard.boardKind]} - - {BOARD_KIND_LABELS[activeBoard.boardKind]} - - {activeBoard.running && } -
- {BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && ( - - )} -
+ {/* 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) && ( + )}
@@ -868,95 +836,42 @@ export const EditorToolbar = ({ Libraries - {/* Import / Export — overflow menu */} -
- - - {overflowOpen && ( -
- - -
- -
- )} -
+ {/* Import zip — was previously hidden in a 3-dot overflow menu; + inlined since there's space and the discoverability cost + outweighed the toolbar savings. */} + + +
diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index c4670bff..633055b7 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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); diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 2a3d7086..d4eacc5b 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -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(null); const resizingRef = useRef(false); const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen); @@ -355,6 +360,66 @@ export const EditorPage: React.FC = () => { + {/* 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. */} +
+ {( + [ + { 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) => ( + + ))} +
{
@@ -477,8 +551,8 @@ export const EditorPage: React.FC = () => {
- {/* Resize handle (desktop only) */} - {!isMobile && ( + {/* Resize handle (desktop only, and only when both panes are visible) */} + {!isMobile && viewMode === 'both' && (
@@ -488,8 +562,17 @@ export const EditorPage: React.FC = () => {
@@ -526,6 +609,9 @@ export const EditorPage: React.FC = () => { {saveModalOpen && setSaveModalOpen(false)} />} {loginPromptOpen && setLoginPromptOpen(false)} />} {showStarBanner && } + {/* Slot reserved for the private pro overlay (e.g. agent chat panel). + Self-hosted builds without an overlay see nothing here. */} +
); }; diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index bd48f258..ad35e7e1 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -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((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: { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 160bee35..d9c50f2a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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: {