From 23221615b79a5374c7f8aea46658c28b627d22db Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Wed, 15 Jul 2026 11:52:03 -0400 Subject: [PATCH 01/10] feat(editor): clear console before re-running code Running the same program twice gave no visual signal that it reran. Blank the output immediately, then run after a 100ms delay so the clear is visible before new output appears. Co-Authored-By: Claude Sonnet 5 --- src/pages/EditorPage.tsx | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index 3154e0d..320515f 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -135,6 +135,15 @@ export default function EditorPage() { null ); const aiResizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + const runTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (runTimeoutRef.current !== null) { + clearTimeout(runTimeoutRef.current); + } + }; + }, []); const isMobile = viewportWidth < MOBILE_BREAKPOINT; @@ -437,11 +446,7 @@ export default function EditorPage() { return result; }, [code, sourceLang, ast, panels, getTranslation]); - const handleRun = () => { - setError(null); - setOutput([]); - setWaitingForNormalInput(false); - + const executeRun = () => { try { const runLang = sourceLang === 'ast' ? 'python' : sourceLang; const program = parseCode(runLang as SupportedLang, code); @@ -478,6 +483,20 @@ export default function EditorPage() { } }; + const handleRun = () => { + setError(null); + setOutput([]); + setWaitingForNormalInput(false); + + if (runTimeoutRef.current !== null) { + clearTimeout(runTimeoutRef.current); + } + runTimeoutRef.current = window.setTimeout(() => { + runTimeoutRef.current = null; + executeRun(); + }, 100); + }; + const handleDebugStart = () => { setError(null); setOutput([]); From b96f50e8ca1365e8e5eafa86ae0ae037b7a858f2 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Wed, 15 Jul 2026 11:54:54 -0400 Subject: [PATCH 02/10] feat(editor): flatten language view toggles onto the panel strip Replace the "+" dropdown with 7 always-visible toggle buttons matching the Open AI Assistant button's on/off style, so opening a language view is a single click instead of open-menu-then-click. Co-Authored-By: Claude Sonnet 5 --- src/components/editor/AddPanelStrip.tsx | 109 ++++++++---------------- src/pages/EditorPage.tsx | 4 - 2 files changed, 37 insertions(+), 76 deletions(-) diff --git a/src/components/editor/AddPanelStrip.tsx b/src/components/editor/AddPanelStrip.tsx index 0b42074..5f99715 100644 --- a/src/components/editor/AddPanelStrip.tsx +++ b/src/components/editor/AddPanelStrip.tsx @@ -1,4 +1,4 @@ -import { Plus, FileJson, Check, Bot } from 'lucide-react'; +import { FileJson, Bot } from 'lucide-react'; import type { MouseEvent } from 'react'; import { LANG_LABELS, type SupportedLang } from '../LanguageSelector'; @@ -6,13 +6,11 @@ import { LanguageLogo } from '../LanguageLogo'; import type { Panel } from './types'; interface AddPanelStripProps { - showAddMenu: boolean; sourceLang: SupportedLang; panels: Panel[]; showAiSidePanel: boolean; /** True while the AI panel is being resized (highlights the handle). */ aiResizeActive: boolean; - onToggleMenu: () => void; onTogglePanel: (lang: SupportedLang) => void; onToggleAiPanel: () => void; /** Starts an AI-panel resize drag from this strip's left edge. */ @@ -29,19 +27,24 @@ const PANEL_LANGS: SupportedLang[] = [ 'python', ]; +const toggleButtonClasses = (active: boolean) => + `p-3 rounded-xl transition-all shadow-lg active:scale-90 border ${ + active + ? 'bg-indigo-600 text-white border-indigo-500' + : 'bg-slate-800 hover:bg-indigo-600 text-indigo-400 hover:text-white border-slate-700' + }`; + export function AddPanelStrip({ - showAddMenu, sourceLang, panels, showAiSidePanel, aiResizeActive, - onToggleMenu, onTogglePanel, onToggleAiPanel, onStartAiResize, }: AddPanelStripProps) { return ( -
+
{/* The strip sits between the code panes and the AI panel, so its left edge doubles as a second resize handle for the AI panel. */} {showAiSidePanel && ( @@ -53,79 +56,41 @@ export function AddPanelStrip({ aria-hidden="true" /> )} -
- - {showAddMenu && ( -
-
- Open View -
-
- {PANEL_LANGS.map((lang) => { - const isOpen = panels.some((panel) => panel.lang === lang); - const isSourceLanguage = lang === sourceLang; - const isDisabled = isSourceLanguage; + {PANEL_LANGS.map((lang) => { + const isOpen = panels.some((panel) => panel.lang === lang); + const isSourceLanguage = lang === sourceLang; + const label = LANG_LABELS[lang]; - return ( - - ); - })} -
-
- )} -
+ return ( + + ); + })} {/* AI assistant — opens the side chat */}
setShowAddMenu((prev) => !prev)} onTogglePanel={togglePanel} onToggleAiPanel={handleToggleAiPanel} onStartAiResize={handleStartAiResize} From 99e28d46dbcc17b5c5de618330b111a3e0659907 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Wed, 15 Jul 2026 11:56:58 -0400 Subject: [PATCH 03/10] feat(editor): move MemDia toggle onto the panel strip Memory Diagram was buried in the Settings dropdown, separate from the other panel toggles. Move it to the panel strip as a 9th toggle button, after a divider, alongside the Open AI Assistant button. Co-Authored-By: Claude Sonnet 5 --- src/components/editor/AddPanelStrip.tsx | 18 ++++++++++++++++- src/components/editor/EditorHeader.tsx | 27 +------------------------ src/pages/EditorPage.tsx | 16 ++++++++------- 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/src/components/editor/AddPanelStrip.tsx b/src/components/editor/AddPanelStrip.tsx index 5f99715..ae4531e 100644 --- a/src/components/editor/AddPanelStrip.tsx +++ b/src/components/editor/AddPanelStrip.tsx @@ -1,4 +1,4 @@ -import { FileJson, Bot } from 'lucide-react'; +import { FileJson, Bot, BrainCircuit } from 'lucide-react'; import type { MouseEvent } from 'react'; import { LANG_LABELS, type SupportedLang } from '../LanguageSelector'; @@ -9,10 +9,12 @@ interface AddPanelStripProps { sourceLang: SupportedLang; panels: Panel[]; showAiSidePanel: boolean; + showMemDia: boolean; /** True while the AI panel is being resized (highlights the handle). */ aiResizeActive: boolean; onTogglePanel: (lang: SupportedLang) => void; onToggleAiPanel: () => void; + onToggleMemDia: () => void; /** Starts an AI-panel resize drag from this strip's left edge. */ onStartAiResize: (e: MouseEvent) => void; } @@ -38,9 +40,11 @@ export function AddPanelStrip({ sourceLang, panels, showAiSidePanel, + showMemDia, aiResizeActive, onTogglePanel, onToggleAiPanel, + onToggleMemDia, onStartAiResize, }: AddPanelStripProps) { return ( @@ -86,6 +90,8 @@ export function AddPanelStrip({ ); })} + ); } diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index a40fe45..da241fe 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -24,7 +24,6 @@ interface EditorHeaderProps { embedCopied: boolean; showExamplesMenu: boolean; showSettingsMenu: boolean; - showMemDia: boolean; isDebugging: boolean; isDebugComplete: boolean; examples: ExampleProgram[]; @@ -34,7 +33,6 @@ interface EditorHeaderProps { onLoadExample: (exampleId: string) => void; onToggleExamplesMenu: () => void; onToggleSettingsMenu: () => void; - onToggleMemDia: () => void; onDebugStart: () => void; onRun: () => void; onDebugStep: () => void; @@ -49,7 +47,6 @@ export function EditorHeader({ embedCopied, showExamplesMenu, showSettingsMenu, - showMemDia, isDebugging, isDebugComplete, examples, @@ -59,7 +56,6 @@ export function EditorHeader({ onLoadExample, onToggleExamplesMenu, onToggleSettingsMenu, - onToggleMemDia, onDebugStart, onRun, onDebugStep, @@ -187,29 +183,8 @@ export function EditorHeader({ aria-label="Editor settings" className="absolute top-full right-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-[220]" > - {/* Editor Panels section */} -
- Editor Panels -
-
- -
- {/* Text Size slider */} -
+
Display
diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index aba3cfa..76c1f71 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -653,6 +653,13 @@ export default function EditorPage() { setShowAiSidePanel((prev) => !prev); }; + const handleToggleMemDia = () => { + setShowMemDia((prev) => { + if (!prev) setMemDiaStates(new Map()); // Reset all closed panes when enabling + return !prev; + }); + }; + // Shared by both AI-panel resize handles (the panel's left edge and the // left edge of the add-panel strip): they move in lockstep because the // strip has a fixed width, so one drag math works for both. @@ -1016,7 +1023,6 @@ export default function EditorPage() { embedCopied={embedCopied} showExamplesMenu={showExamplesMenu} showSettingsMenu={showSettingsMenu} - showMemDia={showMemDia} isDebugging={isDebugging} isDebugComplete={isDebugComplete} examples={EXAMPLE_PROGRAMS} @@ -1032,12 +1038,6 @@ export default function EditorPage() { setShowSettingsMenu((prev) => !prev); setShowExamplesMenu(false); }} - onToggleMemDia={() => { - setShowMemDia((prev) => { - if (!prev) setMemDiaStates(new Map()); // Reset all closed panes when enabling - return !prev; - }); - }} onDebugStart={handleDebugStart} onRun={handleRun} onDebugStep={handleDebugStep} @@ -1213,9 +1213,11 @@ export default function EditorPage() { sourceLang={sourceLang} panels={panels} showAiSidePanel={showAiSidePanel} + showMemDia={showMemDia} aiResizeActive={isResizingAiPanel} onTogglePanel={togglePanel} onToggleAiPanel={handleToggleAiPanel} + onToggleMemDia={handleToggleMemDia} onStartAiResize={handleStartAiResize} /> From 70df14855521a4cc962860105d1fd4c353ae7c0c Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Wed, 15 Jul 2026 12:03:22 -0400 Subject: [PATCH 04/10] feat(editor): persist source code, language, and panel toggles to localStorage Reloading (or returning later) lost all editor state. Save code, source language, and the AddPanelStrip toggle state (open language views, AI Assistant, MemDia) to localStorage on a short debounce, and restore on mount. Skipped when opened via an embed link (?code=...), since that's an explicit one-off share rather than a session to save or overwrite. Co-Authored-By: Claude Sonnet 5 --- src/pages/EditorPage.tsx | 76 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index 76c1f71..9319c04 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -48,12 +48,39 @@ const MOBILE_BREAKPOINT = 1024; const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); +const EDITOR_STATE_KEY = 'praxly-editor-state'; + +type PersistedEditorState = { + code: string; + sourceLang: SupportedLang; + openLangs: SupportedLang[]; + showAiSidePanel: boolean; + showMemDia: boolean; +}; + +/** An embed link (`?code=...`) is an explicit share — it should win over, + * and not be clobbered into, any saved session in localStorage. */ +const hasEmbedParam = () => Boolean(new URLSearchParams(window.location.search).get('code')); + +const loadEditorState = (): PersistedEditorState | null => { + if (hasEmbedParam()) return null; + try { + const raw = localStorage.getItem(EDITOR_STATE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +}; + export default function EditorPage() { const [searchParams] = useSearchParams(); - const [code, setCode] = useState(''); + const [loadedViaEmbedLink] = useState(hasEmbedParam); + const [code, setCode] = useState(() => loadEditorState()?.code ?? ''); const [output, setOutput] = useState([]); const [ast, setAst] = useState(null); - const [sourceLang, setSourceLang] = useState('praxis'); + const [sourceLang, setSourceLang] = useState( + () => loadEditorState()?.sourceLang ?? 'praxis' + ); const [error, setError] = useState(null); const [showSourceLangDropdown, setShowSourceLangDropdown] = useState(false); const [showExamplesMenu, setShowExamplesMenu] = useState(false); @@ -61,8 +88,10 @@ export default function EditorPage() { const [draggedPanelId, setDraggedPanelId] = useState(null); const [dragOverPanelId, setDragOverPanelId] = useState(null); const [showSettingsMenu, setShowSettingsMenu] = useState(false); - const [showAiSidePanel, setShowAiSidePanel] = useState(false); - const [showMemDia, setShowMemDia] = useState(false); + const [showAiSidePanel, setShowAiSidePanel] = useState( + () => loadEditorState()?.showAiSidePanel ?? false + ); + const [showMemDia, setShowMemDia] = useState(() => loadEditorState()?.showMemDia ?? false); const [outputState, setOutputState] = useState<'open' | 'closed'>('open'); const [textSize, setTextSize] = useState( () => Number(localStorage.getItem('praxly-text-size')) || 3 @@ -85,7 +114,24 @@ export default function EditorPage() { const [editorWidth, setEditorWidth] = useState( Math.max(MIN_SOURCE_WIDTH, Math.floor(window.innerWidth * 0.45)) ); - const [panels, setPanels] = useState([]); + const [panels, setPanels] = useState(() => { + const saved = loadEditorState(); + if (!saved || saved.openLangs.length === 0) return []; + + const defaultPanelWidth = + window.innerWidth < MOBILE_BREAKPOINT + ? Math.max(MIN_PANEL_WIDTH, Math.floor(window.innerWidth * 0.8)) + : 350; + + return saved.openLangs + .filter((lang) => lang !== saved.sourceLang) + .map((lang) => ({ + id: randomId(), + lang, + width: defaultPanelWidth, + sourceMap: new Map(), + })); + }); const [waitingForNormalInput, setWaitingForNormalInput] = useState(false); const [normalModeInputPrompt, setNormalModeInputPrompt] = useState(''); @@ -291,6 +337,26 @@ export default function EditorPage() { } }, [searchParams]); + // Persist source code, language, and panel toggles so a reload (or a + // later visit) picks up where the user left off. Skipped for embed + // links — those are an explicit one-off share, not a session to save. + useEffect(() => { + if (loadedViaEmbedLink) return; + + const id = window.setTimeout(() => { + const state: PersistedEditorState = { + code, + sourceLang, + openLangs: panels.map((panel) => panel.lang), + showAiSidePanel, + showMemDia, + }; + localStorage.setItem(EDITOR_STATE_KEY, JSON.stringify(state)); + }, 250); + + return () => clearTimeout(id); + }, [code, sourceLang, panels, showAiSidePanel, showMemDia, loadedViaEmbedLink]); + useEffect(() => { const handleResize = () => { setViewportWidth(window.innerWidth); From b0576161f37cf52aa8003f51323f08c5864b7894 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Wed, 15 Jul 2026 12:24:08 -0400 Subject: [PATCH 05/10] feat(editor): fix run placeholder, move panel strip left, rename AI-chat storage key - Suppress the "Run code to see execution results..." placeholder after the first run instead of showing it every time output is cleared. - Move AddPanelStrip to the left side of the editor. - Rename showAiSidePanel to showAiChat in the persisted editor state. Co-Authored-By: Claude Sonnet 5 --- package.json | 4 +++ src/components/OutputPanel.tsx | 5 +++- src/components/editor/AddPanelStrip.tsx | 21 +-------------- src/pages/EditorPage.tsx | 36 ++++++++++++++----------- 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 6676a21..172d364 100644 --- a/package.json +++ b/package.json @@ -69,5 +69,9 @@ "*.{ts,tsx,js,jsx,json,md,css,html,yml,yaml}": [ "prettier --check" ] + }, + "allowScripts": { + "chromedriver@149.0.4": true, + "esbuild@0.28.1": true } } diff --git a/src/components/OutputPanel.tsx b/src/components/OutputPanel.tsx index 62bca27..9e881a8 100644 --- a/src/components/OutputPanel.tsx +++ b/src/components/OutputPanel.tsx @@ -7,6 +7,8 @@ export type OutputPanelState = 'open' | 'closed'; interface OutputPanelProps { output: string[]; error?: string | null; + /** False until the user's first Run/Debug — suppresses the placeholder after that. */ + hasRun?: boolean; variables?: Record; showVariables?: boolean; height?: number; @@ -44,6 +46,7 @@ const formatVariableValue = (value: any): string => { export const OutputPanel: React.FC = ({ output, error, + hasRun = false, variables = {}, showVariables = false, height, @@ -150,7 +153,7 @@ export const OutputPanel: React.FC = ({ aria-label="Program output" aria-atomic="false" > - {output.length === 0 && !error ? ( + {!hasRun && output.length === 0 && !error ? (
Run code to see execution results...
) : ( output.map((line, idx) => { diff --git a/src/components/editor/AddPanelStrip.tsx b/src/components/editor/AddPanelStrip.tsx index ae4531e..41d0d68 100644 --- a/src/components/editor/AddPanelStrip.tsx +++ b/src/components/editor/AddPanelStrip.tsx @@ -1,5 +1,4 @@ import { FileJson, Bot, BrainCircuit } from 'lucide-react'; -import type { MouseEvent } from 'react'; import { LANG_LABELS, type SupportedLang } from '../LanguageSelector'; import { LanguageLogo } from '../LanguageLogo'; @@ -10,13 +9,9 @@ interface AddPanelStripProps { panels: Panel[]; showAiSidePanel: boolean; showMemDia: boolean; - /** True while the AI panel is being resized (highlights the handle). */ - aiResizeActive: boolean; onTogglePanel: (lang: SupportedLang) => void; onToggleAiPanel: () => void; onToggleMemDia: () => void; - /** Starts an AI-panel resize drag from this strip's left edge. */ - onStartAiResize: (e: MouseEvent) => void; } const PANEL_LANGS: SupportedLang[] = [ @@ -41,26 +36,12 @@ export function AddPanelStrip({ panels, showAiSidePanel, showMemDia, - aiResizeActive, onTogglePanel, onToggleAiPanel, onToggleMemDia, - onStartAiResize, }: AddPanelStripProps) { return ( -
- {/* The strip sits between the code panes and the AI panel, so its left - edge doubles as a second resize handle for the AI panel. */} - {showAiSidePanel && ( -