diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx
index 8629c354c6..8dd17223e8 100644
--- a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx
+++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx
@@ -108,15 +108,19 @@ function PageSceneEditor() {
// onSceneSerialized callback on unmount. Mirrors how Tauri stashes
// per-tab scene JSON via useTabStore.
//
- // Scoped to the no-token playground: when sceneToken is in the URL
- // the server-loaded scene is the source of truth, so we neither
- // restore from cache (would override the shared scene) nor write the
- // session back into it.
- const setCacheJsonString = useSceneCacheStore((s) => s.setCacheJsonString);
- const cacheJsonString = sceneToken
- ? undefined
- : useSceneCacheStore.getState().cacheJsonString;
- const onSceneSerialized = sceneToken ? undefined : setCacheJsonString;
+ // Keyed by sceneToken so each scene (and the playground) gets its own
+ // slot — visiting /edit-3d/A, /edit-3d/B, /edit-3d/A round-trips back
+ // to A's in-progress state rather than B's or the server's. Cache
+ // hits short-circuit the lib's loadScene path (Editor.initialize
+ // prefers cacheJsonString over the sceneToken).
+ const setCurrent = useSceneCacheStore((s) => s.setCurrent);
+ const cacheJsonString = useSceneCacheStore
+ .getState()
+ .getEntry(sceneToken)?.current;
+ const onSceneSerialized = useCallback(
+ (json: string) => setCurrent(sceneToken, json),
+ [sceneToken, setCurrent],
+ );
return (
void;
- clear: () => void;
+// Per-token scratch cache for the 3D editor.
+//
+// The lib gives the host two hooks: a `cacheJsonString` it'll restore
+// from on mount (priority over re-fetching a sceneToken), and an
+// `onSceneSerialized` callback it fires whenever the editor has fresh
+// JSON to stash. We pair those with sessionStorage so SPA navigation,
+// tab reloads, and Cmd+R survive — but a fresh browser session starts
+// clean (no cross-user bleed).
+//
+// Each cache entry holds both the snapshot the host loaded from the
+// server (`original`) and the user's latest in-progress state
+// (`current`). The destructive "Reset to original" menu item is
+// available iff `original` is defined for the active scene, and the
+// reset flow just reads `original` and re-applies it via
+// `editor.applyJson`.
+//
+// Playground sessions (no sceneToken in the URL) key on PLAYGROUND_KEY
+// and don't track an `original` — there's no "server version" to revert
+// to.
+
+export const PLAYGROUND_KEY = "__playground__";
+
+export interface SceneCacheEntry {
+ original?: string;
+ current: string;
}
+interface SceneCacheStore {
+ entries: Record;
+ getEntry: (token: string | undefined) => SceneCacheEntry | undefined;
+ setOriginal: (token: string, original: string) => void;
+ setCurrent: (token: string | undefined, current: string) => void;
+ resetCurrentToOriginal: (token: string) => void;
+ clearToken: (token: string | undefined) => void;
+}
+
+const keyOf = (token: string | undefined) => token ?? PLAYGROUND_KEY;
+
export const useSceneCacheStore = create()(
persist(
- (set) => ({
- cacheJsonString: undefined,
- setCacheJsonString: (cacheJsonString) => set({ cacheJsonString }),
- clear: () => set({ cacheJsonString: undefined }),
+ (set, get) => ({
+ entries: {},
+ getEntry: (token) => get().entries[keyOf(token)],
+ setOriginal: (token, original) =>
+ set((state) => {
+ const key = keyOf(token);
+ const existing = state.entries[key];
+ return {
+ entries: {
+ ...state.entries,
+ [key]: {
+ ...existing,
+ original,
+ current: existing?.current ?? original,
+ },
+ },
+ };
+ }),
+ setCurrent: (token, current) =>
+ set((state) => {
+ const key = keyOf(token);
+ const existing = state.entries[key];
+ return {
+ entries: {
+ ...state.entries,
+ [key]: { ...existing, current },
+ },
+ };
+ }),
+ resetCurrentToOriginal: (token) =>
+ set((state) => {
+ const key = keyOf(token);
+ const existing = state.entries[key];
+ if (!existing?.original) return state;
+ return {
+ entries: {
+ ...state.entries,
+ [key]: { ...existing, current: existing.original },
+ },
+ };
+ }),
+ clearToken: (token) =>
+ set((state) => {
+ const key = keyOf(token);
+ if (!(key in state.entries)) return state;
+ const next = { ...state.entries };
+ delete next[key];
+ return { entries: next };
+ }),
}),
{
name: "artcraft-webapp-pagescene-cache",
diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx
index 0ca2dbb7de..1d517e9c06 100644
--- a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx
+++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx
@@ -19,7 +19,7 @@ import {
UsersApi,
} from "@storyteller/api";
import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
-import { ToastTypes } from "@storyteller/ui-pagescene";
+import { getActiveEditor, ToastTypes } from "@storyteller/ui-pagescene";
import {
UploadModal3D,
UploadModalImage,
@@ -28,6 +28,7 @@ import {
} from "@storyteller/ui-upload-modal";
import { showToast } from "../../components/toast/toast";
import { invalidateSession } from "../../lib/session";
+import { useSceneCacheStore } from "./scene-cache-store";
const apiHost = () =>
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
@@ -90,6 +91,16 @@ const loadSceneViaApi = async (token: string): Promise => {
const fileResp = await fetch(cdnUrl);
if (!fileResp.ok) throw new Error(`Scene fetch HTTP ${fileResp.status}`);
const text = await fileResp.text();
+
+ // Seed the scene cache's `original` snapshot on first fetch so the
+ // destructive "Reset to original" menu item has something to revert
+ // to without going back over the network. Cache-hit reloads skip
+ // this path entirely (Editor.initialize uses cacheJsonString).
+ const cache = useSceneCacheStore.getState();
+ if (!cache.getEntry(token)?.original) {
+ cache.setOriginal(token, text);
+ }
+
return JSON.parse(text);
};
@@ -269,6 +280,25 @@ export const useWebAppPageSceneAdapter = (
promptSignup,
+ // Powers the lib's "Reset to original" menu item. The cache has
+ // the server's snapshot we seeded on first load; pipe it back
+ // through `Editor.applyJson` (which clears undo history and
+ // re-renders) and mirror `current = original` in the cache so a
+ // pre-serialization reload doesn't drag the edits back.
+ resetToOriginal: async () => {
+ if (!initialSceneToken) return;
+ const editor = getActiveEditor();
+ if (!editor) return;
+ const entry = useSceneCacheStore
+ .getState()
+ .getEntry(initialSceneToken);
+ if (!entry?.original) return;
+ await editor.applyJson(entry.original);
+ useSceneCacheStore
+ .getState()
+ .resetCurrentToOriginal(initialSceneToken);
+ },
+
performLogout: async () => {
try {
await new UsersApi().Logout();