// ========================================== // Chat Switchboard — PaneContainer // ========================================== // v0.25.0: Composable multi-pane layout system. // Replaces the "one surface fills viewport" model with resizable // side-by-side panes. Supports leaf panes (single component) and // tabbed panes (N components, tab bar, one active at a time). // // Layout presets define the pane structure. The surface manifest // declares which preset to use via __MANIFEST__.layout. // // Usage: // const layout = PaneContainer.mount( // document.getElementById('editorBody'), // 'editor', // { workspaceId: 'ws-123' } // ); // const fileTree = layout.getPane('files'); // layout.resize('files', 250); // layout.destroy(); const PaneContainer = { _presets: {}, _active: null, // ── Layout Preset Registration ────────── /** * Register a named layout preset. * @param {string} name - Preset name (e.g. 'single', 'editor', 'split') * @param {object} definition - Layout tree definition * * Definition format: * { type: 'leaf', id: 'main', component: 'chat-pane', opts: {} } * { type: 'tabbed', id: 'right', tabs: [{id,label,component,opts}], defaultTab: 0 } * { type: 'split', direction: 'horizontal', children: [def, def, ...], sizes: [220, null, 380] } * * sizes: array matching children. Numbers are initial px widths. null = flex:1 (fills remaining). */ registerPreset(name, definition) { this._presets[name] = definition; }, // ── Mount ─────────────────────────────── /** * Mount a layout preset into a root element. * @param {HTMLElement} rootEl - Container element * @param {string} presetName - Registered preset name * @param {object} opts - Passed to component factories (e.g. { workspaceId }) * @returns {object} Layout instance */ mount(rootEl, presetName, opts) { if (!rootEl) { console.error('[PaneContainer] No root element'); return null; } const preset = this._presets[presetName]; if (!preset) { console.error('[PaneContainer] Unknown preset:', presetName); return null; } opts = opts || {}; const surfaceId = window.__SURFACE__ || 'unknown'; // Build the DOM tree from the preset definition const panes = new Map(); // id → { el, component, type } const handles = []; const buildNode = (def, parentEl) => { if (def.type === 'leaf') { return _buildLeafPane(def, parentEl, panes, opts); } if (def.type === 'tabbed') { return _buildTabbedPane(def, parentEl, panes, opts, surfaceId); } if (def.type === 'split') { return _buildSplit(def, parentEl, panes, handles, opts, surfaceId); } console.error('[PaneContainer] Unknown node type:', def.type); return null; }; rootEl.classList.add('pane-container'); const rootNode = buildNode(preset, rootEl); // Restore persisted sizes _restoreSizes(surfaceId, presetName, handles); const instance = { rootEl, presetName, _panes: panes, _handles: handles, _rootNode: rootNode, /** Get the component instance for a pane by ID. */ getPane(id) { const p = panes.get(id); return p ? p.component : null; }, /** Get the pane element by ID. */ getPaneEl(id) { const p = panes.get(id); return p ? p.el : null; }, /** Programmatic resize of a leaf pane. */ resize(id, sizePx) { const p = panes.get(id); if (p?.el) { p.el.style.flexBasis = sizePx + 'px'; p.el.style.flexGrow = '0'; p.el.style.flexShrink = '0'; } }, /** Minimize a pane (collapse to 0). */ minimize(id) { const p = panes.get(id); if (!p?.el) return; p._prevBasis = p.el.style.flexBasis; p._prevGrow = p.el.style.flexGrow; p.el.style.flexBasis = '0px'; p.el.style.flexGrow = '0'; p.el.style.overflow = 'hidden'; p.el.classList.add('pane-minimized'); }, /** Restore a minimized pane. */ restore(id) { const p = panes.get(id); if (!p?.el) return; p.el.style.flexBasis = p._prevBasis || ''; p.el.style.flexGrow = p._prevGrow || ''; p.el.style.overflow = ''; p.el.classList.remove('pane-minimized'); }, /** Tear down the entire layout. */ destroy() { // Destroy all component instances for (const [, p] of panes) { if (p.component?.destroy) p.component.destroy(); // Tabbed: destroy all tab components if (p.tabs) { for (const tab of p.tabs) { if (tab.component?.destroy) tab.component.destroy(); } } } panes.clear(); handles.length = 0; rootEl.innerHTML = ''; rootEl.classList.remove('pane-container'); PaneContainer._active = null; }, }; PaneContainer._active = instance; return instance; }, /** Get the currently active layout instance. */ active() { return this._active; }, }; // ── Leaf Pane Builder ─────────────────────── function _buildLeafPane(def, parentEl, panes, opts) { const el = document.createElement('div'); el.className = 'pane pane-leaf'; el.dataset.paneId = def.id; if (def.size) { el.style.flexBasis = def.size + 'px'; el.style.flexGrow = '0'; el.style.flexShrink = '0'; } else { el.style.flex = '1'; el.style.minWidth = '0'; el.dataset.paneFlex = '1'; // Mark as flexible — drag handles skip this pane } if (def.minSize) el.style.minWidth = def.minSize + 'px'; parentEl.appendChild(el); // Component instantiation is deferred — the surface boot script // creates components and mounts them into pane elements. // We just register the pane slot here. panes.set(def.id, { el, component: null, // set by surface boot script type: 'leaf', def, }); return el; } // ── Tabbed Pane Builder ───────────────────── function _buildTabbedPane(def, parentEl, panes, opts, surfaceId) { const el = document.createElement('div'); el.className = 'pane pane-tabbed'; el.dataset.paneId = def.id; if (def.size) { el.style.flexBasis = def.size + 'px'; el.style.flexGrow = '0'; el.style.flexShrink = '0'; } else { el.style.flex = '1'; el.style.minWidth = '0'; } if (def.minSize) el.style.minWidth = def.minSize + 'px'; // Tab bar const tabBar = document.createElement('div'); tabBar.className = 'pane-tab-bar'; el.appendChild(tabBar); // Content area const contentArea = document.createElement('div'); contentArea.className = 'pane-tab-content'; el.appendChild(contentArea); // Restore persisted active tab const persistKey = 'sb_tabs_' + surfaceId + '_' + def.id; let savedTab = 0; try { savedTab = parseInt(localStorage.getItem(persistKey)) || 0; } catch (_) {} if (savedTab >= (def.tabs || []).length) savedTab = 0; const tabs = (def.tabs || []).map((tabDef, i) => { // Tab button const btn = document.createElement('button'); btn.className = 'pane-tab-btn' + (i === savedTab ? ' pane-tab-btn--active' : ''); btn.textContent = tabDef.label || tabDef.id; btn.dataset.tabIndex = i; tabBar.appendChild(btn); // Tab panel (mount point for component) const panel = document.createElement('div'); panel.className = 'pane-tab-panel'; panel.dataset.tabId = tabDef.id; panel.style.display = i === savedTab ? '' : 'none'; contentArea.appendChild(panel); return { id: tabDef.id, label: tabDef.label, component: tabDef.component, opts: tabDef.opts || {}, btn, panel, instance: null, // lazy-created on first activation _activated: i === savedTab, }; }); // Tab click handler const activateTab = (index) => { tabs.forEach((tab, i) => { const isActive = i === index; tab.btn.classList.toggle('pane-tab-btn--active', isActive); tab.panel.style.display = isActive ? '' : 'none'; if (isActive) tab._activated = true; }); // Persist try { localStorage.setItem(persistKey, String(index)); } catch (_) {} }; tabs.forEach((tab, i) => { tab.btn.addEventListener('click', () => activateTab(i)); }); parentEl.appendChild(el); panes.set(def.id, { el, component: null, // tabbed panes don't have a single component type: 'tabbed', def, tabs, activateTab, getActiveTab() { return tabs.find((_, i) => tabs[i].btn.classList.contains('pane-tab-btn--active')) || tabs[0]; }, getTabPanel(tabId) { const t = tabs.find(t => t.id === tabId); return t ? t.panel : null; }, }); return el; } // ── Split Builder ─────────────────────────── function _buildSplit(def, parentEl, panes, handles, opts, surfaceId) { const el = document.createElement('div'); el.className = 'pane-split pane-split--' + (def.direction || 'horizontal'); parentEl.appendChild(el); const children = def.children || []; const sizes = def.sizes || []; children.forEach((childDef, i) => { // Apply initial size from preset if (sizes[i] != null && childDef.size === undefined) { childDef.size = sizes[i]; } // Build child if (childDef.type === 'leaf') { _buildLeafPane(childDef, el, panes, opts); } else if (childDef.type === 'tabbed') { _buildTabbedPane(childDef, el, panes, opts, surfaceId); } else if (childDef.type === 'split') { _buildSplit(childDef, el, panes, handles, opts, surfaceId); } // Add drag handle between children (not after last) if (i < children.length - 1) { const handle = _createHandle(def.direction || 'horizontal', el, i, surfaceId, def.id || 'root'); handles.push(handle); } }); return el; } // ── Drag Handle ───────────────────────────── function _createHandle(direction, splitEl, index, surfaceId, splitId) { const handle = document.createElement('div'); handle.className = 'pane-handle pane-handle--' + direction; handle.dataset.handleIndex = index; // Insert after the Nth child (child, handle, child, handle, child) // Children are at positions 0, 2, 4, ... and handles at 1, 3, ... // But since we add sequentially, handle goes after the (index)th pane const childEls = splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split'); const afterEl = childEls[index]; if (afterEl?.nextSibling) { splitEl.insertBefore(handle, afterEl.nextSibling); } else { splitEl.appendChild(handle); } // Track adjacent panes for dblclick reset let lastLeftEl = null, lastRightEl = null; initDragResize(handle, { direction, onStart(_clientPos) { // Find the two panes adjacent to this handle const handleIdx = Array.from(splitEl.children).indexOf(handle); const leftEl = splitEl.children[handleIdx - 1]; const rightEl = splitEl.children[handleIdx + 1]; if (!leftEl || !rightEl) return false; lastLeftEl = leftEl; lastRightEl = rightEl; const isHoriz = direction === 'horizontal'; const dim = isHoriz ? 'width' : 'height'; const startLeftBasis = leftEl.getBoundingClientRect()[dim]; const startRightBasis = rightEl.getBoundingClientRect()[dim]; // Lock non-flex panes to current rendered size so the first // move delta doesn't cause a visual snap if (leftEl.dataset.paneFlex !== '1') { leftEl.style.flexBasis = startLeftBasis + 'px'; leftEl.style.flexGrow = '0'; leftEl.style.flexShrink = '0'; } if (rightEl.dataset.paneFlex !== '1') { rightEl.style.flexBasis = startRightBasis + 'px'; rightEl.style.flexGrow = '0'; rightEl.style.flexShrink = '0'; } handle.classList.add('pane-handle--active'); return { leftEl, rightEl, startLeftBasis, startRightBasis }; }, onMove(delta, ctx) { const { leftEl, rightEl, startLeftBasis, startRightBasis } = ctx; const leftIsFlex = leftEl.dataset.paneFlex === '1'; const rightIsFlex = rightEl.dataset.paneFlex === '1'; const isHoriz = direction === 'horizontal'; const dim = isHoriz ? 'width' : 'height'; const maxSize = splitEl.getBoundingClientRect()[dim] * 0.6; if (leftIsFlex) { const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta)); rightEl.style.flexBasis = newRight + 'px'; rightEl.style.flexGrow = '0'; rightEl.style.flexShrink = '0'; } else if (rightIsFlex) { const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta)); leftEl.style.flexBasis = newLeft + 'px'; leftEl.style.flexGrow = '0'; leftEl.style.flexShrink = '0'; } else { const newLeft = Math.max(50, startLeftBasis + delta); const newRight = Math.max(50, startRightBasis - delta); leftEl.style.flexBasis = newLeft + 'px'; leftEl.style.flexGrow = '0'; leftEl.style.flexShrink = '0'; rightEl.style.flexBasis = newRight + 'px'; rightEl.style.flexGrow = '0'; rightEl.style.flexShrink = '0'; } }, onEnd(_ctx) { handle.classList.remove('pane-handle--active'); _persistSizes(surfaceId, splitEl); }, }); // Double-click resets to default handle.addEventListener('dblclick', () => { _resetPaneSize(lastLeftEl); _resetPaneSize(lastRightEl); _persistSizes(surfaceId, splitEl); }); return { handle, splitEl, index, direction }; } /** Reset a pane element to its default flex sizing. */ function _resetPaneSize(el) { if (!el) return; if (el.dataset.paneFlex === '1') { el.style.flex = '1'; } el.style.flexBasis = ''; el.style.flexGrow = ''; el.style.flexShrink = ''; } // ── Persistence ───────────────────────────── // v0.27.0: Debounced server sync for cross-device pane layout persistence. var _paneSyncTimer = null; function _syncPaneLayoutsToServer() { if (_paneSyncTimer) clearTimeout(_paneSyncTimer); _paneSyncTimer = setTimeout(function() { try { // Collect all sb_layout_* keys var layouts = {}; for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (k && k.indexOf('sb_layout_') === 0) { try { layouts[k] = JSON.parse(localStorage.getItem(k)); } catch (_) {} } } if (typeof API !== 'undefined' && API._put) { API._put('/api/v1/settings', { pane_layouts: layouts }).catch(function() {}); } } catch (_) {} }, 2000); // 2s debounce — avoid hammering during resize drags } // v0.27.0: On page load, restore pane layouts from server if available. // Called once from PaneContainer init (or surface boot). function _loadPaneLayoutsFromServer() { if (typeof API === 'undefined' || !API._get) return; API._get('/api/v1/settings').then(function(settings) { if (!settings || !settings.pane_layouts) return; var layouts = settings.pane_layouts; for (var key in layouts) { if (key.indexOf('sb_layout_') === 0 && layouts[key]) { // Only overwrite if localStorage is empty for this key if (!localStorage.getItem(key)) { localStorage.setItem(key, JSON.stringify(layouts[key])); } } } }).catch(function() {}); } // Kick off server layout load on script execution if (typeof API !== 'undefined') _loadPaneLayoutsFromServer(); function _persistSizes(surfaceId, splitEl) { const key = 'sb_layout_' + surfaceId; try { // Collect sizes for fixed panes only (skip flex panes) const sizes = {}; splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split').forEach(child => { const id = child.dataset?.paneId; if (id && child.dataset.paneFlex !== '1') { sizes[id] = child.getBoundingClientRect().width; } }); // Merge with existing persisted data let existing = {}; try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {} Object.assign(existing, sizes); localStorage.setItem(key, JSON.stringify(existing)); // v0.27.0: Debounced sync to server for cross-device persistence _syncPaneLayoutsToServer(); } catch (_) {} } function _restoreSizes(surfaceId, presetName, handles) { const key = 'sb_layout_' + surfaceId; try { const saved = JSON.parse(localStorage.getItem(key) || '{}'); if (Object.keys(saved).length === 0) return; // Find the split container to measure available width const splitEl = document.querySelector('.pane-split--horizontal'); const containerWidth = splitEl ? splitEl.getBoundingClientRect().width : window.innerWidth; // Validate: sum of all fixed pane sizes must leave at least 150px for flex pane const fixedPanes = document.querySelectorAll('.pane[data-pane-id]'); let totalFixed = 0; fixedPanes.forEach(el => { const id = el.dataset.paneId; if (saved[id] && el.dataset.paneFlex !== '1') { totalFixed += saved[id]; } }); // If saved sizes overflow, clear corrupt data and use defaults if (totalFixed > containerWidth - 150) { console.warn('[PaneContainer] Saved layout overflows viewport (' + totalFixed + 'px > ' + containerWidth + 'px), resetting'); localStorage.removeItem(key); return; } // Apply saved sizes to pane elements (skip flex panes) fixedPanes.forEach(el => { const id = el.dataset.paneId; if (saved[id] && el.dataset.paneFlex !== '1') { el.style.flexBasis = saved[id] + 'px'; el.style.flexGrow = '0'; el.style.flexShrink = '0'; } }); } catch (_) {} } // ── Built-in Presets ──────────────────────── // single: One leaf pane (default chat UX) PaneContainer.registerPreset('single', { type: 'leaf', id: 'main', component: 'chat-pane', }); // editor: files | code-editor | PaneContainer.registerPreset('editor', { type: 'split', id: 'root', direction: 'horizontal', sizes: [220, null, 380], children: [ { type: 'leaf', id: 'files', component: 'file-tree', size: 220, minSize: 140 }, { type: 'leaf', id: 'editor', component: 'code-editor' }, { type: 'tabbed', id: 'assist', size: 380, minSize: 200, tabs: [ { id: 'chat', label: 'Chat', component: 'chat-pane' }, { id: 'notes', label: 'Notes', component: 'note-editor' }, ], }, ], }); // split: generic two-pane layout PaneContainer.registerPreset('split', { type: 'split', id: 'root', direction: 'horizontal', children: [ { type: 'leaf', id: 'primary', component: null }, { type: 'leaf', id: 'secondary', component: null }, ], });