// ==========================================
// Chat Switchboard – Surface Registry
// ==========================================
// Manages "modes" (surfaces) — chat, editor, etc.
// Each surface can take over named regions of the UI without
// destroying the DOM nodes of the previous surface.
//
// Load order: events.js → surfaces.js → extensions.js
//
// Key design: replace() detaches children (kept in memory),
// restore() re-attaches them. CM6 editor instances survive
// mode switches because their DOM isn't destroyed.
// ==========================================
const Surfaces = {
// ── State ────────────────────────────────
_registry: new Map(), // surfaceId → { label, icon, regions, activate, deactivate }
_current: 'chat', // active surface id
_saved: new Map(), // regionId → DocumentFragment (saved DOM children)
_regionEls: new Map(), // regionId → DOM element (cached lookups)
// ── Initialization ───────────────────────
/**
* Cache region elements and register chat as the implicit default surface.
* Called from app.js init after DOM is ready.
*/
init() {
// Cache all region containers
document.querySelectorAll('[data-surface-region]').forEach(el => {
const id = el.dataset.surfaceRegion;
this._regionEls.set(id, el);
});
// Chat is always registered as the default surface
this._registry.set('chat', {
label: 'Chat',
icon: 'message-square',
regions: ['surface-header', 'surface-main', 'surface-footer'],
activate: null, // chat activation is implicit (restore regions)
deactivate: null,
_isDefault: true,
});
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
},
// ── Registration ─────────────────────────
/**
* Register a new surface (mode).
* @param {string} id — unique surface identifier
* @param {object} opts — { label, icon, regions[], activate(), deactivate() }
*/
register(id, opts = {}) {
if (this._registry.has(id)) {
console.warn(`[Surfaces] ${id} already registered`);
return;
}
this._registry.set(id, {
label: opts.label || id,
icon: opts.icon || 'layout',
regions: opts.regions || [],
activate: opts.activate || null,
deactivate: opts.deactivate || null,
});
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
// Show mode selector if we now have >1 surface
this._updateModeSelector();
Events.emit('surface.registered', { surface: id }, { localOnly: true });
},
/**
* Unregister a surface. If it's currently active, switch back to chat.
*/
unregister(id) {
if (id === 'chat') return; // can't unregister chat
if (!this._registry.has(id)) return;
if (this._current === id) {
this.activate('chat');
}
this._registry.delete(id);
this._updateModeSelector();
Events.emit('surface.unregistered', { surface: id }, { localOnly: true });
},
// ── Activation ───────────────────────────
/**
* Switch to a different surface.
* Deactivates the current surface, saves its region DOM, and activates the new one.
*/
activate(id) {
if (!this._registry.has(id)) {
console.error(`[Surfaces] Unknown surface: ${id}`);
return;
}
if (this._current === id) return;
const previous = this._current;
const prevDef = this._registry.get(previous);
const nextDef = this._registry.get(id);
// Deactivate current surface
if (prevDef) {
// Save current region contents
for (const regionId of (prevDef.regions || [])) {
this._saveRegion(regionId);
}
// Call surface-specific deactivation
if (typeof prevDef.deactivate === 'function') {
try { prevDef.deactivate(); } catch (e) {
console.error(`[Surfaces] deactivate ${previous}:`, e);
}
}
}
Events.emit('surface.deactivated', { surface: previous }, { localOnly: true });
this._current = id;
// Activate new surface
if (typeof nextDef.activate === 'function') {
try { nextDef.activate(); } catch (e) {
console.error(`[Surfaces] activate ${id}:`, e);
}
} else {
// Default behavior: restore saved DOM for this surface's regions
for (const regionId of (nextDef.regions || [])) {
this._restoreRegion(regionId);
}
}
// Update mode selector active state
this._updateModeSelectorActive();
Events.emit('surface.activated', { surface: id, previous }, { localOnly: true });
console.log(`[Surfaces] Activated: ${id} (was: ${previous})`);
},
// ── Query ────────────────────────────────
/** Get the currently active surface id. */
getCurrent() {
return this._current;
},
/** Get surface definition by id. */
get(id) {
return this._registry.get(id) || null;
},
/** Get all registered surface ids. */
list() {
return Array.from(this._registry.keys());
},
/** True when more than just chat is registered. */
hasMultiple() {
return this._registry.size > 1;
},
/**
* Get a saved DocumentFragment for a specific surface + region.
* Used by surfaces like editor-mode that want to embed chat DOM
* inside their own layout.
* @param {string} surfaceId — the surface that owns the saved DOM
* @param {string} regionId — the region name
* @returns {DocumentFragment|null}
*/
getSavedFragment(surfaceId, regionId) {
const key = `${surfaceId}::${regionId}`;
return this._saved.get(key) || null;
},
/**
* Put a DocumentFragment back into the saved store.
* Used during deactivation to return borrowed DOM.
*/
putSavedFragment(surfaceId, regionId, frag) {
const key = `${surfaceId}::${regionId}`;
this._saved.set(key, frag);
},
// ── Region Management ────────────────────
/**
* Replace a region's content with a new element.
* The current children are saved (detached, not destroyed) and can be
* restored later with restore(). This is critical for CM6 state preservation.
*
* @param {string} regionId — data-surface-region value
* @param {Element} element — new content to insert
*/
replace(regionId, element) {
const container = this._regionEls.get(regionId);
if (!container) {
console.warn(`[Surfaces] Unknown region: ${regionId}`);
return;
}
// Save current children to a DocumentFragment (preserves DOM state)
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
// Insert new content
if (element) {
container.appendChild(element);
}
},
/**
* Restore a region's previously saved content.
* @param {string} regionId — data-surface-region value
*/
restore(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear current contents
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Re-attach saved DOM
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Internal: Save/Restore Regions ───────
/**
* Save the current DOM children of a region for the active surface.
* Called during deactivation.
*/
_saveRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
},
/**
* Restore the saved DOM children of a region for the active surface.
* Called during activation.
*/
_restoreRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear container
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Restore saved content
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Mode Selector UI ────────────────────
/**
* Rebuild the mode selector in the sidebar.
* Shown only when ≥1 extension surface is registered (i.e. more than just chat).
*/
_updateModeSelector() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
if (this._registry.size <= 1) {
wrap.style.display = 'none';
wrap.innerHTML = '';
return;
}
wrap.style.display = '';
wrap.innerHTML = '';
for (const [id, def] of this._registry) {
const btn = document.createElement('button');
btn.className = 'mode-btn' + (id === this._current ? ' active' : '');
btn.dataset.surface = id;
btn.title = def.label;
btn.innerHTML = `${this._iconSvg(def.icon)}${def.label}`;
btn.addEventListener('click', () => this.activate(id));
wrap.appendChild(btn);
}
},
/** Update the active class on mode selector buttons. */
_updateModeSelectorActive() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
wrap.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.surface === this._current);
});
},
/**
* Return an SVG string for a lucide-style icon name.
* Only the icons we actually need are included here.
*/
_iconSvg(name) {
const icons = {
'message-square': '',
'code': '',
'file-text': '',
'layout': '',
'terminal': '',
'globe': '',
};
return icons[name] || icons['layout'];
},
};