diff --git a/static/js/chat.js b/static/js/chat.js
index 31f632636..b19730050 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -35,6 +35,7 @@ import {
inheritModelRouteState,
} from './chatModelProvenance.js';
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
+import { loadPanel } from './panels.js';
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
@@ -6606,7 +6607,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// Images → Gallery editor.
if (isImage) {
try {
- const gx = await import('./galleryEditor.js');
+ const gx = await loadPanel('editor');
if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
} catch (e) { console.warn('gallery open failed', e); }
window.open(url, '_blank');
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index 023465095..b17d53179 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -9,6 +9,7 @@ import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
+import { loadPanel } from './panels.js';
import { matchModelKey } from './model/matchKey.js';
const SEARCH_ICON = '';
@@ -1548,7 +1549,7 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
try {
const [galleryMod, editorMod] = await Promise.all([
import('./gallery.js'),
- import('./galleryEditor.js'),
+ loadPanel('editor'),
]);
// Ensure the Gallery modal is open so the editor has a container
// to render into; switch its tabs to the Edit tab.
diff --git a/static/js/gallery.js b/static/js/gallery.js
index 93e0b5f2b..3e4aaa28c 100644
--- a/static/js/gallery.js
+++ b/static/js/gallery.js
@@ -3,7 +3,7 @@
*/
import uiModule from './ui.js';
-import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
+import { loadPanel } from './panels.js';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -15,6 +15,54 @@ const API_BASE = window.location.origin;
let _open = false;
let _galleryResizeHandler = null;
+// ── Image editor, loaded on first use ──
+// galleryEditor.js plus everything under js/editor/ is 54 modules / 576 KB.
+// It used to be a static import here, so every page load paid for it even
+// though most sessions never touch the Edit tab. The wrappers below keep the
+// three call shapes the rest of this file already uses.
+//
+// closeEditor() and isEditorOpen() stay synchronous on purpose: if the module
+// was never loaded there is no edit session to close, and none can be open.
+let _editorMod = null;
+let _editorLoading = false;
+
+async function _loadEditor() {
+ _editorLoading = true;
+ try {
+ _editorMod = await loadPanel('editor');
+ return _editorMod;
+ } finally {
+ _editorLoading = false;
+ }
+}
+
+async function openEditor(...args) {
+ let mod = _editorMod;
+ if (!mod) {
+ try {
+ mod = await _loadEditor();
+ } catch (e) {
+ // Previously unreachable — a static import either loaded or the whole
+ // page failed. Now it can fail on its own (offline before the panel was
+ // ever cached), so say so instead of doing nothing.
+ console.error('[gallery] image editor failed to load', e);
+ uiModule?.showError?.('Failed to load the image editor');
+ return;
+ }
+ }
+ return mod.openEditor(...args);
+}
+
+function closeEditor(...args) {
+ return _editorMod ? _editorMod.closeEditor(...args) : undefined;
+}
+
+// True while the module is still in flight as well — the gallery-close paths
+// use this to refuse to tear the container down under an edit that is opening.
+function isEditorOpen() {
+ return _editorLoading || (_editorMod ? _editorMod.isEditorOpen() : false);
+}
+
// Auto-refresh gallery when new image is generated
window.addEventListener('gallery-refresh', (e) => {
if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') {
diff --git a/static/js/panels.js b/static/js/panels.js
new file mode 100644
index 000000000..2ea672ae0
--- /dev/null
+++ b/static/js/panels.js
@@ -0,0 +1,53 @@
+/**
+ * Panel loader registry — imports the modules behind a feature panel the
+ * first time that panel is actually used.
+ *
+ * The panels already populate themselves on open (each fetches its own data).
+ * They were just not *loaded* on demand: every one of them sat on the critical
+ * path of every page load, opened or not.
+ *
+ * A panel only belongs here once it has been checked for import-time side
+ * effects that something outside the panel depends on at startup. Entries get
+ * added one panel at a time, not in bulk.
+ *
+ * Note the service worker still precaches these modules (PANEL_PRECACHE in
+ * sw.js) — they are off the critical path, not off the offline manifest.
+ */
+
+const LOADERS = {
+ editor: () => import('./galleryEditor.js'),
+};
+
+/**
+ * Build a memoising loader over a name -> import-thunk map. Exported so the
+ * behaviour can be tested without pulling a real panel's module graph in.
+ */
+export function createPanelLoader(loaders) {
+ const cache = new Map();
+ return function load(name) {
+ const cached = cache.get(name);
+ if (cached) return cached;
+ const loader = loaders[name];
+ if (!loader) throw new Error(`loadPanel: unknown panel "${name}"`);
+ // A failed load (offline, 404, syntax error) is not memoised — caching the
+ // rejection would leave the panel broken for the rest of the session even
+ // after the network came back.
+ const pending = Promise.resolve().then(loader).catch((err) => {
+ cache.delete(name);
+ throw err;
+ });
+ cache.set(name, pending);
+ return pending;
+ };
+}
+
+/**
+ * Load a panel's module, once. Returns the same promise on every call for the
+ * same panel; throws for a name that is not registered.
+ */
+export const loadPanel = createPanelLoader(LOADERS);
+
+/** Registered panel names — the registry is the list, not a second copy of it. */
+export function panelNames() {
+ return Object.keys(LOADERS);
+}
diff --git a/static/sw.js b/static/sw.js
index dea990b71..d72332d57 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -7,11 +7,22 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
-const CACHE_NAME = 'odysseus-v376-settings-title-icons';
+const CACHE_NAME = 'odysseus-v377-lazy-image-editor';
-// Core shell precached on install so repeat opens are instant without any
-// network wait. Keep this list in sync with the