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 0b42074..d381d02 100644
--- a/src/components/editor/AddPanelStrip.tsx
+++ b/src/components/editor/AddPanelStrip.tsx
@@ -1,22 +1,16 @@
-import { Plus, FileJson, Check, Bot } from 'lucide-react';
-import type { MouseEvent } from 'react';
+import { FileJson, Bot, BrainCircuit } from 'lucide-react';
import { LANG_LABELS, type SupportedLang } from '../LanguageSelector';
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;
+ showMemDia: boolean;
onTogglePanel: (lang: SupportedLang) => void;
onToggleAiPanel: () => void;
- /** Starts an AI-panel resize drag from this strip's left edge. */
- onStartAiResize: (e: MouseEvent) => void;
+ onToggleMemDia: () => void;
}
const PANEL_LANGS: SupportedLang[] = [
@@ -29,107 +23,61 @@ 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,
+ showMemDia,
onTogglePanel,
onToggleAiPanel,
- onStartAiResize,
+ onToggleMemDia,
}: 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 && (
-
- )}
-
-
-
-
+
+ {PANEL_LANGS.map((lang) => {
+ const isOpen = panels.some((panel) => panel.lang === lang);
+ const label = LANG_LABELS[lang];
- {showAddMenu && (
-
-
- Open View
-
-
- {PANEL_LANGS.map((lang) => {
- const isOpen = panels.some((panel) => panel.lang === lang);
- const isSourceLanguage = lang === sourceLang;
- const isDisabled = isSourceLanguage;
+ return (
+ onTogglePanel(lang)}
+ aria-pressed={isOpen}
+ className={toggleButtonClasses(isOpen)}
+ title={label}
+ >
+ {lang === 'ast' ? : }
+
+ );
+ })}
- return (
- !isDisabled && onTogglePanel(lang)}
- disabled={isDisabled}
- className={`flex items-center justify-between gap-3 w-full text-left px-3 py-2.5 text-xs rounded-md transition-colors group ${
- isDisabled
- ? 'text-slate-600 cursor-not-allowed opacity-50'
- : isOpen
- ? 'text-emerald-300 hover:bg-slate-700'
- : 'text-slate-300 hover:bg-indigo-600 hover:text-white'
- }`}
- >
-
- {lang === 'ast' ? (
-
- ) : (
-
-
-
- )}
- {LANG_LABELS[lang]}
-
-
- {isSourceLanguage && (
-
- Source
-
- )}
- {isOpen && !isSourceLanguage && (
-
- )}
-
-
- );
- })}
-
-
- )}
-
+
{/* AI assistant — opens the side chat */}
+
+ {/* Memory Diagram — shows live variable state alongside each pane */}
+
+
+
);
}
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
-
-
-
- Memory Diagram (MemDia)
-
- {showMemDia ? 'On' : 'Off'}
-
-
-
-
{/* Text Size slider */}
-
+
Display
diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx
index 3154e0d..206ef9c 100644
--- a/src/pages/EditorPage.tsx
+++ b/src/pages/EditorPage.tsx
@@ -48,22 +48,53 @@ 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[];
+ showAiChat: 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
([]);
+ // Once true, the "Run code to see execution results..." placeholder never
+ // reappears — it's a first-run hint, not a state to flash on every re-run.
+ const [hasRun, setHasRun] = useState(false);
const [ast, setAst] = useState(null);
- const [sourceLang, setSourceLang] = useState('praxis');
+ const [sourceLang, setSourceLang] = useState(
+ () => loadEditorState()?.sourceLang ?? 'praxis'
+ );
const [error, setError] = useState(null);
- const [showAddMenu, setShowAddMenu] = useState(false);
const [showSourceLangDropdown, setShowSourceLangDropdown] = useState(false);
const [showExamplesMenu, setShowExamplesMenu] = useState(false);
const [embedCopied, setEmbedCopied] = useState(false);
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()?.showAiChat ?? 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
@@ -86,7 +117,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('');
@@ -135,6 +183,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;
@@ -178,7 +235,6 @@ export default function EditorPage() {
(lang: SupportedLang, newCode: string) => {
setSourceLang(lang);
setCode(newCode);
- setPanels((prev) => prev.filter((panel) => panel.lang !== lang));
setIsDebugging(false);
setIsDebugComplete(false);
setHighlightedSourceLines([]);
@@ -283,6 +339,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),
+ showAiChat: 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);
@@ -437,11 +513,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,9 +550,25 @@ export default function EditorPage() {
}
};
+ const handleRun = () => {
+ setError(null);
+ setOutput([]);
+ setHasRun(true);
+ setWaitingForNormalInput(false);
+
+ if (runTimeoutRef.current !== null) {
+ clearTimeout(runTimeoutRef.current);
+ }
+ runTimeoutRef.current = window.setTimeout(() => {
+ runTimeoutRef.current = null;
+ executeRun();
+ }, 100);
+ };
+
const handleDebugStart = () => {
setError(null);
setOutput([]);
+ setHasRun(true);
try {
const runLang = sourceLang === 'ast' ? 'python' : sourceLang;
@@ -607,6 +695,7 @@ export default function EditorPage() {
setAst(null);
setOutput([]);
setError(null);
+ setHasRun(false);
};
const handleLoadExample = (exampleId: string) => {
@@ -621,6 +710,7 @@ export default function EditorPage() {
setPanels((prev) => prev.filter((panel) => panel.lang !== example.lang));
setOutput([]);
setError(null);
+ setHasRun(false);
setIsDebugging(false);
setIsDebugComplete(false);
setHighlightedSourceLines([]);
@@ -635,6 +725,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.
@@ -691,7 +788,7 @@ export default function EditorPage() {
const addPanel = (lang: SupportedLang) => {
setPanels((prev) => {
- if (lang === sourceLang || prev.some((panel) => panel.lang === lang)) {
+ if (prev.some((panel) => panel.lang === lang)) {
return prev;
}
@@ -933,7 +1030,6 @@ export default function EditorPage() {
);
useClickOutside(showSettingsMenu, '.settings-dropdown', () => setShowSettingsMenu(false));
useClickOutside(showExamplesMenu, '.examples-dropdown', () => setShowExamplesMenu(false));
- useClickOutside(showAddMenu, '.add-panel-dropdown', () => setShowAddMenu(false));
// F5/F10 shortcuts. The handlers close over fresh state each render, so we
// read them through a ref — the listener itself is registered only once.
@@ -999,7 +1095,6 @@ export default function EditorPage() {
embedCopied={embedCopied}
showExamplesMenu={showExamplesMenu}
showSettingsMenu={showSettingsMenu}
- showMemDia={showMemDia}
isDebugging={isDebugging}
isDebugComplete={isDebugComplete}
examples={EXAMPLE_PROGRAMS}
@@ -1015,12 +1110,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}
@@ -1029,6 +1118,15 @@ export default function EditorPage() {
/>
+
+
@@ -1172,6 +1270,7 @@ export default function EditorPage() {
-
setShowAddMenu((prev) => !prev)}
- onTogglePanel={togglePanel}
- onToggleAiPanel={handleToggleAiPanel}
- onStartAiResize={handleStartAiResize}
- />
-
{showAiSidePanel && (
= 90:
elif score >= 80:
print("B")
else:
- print("Keep practicing")`,
+ print("Keep practicing")
+`,
},
{
- id: 'python-running-total',
- title: 'Python Running Total',
- description: 'While loop and assignment updates',
+ id: 'python-dice-roll',
+ title: 'Python Dice Roll',
+ description: 'While a random condition is met',
category: 'loops',
lang: 'python',
- code: `i = 1
-total = 0
+ code: `randomSeed(7)
+roll = 0
+attempts = 0
-while i <= 5:
- total = total + i
- i = i + 1
+while roll != 6:
+ roll = randomInt(6) + 1
+ attempts = attempts + 1
+ print(roll)
-print(total)`,
+print("Rolled a 6 after", attempts, "tries")
+`,
},
{
id: 'java-main-loop',
@@ -97,7 +105,8 @@ print(total)`,
}
System.out.println(sum);
}
-}`,
+}
+`,
},
{
id: 'java-if-branch',
@@ -110,7 +119,8 @@ if (x < 10) {
System.out.println("small");
} else {
System.out.println("big");
-}`,
+}
+`,
},
{
id: 'csp-repeat-until',
@@ -123,7 +133,8 @@ REPEAT UNTIL (x >= 5)
{
x <- x + 1
}
-DISPLAY(x)`,
+DISPLAY(x)
+`,
},
{
id: 'csp-procedure-greet',
@@ -136,40 +147,13 @@ DISPLAY(x)`,
DISPLAY("Hello " + name)
}
-greet("Praxly")`,
- },
- {
- id: 'javascript-grade-check',
- title: 'JS Grade Bands',
- description: 'If / else if / else branching',
- category: 'conditionals',
- lang: 'javascript',
- code: `let score = 84;
-
-if (score >= 90) {
- console.log("A");
-} else if (score >= 80) {
- console.log("B");
-} else {
- console.log("Keep practicing");
-}`,
- },
- {
- id: 'javascript-for-loop',
- title: 'JS Running Sum',
- description: 'C-style for loop with accumulator',
- category: 'loops',
- lang: 'javascript',
- code: `let total = 0;
-for (let i = 1; i <= 5; i++) {
- total = total + i;
-}
-console.log(total);`,
+greet("Praxly")
+`,
},
{
id: 'javascript-function',
title: 'JS Max Function',
- description: 'Function declaration with conditional return',
+ description: 'Declare with conditional return',
category: 'functions',
lang: 'javascript',
code: `function max(a, b) {
@@ -180,7 +164,8 @@ console.log(total);`,
}
}
-console.log(max(7, 3));`,
+console.log(max(7, 3));
+`,
},
{
id: 'javascript-array-loop',
@@ -193,7 +178,8 @@ let sum = 0;
for (const n of nums) {
sum = sum + n;
}
-console.log(sum);`,
+console.log(sum);
+`,
},
];