// ========================================== // 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']; }, };