Skip to content
Closed
30 changes: 28 additions & 2 deletions src/components/PlaygroundSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React from "react";
import { IoCodeSlash } from "react-icons/io5";
import { VscOutput } from "react-icons/vsc";
import { FiTerminal, FiShare2, FiSettings } from "react-icons/fi";
import { FiTerminal, FiShare2, FiSettings, FiRefreshCcw } from "react-icons/fi";
import { FaCirclePlay } from "react-icons/fa6";
import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
import useAppStore from "../store/store";
import { message, Tooltip } from "antd";
import { message, Modal, Tooltip } from "antd";
import FullScreenModal from "./FullScreenModal";
import SettingsModal from "./SettingsModal";
import tour from "./Tour";
Expand All @@ -22,6 +22,7 @@ const PlaygroundSidebar = () => {
setProblemPanelVisible,
setAIChatOpen,
generateShareableLink,
resetToDefault,
setSettingsOpen,
} = useAppStore((state) => ({
isEditorsVisible: state.isEditorsVisible,
Expand All @@ -33,6 +34,7 @@ const PlaygroundSidebar = () => {
setProblemPanelVisible: state.setProblemPanelVisible,
setAIChatOpen: state.setAIChatOpen,
generateShareableLink: state.generateShareableLink,
resetToDefault: state.resetToDefault,
setSettingsOpen: state.setSettingsOpen,
}));

Expand All @@ -51,6 +53,25 @@ const PlaygroundSidebar = () => {
setSettingsOpen(true);
};

const handleReset = () => {
Modal.confirm({
title: 'Reset to Default?',
content: 'This will clear all your current work and reload the default playground sample. This action cannot be undone.',
okText: 'Reset',
okType: 'danger',
cancelText: 'Cancel',
onOk: async () => {
try {
await resetToDefault();
void message.success('Reset to default playground!');
} catch (err) {
console.error('Error resetting:', err);
void message.error('Failed to reset');
}
},
});
};

const handleStartTour = async () => {
try {
await tour.start();
Expand Down Expand Up @@ -141,6 +162,11 @@ const PlaygroundSidebar = () => {
icon: FiShare2,
onClick: () => void handleShare()
},
{
title: "Reset",
icon: FiRefreshCcw,
onClick: () => void handleReset()
},
Comment on lines +165 to +169
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new "Reset" sidebar action is user-facing and destructive; there are existing component tests for PlaygroundSidebar, but they don't cover rendering/clicking this new item or asserting the confirmation flow. Please extend the sidebar test to include the Reset button and verify it calls Modal.confirm and triggers resetToDefault on confirmation.

Copilot generated this review using guidance from repository custom instructions.
{
title: "Start Tour",
icon: FaCirclePlay,
Expand Down
77 changes: 76 additions & 1 deletion src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import * as playground from "../samples/playground";
import { compress, decompress } from "../utils/compression/compression";
import { AIConfig, ChatState } from '../types/components/AIAssistant.types';

const EDITOR_STATE_KEY = 'editor-state';

interface AppState {
templateMarkdown: string;
editorValue: string;
Expand Down Expand Up @@ -61,6 +63,7 @@ interface AppState {
toggleModelCollapse: () => void;
toggleTemplateCollapse: () => void;
toggleDataCollapse: () => void;
resetToDefault: () => Promise<void>;
showLineNumbers: boolean;
setShowLineNumbers: (value: boolean) => void;
isSettingsOpen: boolean;
Expand Down Expand Up @@ -137,6 +140,54 @@ const getInitialPanelState = () => {
return defaults;
};

/* --- Helper to safely load editor state --- */
const getInitialEditorState = () => {
if(typeof window !== 'undefined'){
try{
const saved = localStorage.getItem(EDITOR_STATE_KEY);
if(saved){
return JSON.parse(saved);
}
} catch(e){
/* ignore */
Comment on lines +143 to +152
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getInitialEditorState currently returns the raw JSON.parse() result, which is typed as any. That makes the function effectively return any (the null branch is erased), and it also means malformed/legacy saved values can silently flow into the store without shape validation. Consider defining a PersistedEditorState interface (only the 6 persisted fields), parsing as unknown, validating/narrowing, and returning PersistedEditorState | null.

Suggested change
/* --- Helper to safely load editor state --- */
const getInitialEditorState = () => {
if(typeof window !== 'undefined'){
try{
const saved = localStorage.getItem(EDITOR_STATE_KEY);
if(saved){
return JSON.parse(saved);
}
} catch(e){
/* ignore */
interface PersistedEditorState {
readonly editorValue?: string;
readonly templateMarkdown?: string;
readonly editorModelCto?: string;
readonly modelCto?: string;
readonly data?: string;
readonly editorAgreementData?: string;
}
const isPersistedEditorState = (value: unknown): value is PersistedEditorState => {
if (value === null || typeof value !== "object") {
return false;
}
const candidate = value as Record<string, unknown>;
const isStringOrUndefined = (v: unknown): v is string | undefined =>
typeof v === "string" || typeof v === "undefined";
return (
(!("editorValue" in candidate) || isStringOrUndefined(candidate.editorValue)) &&
(!("templateMarkdown" in candidate) || isStringOrUndefined(candidate.templateMarkdown)) &&
(!("editorModelCto" in candidate) || isStringOrUndefined(candidate.editorModelCto)) &&
(!("modelCto" in candidate) || isStringOrUndefined(candidate.modelCto)) &&
(!("data" in candidate) || isStringOrUndefined(candidate.data)) &&
(!("editorAgreementData" in candidate) || isStringOrUndefined(candidate.editorAgreementData))
);
};
/* --- Helper to safely load editor state --- */
const getInitialEditorState = (): PersistedEditorState | null => {
if (typeof window !== "undefined") {
try {
const saved = localStorage.getItem(EDITOR_STATE_KEY);
if (saved) {
const parsed: unknown = JSON.parse(saved);
if (isPersistedEditorState(parsed)) {
return parsed;
}
}
} catch (e) {
/* ignore */

Copilot uses AI. Check for mistakes.
}
}
return null;
};

/* --- Helper to safely save editor state --- */
const saveEditorState = (state: Partial<AppState>) => {
if(typeof window !== 'undefined'){
const editorData = {
editorValue: state.editorValue,
templateMarkdown: state.templateMarkdown,
editorModelCto: state.editorModelCto,
modelCto: state.modelCto,
data: state.data,
editorAgreementData: state.editorAgreementData,
}
try {
localStorage.setItem(EDITOR_STATE_KEY, JSON.stringify(editorData));
} catch (e) {
// Handle quota exceeded error
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
console.warn('localStorage quota exceeded, clearing editor state and retrying');
localStorage.removeItem(EDITOR_STATE_KEY);
Comment on lines +171 to +175
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quota handling is currently gated on e.name === 'QuotaExceededError'. In practice, quota-exceeded failures use different names in some browsers (e.g., Firefox's NS_ERROR_DOM_QUOTA_REACHED) and sometimes only provide a numeric code. Consider broadening the detection (e.g., check DOMException code/name variants) so the graceful fallback runs consistently cross-browser.

Copilot uses AI. Check for mistakes.
// Try one more time after clearing
try {
localStorage.setItem(EDITOR_STATE_KEY, JSON.stringify(editorData));
} catch (retryError) {
console.error('Failed to save editor state even after clearing:', retryError);
}
} else {
console.error('Error saving editor state:', e);
}
}
}
};

const saveEditorStateDeBounced = debounce(saveEditorState, 1000);

Comment on lines +189 to +190
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds new persistent editor behavior + a new reset action, but there are existing unit tests for the store and localStorage-backed settings. Please add/extend store tests to cover: (1) debounced writes to editor-state, (2) init restoring state, and (3) resetToDefault clearing storage (including the case where a debounced save is pending).

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +189 to +190
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Persistence is only triggered from the individual setter actions. Store-driven content changes like loadSample (and potentially other bulk loads) won't update editor-state unless the user edits afterward, so a refresh immediately after loading a sample can restore stale persisted content. Consider invoking the debounced save after bulk state updates that change the editors (e.g., at the end of loadSample).

Copilot uses AI. Check for mistakes.
/* --- Helper to safely save panel state --- */
const savePanelState = (state: Partial<AppState>) => {
if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -232,6 +283,17 @@ const useAppStore = create<AppState>()(
if (compressedData) {
await get().loadFromLink(compressedData);
} else {
const savedState = getInitialEditorState();
if(savedState){
set((prev) => ({
templateMarkdown: savedState.templateMarkdown ?? prev.templateMarkdown,
editorValue: savedState.editorValue ?? prev.editorValue,
modelCto: savedState.modelCto ?? prev.modelCto,
editorModelCto: savedState.editorModelCto ?? prev.editorModelCto,
data: savedState.data ?? prev.data,
editorAgreementData: savedState.editorAgreementData ?? prev.editorAgreementData,
}));
}
await get().rebuild();
}
},
Expand Down Expand Up @@ -276,9 +338,11 @@ const useAppStore = create<AppState>()(
isProblemPanelVisible: true,
}));
}
saveEditorStateDeBounced(get());
},
setEditorValue: (value: string) => {
set(() => ({ editorValue: value }));
saveEditorStateDeBounced(get());
},
setModelCto: async (model: string) => {
set(() => ({ modelCto: model }));
Expand All @@ -292,9 +356,11 @@ const useAppStore = create<AppState>()(
isProblemPanelVisible: true,
}));
}
saveEditorStateDeBounced(get());
},
setEditorModelCto: (value: string) => {
set(() => ({ editorModelCto: value }));
saveEditorStateDeBounced(get());
},
setData: async (data: string) => {
set(() => ({ data }));
Expand All @@ -311,10 +377,11 @@ const useAppStore = create<AppState>()(
isProblemPanelVisible: true,
}));
}

saveEditorStateDeBounced(get());
},
setEditorAgreementData: (value: string) => {
set(() => ({ editorAgreementData: value }));
saveEditorStateDeBounced(get());
},
generateShareableLink: () => {
const state = get();
Expand Down Expand Up @@ -396,6 +463,14 @@ const useAppStore = create<AppState>()(
startTour: () => {
console.log('Starting tour...');
},
resetToDefault: async () => {
// Clear saved editor state from localStorage
if (typeof window !== 'undefined') {
Comment on lines +467 to +468
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resetToDefault removes the localStorage key, but any in-flight saveEditorStateDeBounced call scheduled from recent edits can still fire after the reset and repopulate editor-state with the pre-reset content. To make reset reliable, cancel/flush the pending debounced save before clearing storage (and/or temporarily disable saving during the reset flow).

Suggested change
// Clear saved editor state from localStorage
if (typeof window !== 'undefined') {
// Cancel any in-flight debounced save to avoid restoring stale editor state
try {
// saveEditorStateDeBounced is created via ts-debounce and exposes a cancel method
saveEditorStateDeBounced.cancel();
} catch {
// If for some reason cancellation fails, continue with reset
}
// Clear saved editor state from localStorage
if (typeof window !== "undefined") {

Copilot uses AI. Check for mistakes.
localStorage.removeItem(EDITOR_STATE_KEY);
}
// Load the default playground sample
await get().loadSample(playground.NAME);
},
}
})
)
Expand Down
Loading