+
-
@@ -150,7 +155,7 @@
+
-
Chat Switchboard
@@ -158,7 +163,7 @@
+
⚠️
@@ -1065,6 +1070,7 @@
+
@@ -1076,6 +1082,18 @@
+
+
+
+
+
+
+
+
+ >
+
+
+
@@ -1157,7 +1175,9 @@
+
+
diff --git a/src/js/app.js b/src/js/app.js
index 8dc91a3..9d0c368 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -173,6 +173,10 @@ async function startApp() {
UI.restoreSidebar();
await loadSettings();
+ // Initialize surface system (v0.21.3) — must happen before extensions
+ // so that extensions can register surfaces during init().
+ if (typeof Surfaces !== 'undefined') Surfaces.init();
+
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
// are registered when messages are first rendered.
try {
@@ -198,6 +202,7 @@ async function startApp() {
await initBanners();
initAttachments();
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
+ if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
if (typeof KnowledgeUI !== 'undefined') {
KnowledgeUI.init();
} else {
diff --git a/src/js/extensions.js b/src/js/extensions.js
index 76444f5..e5c6fcb 100644
--- a/src/js/extensions.js
+++ b/src/js/extensions.js
@@ -242,6 +242,22 @@ const Extensions = {
return Promise.resolve(window.confirm(msg));
},
+ /**
+ * Replace a surface region's content with a new element.
+ * Current children are preserved in memory (not destroyed)
+ * and can be restored later. Critical for CM6 state preservation.
+ */
+ replace(regionId, element) {
+ if (typeof Surfaces !== 'undefined') Surfaces.replace(regionId, element);
+ },
+
+ /**
+ * Restore a surface region's previously saved content.
+ */
+ restore(regionId) {
+ if (typeof Surfaces !== 'undefined') Surfaces.restore(regionId);
+ },
+
/** Inject an element into a named UI region (stub for future use). */
inject(region, el) {
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
@@ -254,6 +270,23 @@ const Extensions = {
},
},
+ // Surface registration (v0.21.3)
+ surfaces: {
+ register: (id, opts) => {
+ if (typeof Surfaces !== 'undefined') Surfaces.register(id, opts);
+ },
+ unregister: (id) => {
+ if (typeof Surfaces !== 'undefined') Surfaces.unregister(id);
+ },
+ activate: (id) => {
+ if (typeof Surfaces !== 'undefined') Surfaces.activate(id);
+ },
+ getCurrent: () => {
+ if (typeof Surfaces !== 'undefined') return Surfaces.getCurrent();
+ return 'chat';
+ },
+ },
+
// Model info (resolved at call time)
get model() {
return {
diff --git a/src/js/repl.js b/src/js/repl.js
new file mode 100644
index 0000000..6e0ac85
--- /dev/null
+++ b/src/js/repl.js
@@ -0,0 +1,552 @@
+// ==========================================
+// Chat Switchboard – REPL Console
+// ==========================================
+// Fourth tab in the debug modal (Ctrl+Shift+L).
+// Evaluates JavaScript with top-level await support
+// via AsyncFunction. Injects app globals for introspection.
+//
+// Features:
+// - AsyncFunction wrapper (top-level await)
+// - Injected globals: API, Events, Extensions, DebugLog, Surfaces, Stores
+// - Pretty-print results (collapsible JSON, red errors with stack)
+// - Command history: ↑/↓ with sessionStorage
+// - Tab-completion on live object graphs
+// - Event label hints on Events.on(' / Events.emit('
+// - Multi-line: Shift+Enter for newline, Enter to run
+// - Admin-gated OR ?debug=1 URL param
+// ==========================================
+
+const REPL = {
+
+ // ── State ────────────────────────────────
+ _history: [],
+ _historyIndex: -1,
+ _maxHistory: 100,
+ _storageKey: 'cs::repl::history',
+ _initialized: false,
+
+ // ── Initialization ───────────────────────
+
+ init() {
+ if (this._initialized) return;
+ this._initialized = true;
+
+ // Restore history from sessionStorage
+ try {
+ const saved = sessionStorage.getItem(this._storageKey);
+ if (saved) this._history = JSON.parse(saved);
+ } catch { /* ignore */ }
+
+ const input = document.getElementById('replInput');
+ if (!input) return;
+
+ input.addEventListener('keydown', (e) => this._onKeyDown(e));
+ input.addEventListener('input', () => this._autoResize(input));
+
+ // Gate: hide REPL tab if not admin and no ?debug=1
+ this._applyGate();
+ },
+
+ /**
+ * Hide the REPL tab unless user is admin or ?debug=1 is set.
+ */
+ _applyGate() {
+ const tab = document.querySelector('.debug-tab[data-tab="repl"]');
+ if (!tab) return;
+
+ const isAdmin = typeof API !== 'undefined' && API.user?.role === 'admin';
+ const debugParam = new URLSearchParams(window.location.search).has('debug');
+
+ if (!isAdmin && !debugParam) {
+ tab.style.display = 'none';
+ } else {
+ tab.style.display = '';
+ }
+ },
+
+ // ── Execution ────────────────────────────
+
+ /**
+ * Evaluate an expression with top-level await support.
+ * Injects app globals as local variables.
+ */
+ async run(code) {
+ if (!code.trim()) return;
+
+ // Add to history
+ this._history.push(code);
+ if (this._history.length > this._maxHistory) this._history.shift();
+ this._historyIndex = -1;
+ this._saveHistory();
+
+ // Render input
+ this._appendInput(code);
+
+ const startTime = performance.now();
+
+ try {
+ // Build an AsyncFunction with injected globals.
+ // The last expression is auto-returned if no explicit return.
+ const wrappedCode = this._wrapCode(code);
+
+ // Injected globals accessible inside the REPL
+ const globals = this._getGlobals();
+ const argNames = Object.keys(globals);
+ const argValues = Object.values(globals);
+
+ // eslint-disable-next-line no-new-func
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
+ const fn = new AsyncFunction(...argNames, wrappedCode);
+
+ const result = await fn(...argValues);
+ const elapsed = (performance.now() - startTime).toFixed(1);
+
+ if (result !== undefined) {
+ this._appendResult(result, elapsed);
+ } else {
+ this._appendInfo(`(undefined) [${elapsed}ms]`);
+ }
+ } catch (err) {
+ this._appendError(err);
+ }
+
+ this._scrollToBottom();
+ },
+
+ /**
+ * Wrap code for AsyncFunction evaluation.
+ * If the code is a single expression (no semicolons, no let/const/var/return),
+ * auto-return it. Otherwise, execute as-is.
+ */
+ _wrapCode(code) {
+ const trimmed = code.trim();
+
+ // Multi-statement: check for declarations or multiple statements
+ if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) {
+ return trimmed;
+ }
+
+ // Single expression — auto-return
+ // Handle edge case: object literals starting with { need wrapping
+ if (trimmed.startsWith('{') && !trimmed.startsWith('{(')) {
+ return `return (${trimmed})`;
+ }
+
+ return `return (${trimmed})`;
+ },
+
+ /**
+ * Get the set of globals injected into REPL scope.
+ */
+ _getGlobals() {
+ const globals = {};
+
+ // Core app objects
+ if (typeof API !== 'undefined') globals.API = API;
+ if (typeof Events !== 'undefined') globals.Events = Events;
+ if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
+ if (typeof Surfaces !== 'undefined') globals.Surfaces = Surfaces;
+ if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
+ if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
+ if (typeof UI !== 'undefined') globals.UI = UI;
+
+ // Convenience aliases
+ globals.$ = (sel) => document.querySelector(sel);
+ globals.$$ = (sel) => [...document.querySelectorAll(sel)];
+ globals.sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+ return globals;
+ },
+
+ // ── Key Handling ─────────────────────────
+
+ _onKeyDown(e) {
+ const input = e.target;
+
+ // Enter (without Shift) → execute
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ this.run(input.value);
+ input.value = '';
+ this._autoResize(input);
+ return;
+ }
+
+ // ↑ / ↓ → history navigation (only when cursor is on first/last line)
+ if (e.key === 'ArrowUp' && this._isCursorOnFirstLine(input)) {
+ e.preventDefault();
+ this._navigateHistory(-1, input);
+ return;
+ }
+ if (e.key === 'ArrowDown' && this._isCursorOnLastLine(input)) {
+ e.preventDefault();
+ this._navigateHistory(1, input);
+ return;
+ }
+
+ // Tab → completion
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ this._tabComplete(input);
+ return;
+ }
+ },
+
+ _isCursorOnFirstLine(input) {
+ return input.value.substring(0, input.selectionStart).indexOf('\n') === -1;
+ },
+
+ _isCursorOnLastLine(input) {
+ return input.value.substring(input.selectionStart).indexOf('\n') === -1;
+ },
+
+ _autoResize(input) {
+ input.style.height = 'auto';
+ input.style.height = Math.min(input.scrollHeight, 120) + 'px';
+ },
+
+ // ── History ──────────────────────────────
+
+ _navigateHistory(direction, input) {
+ if (this._history.length === 0) return;
+
+ if (this._historyIndex === -1) {
+ // Starting navigation — save current input
+ this._historyScratch = input.value;
+ this._historyIndex = this._history.length;
+ }
+
+ this._historyIndex += direction;
+
+ if (this._historyIndex < 0) this._historyIndex = 0;
+ if (this._historyIndex >= this._history.length) {
+ // Past end → restore scratch
+ this._historyIndex = -1;
+ input.value = this._historyScratch || '';
+ } else {
+ input.value = this._history[this._historyIndex];
+ }
+
+ this._autoResize(input);
+ // Move cursor to end
+ input.selectionStart = input.selectionEnd = input.value.length;
+ },
+
+ _saveHistory() {
+ try {
+ sessionStorage.setItem(this._storageKey, JSON.stringify(this._history));
+ } catch { /* quota exceeded — ignore */ }
+ },
+
+ // ── Tab Completion ──────────────────────
+
+ _tabComplete(input) {
+ const cursor = input.selectionStart;
+ const text = input.value.substring(0, cursor);
+
+ // Event label completion: Events.on(' or Events.emit('
+ const eventMatch = text.match(/Events\.(on|once|emit|off)\s*\(\s*['"]([^'"]*)?$/);
+ if (eventMatch) {
+ this._completeEventLabels(input, cursor, eventMatch[2] || '');
+ return;
+ }
+
+ // Object property completion: obj.prop or obj.prop.sub
+ const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
+ if (propMatch) {
+ this._completeProperty(input, cursor, propMatch[1], propMatch[2]);
+ return;
+ }
+
+ // Top-level global completion
+ const wordMatch = text.match(/([\w$]+)$/);
+ if (wordMatch) {
+ this._completeGlobal(input, cursor, wordMatch[1]);
+ }
+ },
+
+ _completeEventLabels(input, cursor, partial) {
+ // Gather known event labels from the Events handler map
+ if (typeof Events === 'undefined') return;
+ const labels = Array.from(Events._handlers?.keys() || []);
+ const matches = labels.filter(l => l.startsWith(partial)).sort();
+
+ if (matches.length === 0) return;
+ if (matches.length === 1) {
+ this._insertCompletion(input, cursor, partial, matches[0]);
+ } else {
+ this._appendInfo('Completions: ' + matches.join(', '));
+ // Complete common prefix
+ const common = this._commonPrefix(matches);
+ if (common.length > partial.length) {
+ this._insertCompletion(input, cursor, partial, common);
+ }
+ }
+ },
+
+ _completeProperty(input, cursor, objExpr, partial) {
+ try {
+ // Resolve the object in global scope
+ const globals = this._getGlobals();
+ // eslint-disable-next-line no-new-func
+ const obj = new Function(...Object.keys(globals), `return ${objExpr}`)(...Object.values(globals));
+ if (obj == null) return;
+
+ const keys = [];
+ // Own + prototype properties
+ for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
+ for (const k of Object.getOwnPropertyNames(o)) {
+ if (k.startsWith(partial) && !k.startsWith('_')) keys.push(k);
+ }
+ }
+ const unique = [...new Set(keys)].sort();
+
+ if (unique.length === 0) return;
+ if (unique.length === 1) {
+ this._insertCompletion(input, cursor, partial, unique[0]);
+ } else {
+ this._appendInfo('Completions: ' + unique.slice(0, 30).join(', ') + (unique.length > 30 ? ' …' : ''));
+ const common = this._commonPrefix(unique);
+ if (common.length > partial.length) {
+ this._insertCompletion(input, cursor, partial, common);
+ }
+ }
+ } catch { /* can't resolve — ignore */ }
+ },
+
+ _completeGlobal(input, cursor, partial) {
+ const globals = Object.keys(this._getGlobals());
+ // Add common window globals
+ const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math',
+ 'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set'];
+ const matches = [...new Set(candidates)].filter(c => c.startsWith(partial)).sort();
+
+ if (matches.length === 0) return;
+ if (matches.length === 1) {
+ this._insertCompletion(input, cursor, partial, matches[0]);
+ } else {
+ this._appendInfo('Completions: ' + matches.join(', '));
+ const common = this._commonPrefix(matches);
+ if (common.length > partial.length) {
+ this._insertCompletion(input, cursor, partial, common);
+ }
+ }
+ },
+
+ _insertCompletion(input, cursor, partial, completion) {
+ const before = input.value.substring(0, cursor - partial.length);
+ const after = input.value.substring(cursor);
+ input.value = before + completion + after;
+ const newPos = before.length + completion.length;
+ input.selectionStart = input.selectionEnd = newPos;
+ },
+
+ _commonPrefix(strs) {
+ if (strs.length === 0) return '';
+ let prefix = strs[0];
+ for (let i = 1; i < strs.length; i++) {
+ while (!strs[i].startsWith(prefix)) {
+ prefix = prefix.slice(0, -1);
+ }
+ }
+ return prefix;
+ },
+
+ // ── Output Rendering ────────────────────
+
+ _appendInput(code) {
+ const output = document.getElementById('replOutput');
+ if (!output) return;
+
+ const div = document.createElement('div');
+ div.className = 'repl-entry repl-entry-input';
+
+ const pre = document.createElement('pre');
+ pre.textContent = '> ' + code;
+ div.appendChild(pre);
+ output.appendChild(div);
+ },
+
+ _appendResult(value, elapsed) {
+ const output = document.getElementById('replOutput');
+ if (!output) return;
+
+ const div = document.createElement('div');
+ div.className = 'repl-entry repl-entry-result';
+
+ const formatted = this._formatValue(value);
+ div.appendChild(formatted);
+
+ if (elapsed) {
+ const time = document.createElement('span');
+ time.className = 'repl-elapsed';
+ time.textContent = `[${elapsed}ms]`;
+ div.appendChild(time);
+ }
+
+ output.appendChild(div);
+ },
+
+ _appendError(err) {
+ const output = document.getElementById('replOutput');
+ if (!output) return;
+
+ const div = document.createElement('div');
+ div.className = 'repl-entry repl-entry-error';
+
+ const msg = document.createElement('pre');
+ msg.className = 'repl-error-msg';
+ msg.textContent = err.message || String(err);
+ div.appendChild(msg);
+
+ if (err.stack) {
+ const stack = document.createElement('pre');
+ stack.className = 'repl-error-stack';
+ stack.textContent = err.stack.split('\n').slice(1, 5).join('\n');
+ stack.style.display = 'none';
+
+ const toggle = document.createElement('button');
+ toggle.className = 'repl-stack-toggle';
+ toggle.textContent = '▸ stack';
+ toggle.onclick = () => {
+ const shown = stack.style.display !== 'none';
+ stack.style.display = shown ? 'none' : '';
+ toggle.textContent = shown ? '▸ stack' : '▾ stack';
+ };
+
+ div.appendChild(toggle);
+ div.appendChild(stack);
+ }
+
+ output.appendChild(div);
+ },
+
+ _appendInfo(text) {
+ const output = document.getElementById('replOutput');
+ if (!output) return;
+
+ const div = document.createElement('div');
+ div.className = 'repl-entry repl-entry-info';
+ div.textContent = text;
+ output.appendChild(div);
+ },
+
+ /**
+ * Format a JS value for display.
+ * Objects/arrays get collapsible JSON; primitives get plain text.
+ */
+ _formatValue(value) {
+ if (value === null) return this._textEl('null', 'repl-null');
+ if (value === undefined) return this._textEl('undefined', 'repl-undefined');
+
+ const type = typeof value;
+
+ if (type === 'string') return this._textEl(JSON.stringify(value), 'repl-string');
+ if (type === 'number' || type === 'bigint') return this._textEl(String(value), 'repl-number');
+ if (type === 'boolean') return this._textEl(String(value), 'repl-boolean');
+ if (type === 'function') return this._textEl(`[Function: ${value.name || 'anonymous'}]`, 'repl-function');
+ if (type === 'symbol') return this._textEl(String(value), 'repl-symbol');
+
+ if (value instanceof HTMLElement) {
+ return this._textEl(`<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`, 'repl-dom');
+ }
+
+ if (value instanceof Error) {
+ return this._textEl(`${value.constructor.name}: ${value.message}`, 'repl-error-msg');
+ }
+
+ // Objects and arrays → collapsible JSON
+ try {
+ const json = JSON.stringify(value, null, 2);
+ if (json.length < 200) {
+ return this._textEl(json, 'repl-json');
+ }
+ return this._collapsibleJson(json, value);
+ } catch {
+ return this._textEl(String(value), 'repl-object');
+ }
+ },
+
+ _textEl(text, className) {
+ const pre = document.createElement('pre');
+ pre.className = className || '';
+ pre.textContent = text;
+ return pre;
+ },
+
+ _collapsibleJson(json, value) {
+ const container = document.createElement('div');
+ container.className = 'repl-collapsible';
+
+ const summary = Array.isArray(value)
+ ? `Array(${value.length})`
+ : `Object {${Object.keys(value).slice(0, 3).join(', ')}${Object.keys(value).length > 3 ? ', …' : ''}}`;
+
+ const toggle = document.createElement('button');
+ toggle.className = 'repl-json-toggle';
+ toggle.textContent = '▸ ' + summary;
+
+ const pre = document.createElement('pre');
+ pre.className = 'repl-json';
+ pre.textContent = json;
+ pre.style.display = 'none';
+
+ toggle.onclick = () => {
+ const shown = pre.style.display !== 'none';
+ pre.style.display = shown ? 'none' : '';
+ toggle.textContent = (shown ? '▸ ' : '▾ ') + summary;
+ };
+
+ container.appendChild(toggle);
+ container.appendChild(pre);
+ return container;
+ },
+
+ _scrollToBottom() {
+ const output = document.getElementById('replOutput');
+ if (output) output.scrollTop = output.scrollHeight;
+ },
+
+ // ── Toolbar Actions ──────────────────────
+
+ clear() {
+ const output = document.getElementById('replOutput');
+ if (output) output.innerHTML = '';
+ },
+
+ copyOutput() {
+ const output = document.getElementById('replOutput');
+ if (!output) return;
+ const text = output.innerText;
+ navigator.clipboard.writeText(text)
+ .then(() => { if (typeof showToast === 'function') showToast('📋 REPL output copied', 'success'); })
+ .catch(() => {});
+ },
+
+ showHelp() {
+ const globals = Object.keys(this._getGlobals());
+ const help = [
+ '╭─ REPL Help ─────────────────────────────╮',
+ '│ Enter Run expression │',
+ '│ Shift+Enter New line │',
+ '│ ↑ / ↓ Command history │',
+ '│ Tab Auto-complete │',
+ '╰──────────────────────────────────────────╯',
+ '',
+ 'Injected globals: ' + globals.join(', '),
+ '',
+ 'Top-level await is supported:',
+ ' const r = await API._get("/api/v1/me")',
+ '',
+ 'Shortcuts:',
+ ' $(sel) → document.querySelector(sel)',
+ ' $$(sel) → document.querySelectorAll(sel)',
+ ' sleep(ms) → await sleep(1000)',
+ ];
+ help.forEach(line => this._appendInfo(line));
+ this._scrollToBottom();
+ },
+};
+
+// Initialized from app.js startApp() after auth is confirmed,
+// ensuring API.user.role is available for admin gate check.
diff --git a/src/js/surfaces.js b/src/js/surfaces.js
new file mode 100644
index 0000000..35f0697
--- /dev/null
+++ b/src/js/surfaces.js
@@ -0,0 +1,319 @@
+// ==========================================
+// Chat Switchboard – Surface Registry
+// ==========================================
+// Manages "modes" (surfaces) — chat, editor, article, 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;
+ },
+
+ // ── 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);
+ 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'];
+ },
+};