Changeset 0.25.0 (#160)

This commit is contained in:
2026-03-08 16:54:17 +00:00
parent 937be26578
commit 2b01d540d6
63 changed files with 6942 additions and 2773 deletions

View File

@@ -264,6 +264,9 @@
'</div>' +
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
SCAFFOLDING.surfaces =
'<div id="adminSurfacesContent"></div>';
// === Add button actions ==============================================
@@ -312,21 +315,23 @@
if (!section) return;
// -- Category / nav resolution -----------
var catMap = {
users:'people', teams:'people', groups:'people',
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
health:'routing', routing:'routing', capabilities:'routing',
settings:'system', storage:'system', extensions:'system', channels:'system',
usage:'monitoring', audit:'monitoring', stats:'monitoring',
};
var cat = catMap[section] || 'people';
var secMap = {
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'},{id:'channels',l:'Channels'}],
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
};
// Derive from ADMIN_SECTIONS + ADMIN_LABELS (ui-admin.js) — single source of truth.
// admin-scaffold.js previously had its own duplicate catMap/secMap.
var cat = 'people';
if (typeof ADMIN_SECTIONS !== 'undefined') {
for (var c in ADMIN_SECTIONS) {
if (ADMIN_SECTIONS[c].indexOf(section) !== -1) { cat = c; break; }
}
}
var secMap;
if (typeof ADMIN_SECTIONS !== 'undefined' && typeof ADMIN_LABELS !== 'undefined') {
secMap = {};
for (var c in ADMIN_SECTIONS) {
secMap[c] = ADMIN_SECTIONS[c].map(function(id) {
return { id: id, l: ADMIN_LABELS[id] || id };
});
}
}
// Rebuild left nav for current category
var navEl = document.getElementById('adminNav');

198
src/js/admin-surfaces.js Normal file
View File

@@ -0,0 +1,198 @@
// ==========================================
// Chat Switchboard — Admin Surfaces UI
// ==========================================
// v0.25.0: Surface lifecycle management.
// Renders in the "Surfaces" admin section via ADMIN_LOADERS.
async function _loadAdminSurfaces() {
const container = document.getElementById('adminSurfacesContent');
if (!container) return;
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<p style="font-size:13px;color:var(--text-2);margin:0;line-height:1.6;flex:1;">
Manage which surfaces are available. Disabled surfaces redirect to Chat and are hidden from navigation.
</p>
<button id="adminSurfaceInstallBtn" class="btn-small" style="margin-left:16px;white-space:nowrap;">
+ Install Surface
</button>
</div>
<div id="adminSurfaceInstallForm" style="display:none;margin-bottom:16px;padding:16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Install Surface Archive</div>
<p style="font-size:12px;color:var(--text-3);margin:0 0 12px;">Upload a <code>.surface</code> or <code>.zip</code> archive containing manifest.json, js/, css/, and assets/.</p>
<div style="display:flex;align-items:center;gap:8px;">
<input type="file" id="adminSurfaceFile" accept=".surface,.zip" style="font-size:12px;color:var(--text-2);">
<button id="adminSurfaceUploadBtn" class="btn-small btn-primary" disabled>Upload</button>
<button id="adminSurfaceCancelBtn" class="btn-small">Cancel</button>
</div>
<div id="adminSurfaceInstallStatus" style="font-size:12px;margin-top:8px;"></div>
</div>
<div id="adminSurfaceList" class="admin-surface-list">
<div style="padding:12px;font-size:12px;color:var(--text-3);">Loading…</div>
</div>
`;
// Wire install form
var installBtn = document.getElementById('adminSurfaceInstallBtn');
var installForm = document.getElementById('adminSurfaceInstallForm');
var fileInput = document.getElementById('adminSurfaceFile');
var uploadBtn = document.getElementById('adminSurfaceUploadBtn');
var cancelBtn = document.getElementById('adminSurfaceCancelBtn');
var statusEl = document.getElementById('adminSurfaceInstallStatus');
if (installBtn) installBtn.addEventListener('click', function() {
installForm.style.display = '';
installBtn.style.display = 'none';
});
if (cancelBtn) cancelBtn.addEventListener('click', function() {
installForm.style.display = 'none';
installBtn.style.display = '';
fileInput.value = '';
uploadBtn.disabled = true;
statusEl.textContent = '';
});
if (fileInput) fileInput.addEventListener('change', function() {
uploadBtn.disabled = !fileInput.files.length;
});
if (uploadBtn) uploadBtn.addEventListener('click', async function() {
var file = fileInput.files[0];
if (!file) return;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading…';
statusEl.textContent = '';
statusEl.style.color = 'var(--text-3)';
try {
var formData = new FormData();
formData.append('file', file);
var base = window.__BASE__ || '';
var resp = await fetch(base + '/api/v1/admin/surfaces/install', {
method: 'POST',
headers: API._authHeaders ? (function() { var h = API._authHeaders(); delete h['Content-Type']; return h; })() : {},
body: formData,
});
var data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Upload failed');
statusEl.style.color = 'var(--success)';
statusEl.textContent = 'Installed: ' + (data.title || data.id);
if (typeof UI !== 'undefined') UI.toast('Surface installed: ' + (data.title || data.id), 'success');
// Refresh the list
installForm.style.display = 'none';
installBtn.style.display = '';
fileInput.value = '';
_loadSurfaceList();
} catch (e) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Error: ' + e.message;
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload';
}
});
_loadSurfaceList();
}
async function _loadSurfaceList() {
var listEl = document.getElementById('adminSurfaceList');
if (!listEl) return;
try {
var resp = await API._get('/api/v1/admin/surfaces');
var surfaces = resp.surfaces || [];
if (surfaces.length === 0) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No surfaces registered</div>';
return;
}
listEl.innerHTML = '';
surfaces.forEach(function(s) {
var row = document.createElement('div');
row.className = 'admin-surface-row';
row.dataset.surfaceId = s.id;
var isProtected = s.id === 'chat' || s.id === 'admin';
var sourceLabel = s.source === 'core' ? 'Core' : 'Extension';
var route = s.manifest && s.manifest.route ? s.manifest.route : '';
row.innerHTML =
'<div class="admin-surface-info">' +
'<div class="admin-surface-title">' +
'<span class="admin-surface-name">' + _surfEsc(s.title) + '</span>' +
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
'</div>' +
'<div class="admin-surface-meta">' +
'<span class="admin-surface-id">' + _surfEsc(s.id) + '</span>' +
(route ? '<span class="admin-surface-route">' + _surfEsc(route) + '</span>' : '') +
'</div>' +
'</div>' +
'<div class="admin-surface-actions">' +
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
' data-surface-id="' + _surfEsc(s.id) + '">' +
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
'</label>' +
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + _surfEsc(s.id) + '">Uninstall</button>' : '') +
'</div>';
// Toggle handler
var checkbox = row.querySelector('input[type="checkbox"]');
if (checkbox && !isProtected) {
checkbox.addEventListener('change', (function(surface) {
return async function() {
var id = this.dataset.surfaceId;
var action = this.checked ? 'enable' : 'disable';
var label = row.querySelector('.admin-surface-toggle-label');
var cb = this;
try {
await API._put('/api/v1/admin/surfaces/' + id + '/' + action);
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
if (typeof UI !== 'undefined') {
UI.toast(surface.title + ' ' + (cb.checked ? 'enabled' : 'disabled') + ' — takes effect on next page load', 'success');
}
} catch (e) {
cb.checked = !cb.checked;
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
};
})(s));
}
// Uninstall handler
var uninstallBtn = row.querySelector('.admin-surface-uninstall');
if (uninstallBtn) {
uninstallBtn.addEventListener('click', (function(surface) {
return async function() {
var ok = typeof showConfirm === 'function'
? await showConfirm('Uninstall ' + surface.title + '? This removes all files.')
: window.confirm('Uninstall ' + surface.title + '?');
if (!ok) return;
try {
await API._del('/api/v1/admin/surfaces/' + surface.id);
row.remove();
if (typeof UI !== 'undefined') UI.toast(surface.title + ' uninstalled', 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
};
})(s));
}
listEl.appendChild(row);
});
} catch (e) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + _surfEsc(e.message) + '</div>';
}
}
function _surfEsc(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}

View File

@@ -68,8 +68,52 @@ async function init() {
async function startApp() {
hideSplash();
UI.restoreSidebar();
await loadSettings();
// v0.25.0: Surface-aware initialization.
// Common init runs on all surfaces. Chat-specific init only on chat.
const surface = window.__SURFACE__ || 'chat';
// ── Common init (all surfaces) ──────────
if (typeof loadSettings === 'function') await loadSettings();
// Guard: if token was invalidated during startup
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
UI.updateUser();
UI.showAdminButton(API.isAdmin);
UI.restoreAppearance();
// Load models for all surfaces (editor needs them for model selector)
await fetchModels();
// Connect EventBus WebSocket (non-blocking)
try {
Events.connect((window.__BASE__ || '') + '/ws');
Events.on('ws.connected', () => {
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
});
} catch (e) {
console.warn('EventBus connect error:', e.message);
}
// Signal that common init is complete — surface-specific JS can react
document.dispatchEvent(new CustomEvent('sb:ready', { detail: { surface } }));
// ── Chat surface init ───────────────────
if (surface === 'chat') {
await _startChatSurface();
}
// Editor, notes, admin, settings — their surface-specific JS handles
// the rest via DOMContentLoaded listeners in their own boot scripts.
}
// Chat-surface-specific initialization (extracted from monolithic startApp)
async function _startChatSurface() {
// v0.23.1: Load folders and channels before rendering sidebar
await loadFolders();
await loadChannels();
@@ -86,8 +130,7 @@ async function startApp() {
await loadChats();
await loadProjects();
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
await fetchModels();
// app-state.js fetchModels populates App.models; UI updates are surface-specific
// Models already loaded in common init; update chat-surface-specific UI
if (typeof UI !== 'undefined') {
UI.updateModelSelector();
UI.updateCapabilityBadges();
@@ -96,14 +139,6 @@ async function startApp() {
// v0.23.2: Load user list for @mention autocomplete
await loadUsers();
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
// kick back to login instead of rendering a broken UI.
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
await initBanners();
initFiles();
@@ -150,29 +185,14 @@ async function startApp() {
UI.showEmptyState();
}
UI.updateUser();
UI.showAdminButton(API.isAdmin);
initListeners();
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
try {
Events.connect((window.__BASE__ || '') + '/ws');
// Once WS connects, re-fetch tools so browser extension tools appear
// (ToolsToggle.init runs before WS is up → server omits browser tools)
Events.on('ws.connected', () => {
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
});
// Admin-only: persistent fallback alert banner (v0.17.0)
Events.on('role.fallback', (payload) => {
if (App.user?.role !== 'admin') return;
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
showFallbackBanner(data);
});
} catch (e) {
console.warn('EventBus WebSocket not available:', e.message);
}
// Admin-only: persistent fallback alert banner (v0.17.0)
Events.on('role.fallback', (payload) => {
if (App.user?.role !== 'admin') return;
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
showFallbackBanner(data);
});
console.log('✅ Chat Switchboard ready');
}

341
src/js/code-editor.js Normal file
View File

@@ -0,0 +1,341 @@
// ==========================================
// Chat Switchboard — CodeEditor Component
// ==========================================
// v0.25.0: Extracted from editor-mode.js (lines 536-940).
// Tabbed code editor with CM6 integration, auto-save, language detection,
// modified indicators, and status bar.
//
// Usage:
// const editor = CodeEditor.create({
// id: 'editor',
// workspaceId: 'ws-123',
// onSave: (path, content) => fileTree.refresh(),
// onActivate: (path) => fileTree.setActiveFile(path),
// });
// await editor.openFile('src/main.go');
// editor.getActiveFile(); // → 'src/main.go'
// editor.isModified('src/main.go'); // → false
// await editor.saveAll();
// editor.destroy();
const CodeEditor = {
_instances: new Map(),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
id: pfx,
tabsEl: document.getElementById(pfx + 'EditorTabs'),
contentEl: document.getElementById(pfx + 'EditorContent'),
welcomeEl: document.getElementById(pfx + 'EditorWelcome'),
statusEl: document.getElementById(pfx + 'EditorStatus'),
statusFileEl: document.getElementById(pfx + 'EditorStatusFile'),
statusLangEl: document.getElementById(pfx + 'EditorStatusLang'),
statusBranchEl: document.getElementById(pfx + 'EditorStatusBranch'),
workspaceId: opts.workspaceId || null,
onSave: opts.onSave || null,
onActivate: opts.onActivate || null,
_openFiles: new Map(), // path → { tab, editorWrap, editor, content, modified, language }
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
setWorkspace(wsId) { this.workspaceId = wsId; },
async openFile(path, content, language) {
if (this._openFiles.has(path)) {
this.activateFile(path);
return;
}
// Fetch if content not provided
if (content === undefined) {
try {
content = await API.readWorkspaceFile(this.workspaceId, path);
} catch (e) {
console.error('[CodeEditor] Failed to read:', e);
if (typeof UI !== 'undefined') UI.toast('Failed to open ' + path, 'error');
return;
}
}
const lang = language || _ceDetectLanguage(path);
// Create tab
const tab = document.createElement('div');
tab.className = 'code-editor-tab';
tab.dataset.path = path;
const fileName = path.split('/').pop();
tab.innerHTML =
'<span class="code-editor-tab-icon">' + _ceFileIcon(fileName) + '</span>' +
'<span class="code-editor-tab-name">' + _ceEsc(fileName) + '</span>' +
'<span class="code-editor-tab-modified" style="display:none">●</span>' +
'<button class="code-editor-tab-close" title="Close">✕</button>';
const self = this;
tab.addEventListener('click', (e) => {
if (!e.target.closest('.code-editor-tab-close')) self.activateFile(path);
});
tab.querySelector('.code-editor-tab-close').addEventListener('click', (e) => {
e.stopPropagation();
self.closeFile(path);
});
if (this.tabsEl) this.tabsEl.appendChild(tab);
// Create editor wrapper
const editorWrap = document.createElement('div');
editorWrap.className = 'code-editor-cm-wrap';
editorWrap.style.display = 'none';
if (this.contentEl) this.contentEl.appendChild(editorWrap);
// Create CM6 editor or textarea fallback
let editor = null;
if (window.CM?.codeEditor) {
editor = CM.codeEditor(editorWrap, {
language: lang,
value: content,
lineNumbers: true,
});
if (editor.onUpdate) {
editor.onUpdate(() => self._markModified(path));
}
} else {
const ta = document.createElement('textarea');
ta.className = 'code-editor-textarea-fallback';
ta.value = content;
ta.addEventListener('input', () => self._markModified(path));
editorWrap.appendChild(ta);
editor = {
getValue: () => ta.value,
setValue: (v) => { ta.value = v; },
focus: () => ta.focus(),
destroy: () => {},
_textarea: true,
};
}
this._openFiles.set(path, {
tab, editorWrap, editor, content, modified: false, language: lang,
});
this.activateFile(path);
},
activateFile(path) {
if (this._activeFile === path) return;
// Auto-save previous if modified
if (this._activeFile && this._openFiles.has(this._activeFile)) {
const prev = this._openFiles.get(this._activeFile);
if (prev.modified) this.saveFile(this._activeFile);
prev.tab.classList.remove('active');
prev.editorWrap.style.display = 'none';
}
this._activeFile = path;
const f = this._openFiles.get(path);
if (!f) return;
f.tab.classList.add('active');
f.editorWrap.style.display = '';
if (this.welcomeEl) this.welcomeEl.style.display = 'none';
this._updateStatusBar();
if (this.onActivate) this.onActivate(path);
if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
},
async closeFile(path) {
const f = this._openFiles.get(path);
if (!f) return;
if (f.modified) {
const save = await this._confirmClose(path);
if (save === null) return; // cancelled
if (save) await this.saveFile(path);
}
f.tab.remove();
f.editorWrap.remove();
if (f.editor?.destroy) f.editor.destroy();
this._openFiles.delete(path);
if (this._activeFile === path) {
this._activeFile = null;
const remaining = Array.from(this._openFiles.keys());
if (remaining.length) {
this.activateFile(remaining[remaining.length - 1]);
} else {
if (this.welcomeEl) this.welcomeEl.style.display = '';
this._updateStatusBar();
}
}
},
async saveFile(path) {
const f = this._openFiles.get(path);
if (!f) return;
const content = f.editor?.getValue?.() ?? '';
try {
await API.writeWorkspaceFile(this.workspaceId, path, content);
f.content = content;
f.modified = false;
f.tab.querySelector('.code-editor-tab-modified').style.display = 'none';
f.tab.classList.remove('modified');
if (path === this._activeFile) this._updateStatusBar();
if (typeof UI !== 'undefined') UI.toast('Saved ' + path.split('/').pop(), 'success');
if (this.onSave) this.onSave(path, content);
} catch (e) {
console.error('[CodeEditor] Save failed:', e);
if (typeof UI !== 'undefined') UI.toast('Failed to save: ' + e.message, 'error');
}
},
async saveAll() {
for (const [path, f] of this._openFiles) {
if (f.modified) await this.saveFile(path);
}
},
getActiveFile() { return this._activeFile; },
isModified(path) {
const f = this._openFiles.get(path);
return f ? f.modified : false;
},
hasUnsaved() {
for (const [, f] of this._openFiles) {
if (f.modified) return true;
}
return false;
},
getOpenFiles() { return Array.from(this._openFiles.keys()); },
// ── Git Branch Display ──────────────
setBranch(branch) {
if (this.statusBranchEl) {
this.statusBranchEl.textContent = branch ? '⎇ ' + branch : '';
}
},
// ── Keyboard Shortcuts ──────────────
bind() {
this._on(document, 'keydown', (e) => {
// Ctrl/Cmd+S — save active file
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (this._activeFile) this.saveFile(this._activeFile);
}
});
},
// ── Lifecycle ───────────────────────
destroy() {
// Destroy all CM6 instances
for (const [, f] of this._openFiles) {
if (f.editor?.destroy) f.editor.destroy();
f.tab.remove();
f.editorWrap.remove();
}
this._openFiles.clear();
this._activeFile = null;
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
CodeEditor._instances.delete(this.id);
},
// ── Internal ────────────────────────
_markModified(path) {
const f = this._openFiles.get(path);
if (!f || f.modified) return;
const currentVal = f.editor?.getValue?.() ?? '';
if (currentVal === f.content) return;
f.modified = true;
f.tab.querySelector('.code-editor-tab-modified').style.display = '';
f.tab.classList.add('modified');
if (path === this._activeFile) this._updateStatusBar();
},
_updateStatusBar() {
if (!this.statusFileEl) return;
if (!this._activeFile) {
this.statusFileEl.textContent = 'No file open';
if (this.statusLangEl) this.statusLangEl.textContent = '';
return;
}
const f = this._openFiles.get(this._activeFile);
this.statusFileEl.textContent = this._activeFile + (f?.modified ? ' ●' : '');
if (this.statusLangEl && f) {
this.statusLangEl.textContent = f.language || '';
}
},
async _confirmClose(path) {
const fileName = path.split('/').pop();
if (typeof showConfirm === 'function') {
return showConfirm('Save changes to ' + fileName + '?', {
confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
});
}
return Promise.resolve(true);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
CodeEditor._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ceEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ceFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();
const icons = {
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
md: '📝', txt: '📄', svg: '🖼',
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
dockerfile: '🐳', makefile: '🔧',
mod: '📦', sum: '📦', lock: '🔒',
};
return icons[ext] || '📄';
}
function _ceDetectLanguage(path) {
const ext = path.split('.').pop()?.toLowerCase();
const map = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
md: 'markdown', mdx: 'markdown',
sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
xml: 'xml', svg: 'xml',
c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
java: 'java', kt: 'java',
php: 'php', pl: 'perl',
dockerfile: 'dockerfile',
};
return map[ext] || 'text';
}

File diff suppressed because it is too large Load Diff

662
src/js/editor-surface.js Normal file
View File

@@ -0,0 +1,662 @@
// ==========================================
// Chat Switchboard — Editor Surface (v0.25.0)
// ==========================================
// Uses PaneContainer to mount a three-pane layout and moves
// server-rendered component partials into pane slots.
// Components are rendered by Go templates into #editorComponents
// (hidden) — this JS moves them into the right pane slots.
// ==========================================
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
if (window.__SURFACE__ !== 'editor') return;
const pageData = window.__PAGE_DATA__ || {};
const wsId = pageData.WorkspaceID;
const wsName = pageData.WorkspaceName || 'Workspace';
// Wire user menu
_initUserMenu();
// Wire workspace selector
_initWsSelector(wsId);
// No workspace — show bootstrap
if (!wsId) {
_showBootstrap();
return;
}
_mountEditor(wsId, wsName);
});
// ── User Menu ───────────────────────────
function _initUserMenu() {
if (typeof UserMenu === 'undefined') return;
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
menu.setUser(API.user || window.__USER__);
menu.bind({
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: () => { if (typeof openDebugModal === 'function') openDebugModal(); },
onSignout: () => { if (typeof handleLogout === 'function') handleLogout(); },
});
menu.showAdmin(API.isAdmin);
}
// ── Workspace Selector ──────────────────
function _initWsSelector(currentWsId) {
const btn = document.getElementById('editorWsSelectorBtn');
const dropdown = document.getElementById('editorWsDropdown');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
if (isOpen) _loadWsDropdown(currentWsId);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
});
// New workspace button
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
dropdown.classList.remove('open');
const name = window.prompt('Workspace name:');
if (!name) return;
try {
const userId = API.user?.id || window.__USER__?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (newId) {
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
}
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
async function _loadWsDropdown(currentWsId) {
const listEl = document.getElementById('editorWsList');
if (!listEl) return;
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading…</div>';
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
listEl.innerHTML = '';
if (workspaces.length === 0) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
return;
}
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
item.textContent = ws.name || ws.id?.slice(0, 8);
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
}
}
// ── Bootstrap (no workspace) ────────────
function _showBootstrap() {
const body = document.getElementById('editorBody');
const bootstrap = document.getElementById('editorBootstrap');
if (body) body.style.display = 'none';
if (bootstrap) bootstrap.style.display = '';
_loadBootstrapList();
const btn = document.getElementById('editorBootstrapBtn');
const input = document.getElementById('editorBootstrapName');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const name = input.value.trim() || 'workspace';
btn.disabled = true;
btn.textContent = 'Creating…';
try {
const userId = API.user?.id || window.__USER__?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (!newId) throw new Error('No workspace ID returned');
// Navigate to the new workspace
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
} catch (e) {
btn.disabled = false;
btn.textContent = 'Create Workspace';
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
async function _loadBootstrapList() {
const listEl = document.getElementById('editorBootstrapList');
if (!listEl) return;
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
if (workspaces.length === 0) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
return;
}
listEl.innerHTML = '';
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item';
item.innerHTML =
'<span class="editor-bootstrap-ws-name">' + _edEsc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + _edEsc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
}
}
// ── Mount Pane Layout ───────────────────
function _mountEditor(wsId, wsName) {
const body = document.getElementById('editorBody');
if (!body || typeof PaneContainer === 'undefined') {
console.error('[EditorSurface] Missing body or PaneContainer');
return;
}
// Mount the 'editor' preset: files | editor | <chat, notes>
const layout = PaneContainer.mount(body, 'editor', { workspaceId: wsId });
if (!layout) return;
// ── Move server-rendered components into pane slots ──
// Components were rendered by Go templates into #editorComponents (hidden).
// We move them into the pane slots created by PaneContainer.
const components = document.getElementById('editorComponents');
// File Tree → files pane
const filesPaneInfo = layout._panes.get('files');
const fileTreeEl = document.getElementById('edFileTree');
if (filesPaneInfo?.el && fileTreeEl) {
filesPaneInfo.el.appendChild(fileTreeEl);
}
const fileTree = FileTree.create({ id: 'ed', workspaceId: wsId,
onSelect: (path) => codeEditor.openFile(path),
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
onNewFile: () => _createNewFile(wsId, fileTree),
});
filesPaneInfo.component = fileTree;
fileTree.bind();
// Code Editor → editor pane
const editorPaneInfo = layout._panes.get('editor');
const codeEditorEl = document.getElementById('edCodeEditor');
if (editorPaneInfo?.el && codeEditorEl) {
editorPaneInfo.el.appendChild(codeEditorEl);
}
const codeEditor = CodeEditor.create({ id: 'ed', workspaceId: wsId,
onSave: () => fileTree.refresh(),
onActivate: (path) => fileTree.setActiveFile(path),
});
editorPaneInfo.component = codeEditor;
codeEditor.bind();
// Assist pane (tabbed: chat + notes)
const assistPaneInfo = layout._panes.get('assist');
if (assistPaneInfo?.tabs) {
// Chat tab — move server-rendered ChatPane partial
const chatPanel = assistPaneInfo.getTabPanel('chat');
const chatPaneEl = document.getElementById('edChatPane');
if (chatPanel && chatPaneEl) {
chatPanel.appendChild(chatPaneEl);
// Create ChatPane instance from the server-rendered mount points
if (typeof ChatPane !== 'undefined') {
const chatPane = ChatPane.create({
id: 'ed',
messagesEl: document.getElementById('edChatMessages'),
inputEl: document.getElementById('edChatInput'),
sendBtnEl: document.getElementById('edSendBtn'),
modelSelEl: document.getElementById('edModelSel'),
toolbarEl: document.getElementById('edToolbar'),
standalone: true,
});
chatPane.showWelcome();
_initAssistChat(chatPane, codeEditor);
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
if (chatTab) chatTab.instance = chatPane;
}
}
// Notes tab — move server-rendered NoteEditor partial
const notesPanel = assistPaneInfo.getTabPanel('notes');
const noteEditorEl = document.getElementById('edNotesNoteEditor');
if (notesPanel && noteEditorEl) {
notesPanel.appendChild(noteEditorEl);
if (typeof NoteEditor !== 'undefined') {
const noteEditor = NoteEditor.create({ id: 'edNotes', onOpenGraph: null });
noteEditor.bind();
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
if (notesTab) {
notesTab.instance = noteEditor;
// Lazy-load on first tab click
notesTab.btn.addEventListener('click', () => {
if (!notesTab._loaded) {
notesTab._loaded = true;
noteEditor.loadNotes();
noteEditor.loadFolders();
}
});
}
}
}
}
// Remove the hidden container
if (components) components.remove();
// Git branch
_refreshGitBranch(wsId, codeEditor);
// Toolbar
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
fileTree.refresh();
_refreshGitBranch(wsId, codeEditor);
});
// Initial load
fileTree.refresh();
console.log('[EditorSurface] Mounted for workspace', wsId);
}
// ── File Operations ─────────────────────
async function _deleteFile(wsId, path, fileTree, codeEditor) {
const ok = typeof showConfirm === 'function'
? await showConfirm('Delete ' + path + '?')
: window.confirm('Delete ' + path + '?');
if (!ok) return;
try {
await API.deleteWorkspaceFile(wsId, path);
if (codeEditor.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
fileTree.refresh();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
}
}
async function _createNewFile(wsId, fileTree) {
const name = window.prompt('File name (e.g. src/main.go):');
if (!name) return;
try {
await API.writeWorkspaceFile(wsId, name.trim(), '');
fileTree.refresh();
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
}
// ── Git Branch ──────────────────────────
async function _refreshGitBranch(wsId, codeEditor) {
try {
const resp = await API.getWorkspaceGitBranches(wsId);
const branch = resp.current || null;
if (branch) {
const badge = document.getElementById('editorBranchBadge');
const name = document.getElementById('editorBranchName');
if (badge) badge.style.display = '';
if (name) name.textContent = branch;
}
if (codeEditor) codeEditor.setBranch(branch);
} catch (_) {
// Git not configured — hide branch badge
}
}
// ── Helpers ──────────────────────────────
function _edEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ── Assist Chat (channel-based, standalone) ──
// Full chat pane for the editor: model selector, chat history,
// new chat, SSE streaming. Uses real channels/messages API.
// No dependency on chat.js — fully standalone.
function _initAssistChat(chatPane, codeEditor) {
const inputEl = chatPane.inputEl;
const sendBtn = chatPane.sendBtnEl;
const headerEl = document.getElementById('edChatHeader');
const selectEl = document.getElementById('edChatSelect');
const newBtnEl = document.getElementById('edChatNewBtn');
const modelSelEl = document.getElementById('edModelSel');
if (!inputEl) return;
// Show the header bar
if (headerEl) headerEl.style.display = '';
// State
let _channelId = null;
let _messages = [];
let _sending = false;
let _abortController = null;
let _selectedModel = App.settings?.model || '';
// ── Model Selector ──────────────────────
async function _initModelSelector() {
if (!modelSelEl) return;
// Ensure models are loaded
if (!App.models?.length && typeof fetchModels === 'function') {
await fetchModels();
}
const models = App.models || [];
if (!models.length) return;
const sel = document.createElement('select');
sel.className = 'chat-pane-model-select';
sel.title = 'Select model';
models.forEach(m => {
if (m.hidden) return;
const opt = document.createElement('option');
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
const prefix = m.isPersona ? '🎭 ' : '';
opt.textContent = prefix + (m.name || m.id);
if (m.id === _selectedModel || m.personaId === _selectedModel) {
opt.selected = true;
}
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
_selectedModel = sel.value;
});
modelSelEl.innerHTML = '';
modelSelEl.appendChild(sel);
}
// ── Chat History Selector ───────────────
async function _loadChatHistory() {
if (!selectEl) return;
try {
const resp = await API.listChannels(1, 20, 'direct');
const channels = resp.data || resp || [];
selectEl.innerHTML = '<option value="">New conversation</option>';
channels.forEach(ch => {
const opt = document.createElement('option');
opt.value = ch.id;
opt.textContent = ch.title || 'Untitled';
if (ch.id === _channelId) opt.selected = true;
selectEl.appendChild(opt);
});
} catch (e) {
console.warn('[AssistChat] Failed to load history:', e.message);
}
}
if (selectEl) {
selectEl.addEventListener('change', () => {
const id = selectEl.value;
if (id) {
_switchToChannel(id);
} else {
_newChat();
}
});
}
if (newBtnEl) {
newBtnEl.addEventListener('click', _newChat);
}
// ── Switch Channel ──────────────────────
async function _switchToChannel(channelId) {
_channelId = channelId;
_messages = [];
chatPane.clear();
chatPane.appendTyping();
try {
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
const msgs = resp.data || resp || [];
_messages = msgs;
chatPane.removeTyping();
_renderAllMessages();
} catch (e) {
chatPane.removeTyping();
_appendSystemMsg('Failed to load messages: ' + e.message);
}
}
function _newChat() {
_channelId = null;
_messages = [];
chatPane.showWelcome();
if (selectEl) selectEl.value = '';
}
// ── Create Textarea ─────────────────────
const ta = document.createElement('textarea');
ta.placeholder = 'Ask about your code…';
ta.rows = 1;
inputEl.appendChild(ta);
ta.addEventListener('input', () => {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
});
// ── Send ────────────────────────────────
async function sendMessage() {
const text = ta.value.trim();
if (!text || _sending) return;
_sending = true;
if (sendBtn) sendBtn.disabled = true;
ta.value = '';
ta.style.height = 'auto';
// Create channel if new conversation
if (!_channelId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '…' : '');
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
_channelId = resp.id;
// Update dropdown
_loadChatHistory();
} catch (e) {
_appendSystemMsg('Failed to create chat: ' + e.message);
_sending = false;
if (sendBtn) sendBtn.disabled = false;
return;
}
}
// Render user message
_messages.push({ role: 'user', content: text });
_renderAllMessages();
// Build context from active file
const fileCtx = _getFileContext(codeEditor);
// Stream the completion
try {
_abortController = new AbortController();
// If we have file context, prepend it to the message
const content = fileCtx
? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```'
: text;
const resp = await API.streamCompletion(
_channelId, content, _selectedModel,
_abortController.signal
);
await _handleStream(resp, chatPane);
} catch (e) {
if (e.name !== 'AbortError') {
_appendSystemMsg('Error: ' + e.message);
}
}
_abortController = null;
_sending = false;
if (sendBtn) sendBtn.disabled = false;
ta.focus();
}
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// ── SSE Stream Handler ──────────────────
async function _handleStream(response, pane) {
const messagesEl = pane.messagesEl;
if (!messagesEl) return;
// Create assistant bubble
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
const contentEl = document.createElement('div');
contentEl.className = 'chat-msg__content';
div.appendChild(contentEl);
messagesEl.appendChild(div);
let content = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
content += delta;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content));
} else {
contentEl.textContent = content;
}
pane.scrollToBottom();
}
} catch (_) { /* skip unparseable lines */ }
}
}
} catch (e) {
if (e.name !== 'AbortError') {
content += '\n\n**[Stream error: ' + e.message + ']**';
}
}
div.classList.remove('chat-msg--streaming');
_messages.push({ role: 'assistant', content });
}
// ── Rendering ───────────────────────────
function _renderAllMessages() {
if (!chatPane.messagesEl) return;
chatPane.messagesEl.innerHTML = '';
_messages.forEach(m => {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
const content = document.createElement('div');
content.className = 'chat-msg__content';
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
content.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
} else {
content.textContent = m.content || '';
}
div.appendChild(content);
chatPane.messagesEl.appendChild(div);
});
chatPane.scrollToBottom();
}
function _appendSystemMsg(text) {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--system';
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + _edEsc(text) + '</div>';
chatPane.messagesEl?.appendChild(div);
chatPane.scrollToBottom();
}
function _getFileContext(editor) {
if (!editor) return null;
try {
const openFiles = editor.getOpenFiles();
if (!openFiles.length) return null;
// Find active tab
const activeTab = document.querySelector('.code-editor-tab.active');
const path = activeTab?.dataset?.path || openFiles[0];
// Get content from the editor instances
const inst = CodeEditor._instances?.get('ed');
if (inst?._files) {
const file = inst._files.get(path);
if (file?.view) {
const content = file.view.state.doc.toString();
return { path, content: content.slice(0, 4000) };
}
}
} catch (_) {}
return null;
}
// ── Init ────────────────────────────────
// Defer model selector and chat history loading until app common init
// completes (fetchModels, auth, etc.). The sb:ready event fires from
// startApp() after App.models is populated.
function _deferredInit() {
_initModelSelector();
_loadChatHistory();
}
if (App.models?.length) {
// Already loaded (unlikely but possible)
_deferredInit();
} else {
document.addEventListener('sb:ready', _deferredInit, { once: true });
}
}
})();

278
src/js/file-tree.js Normal file
View File

@@ -0,0 +1,278 @@
// ==========================================
// Chat Switchboard — FileTree Component
// ==========================================
// v0.25.0: Extracted from editor-mode.js (lines 336-535).
// Workspace file browser with directory expand/collapse, git status
// badges, context menu, and file icon mapping.
//
// Usage:
// const tree = FileTree.create({
// id: 'editor',
// workspaceId: 'ws-123',
// onSelect: (path) => codeEditor.openFile(path),
// onDelete: (path) => { ... },
// onNewFile: () => { ... },
// });
// await tree.refresh();
// tree.setActiveFile('src/main.go');
// tree.destroy();
const FileTree = {
_instances: new Map(),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
id: pfx,
itemsEl: document.getElementById(pfx + 'TreeItems'),
newFileEl: document.getElementById(pfx + 'TreeNewFile'),
workspaceId: opts.workspaceId || null,
onSelect: opts.onSelect || null,
onDelete: opts.onDelete || null,
onNewFile: opts.onNewFile || null,
_treeData: [],
_expandedDirs: new Set(['']),
_gitStatusMap: new Map(),
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
setWorkspace(wsId) { this.workspaceId = wsId; },
async refresh() {
if (!this.workspaceId) return;
try {
const resp = await API.listWorkspaceFiles(this.workspaceId, '', true);
this._treeData = resp.files || resp.data || resp || [];
this._render();
} catch (e) {
console.error('[FileTree] Failed to load:', e);
if (this.itemsEl) this.itemsEl.innerHTML = '<div class="file-tree-error">Failed to load files</div>';
}
// Non-blocking git status refresh
this._refreshGitStatus();
},
setActiveFile(path) {
this._activeFile = path;
if (!this.itemsEl) return;
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
row.classList.toggle('active', row.dataset.path === path);
});
},
getActiveFile() { return this._activeFile; },
// ── Git Status ──────────────────────
async _refreshGitStatus() {
if (!this.workspaceId) return;
this._gitStatusMap.clear();
try {
const resp = await API.getWorkspaceGitStatus(this.workspaceId);
const files = resp.files || [];
for (const f of files) {
this._gitStatusMap.set(f.path, f.status || '?');
}
// Apply to rendered rows
if (this.itemsEl) {
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
const p = row.dataset.path;
const st = this._gitStatusMap.get(p);
row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted');
if (st === 'M') row.classList.add('git-modified');
else if (st === 'A') row.classList.add('git-added');
else if (st === '?') row.classList.add('git-untracked');
else if (st === 'D') row.classList.add('git-deleted');
});
}
} catch (_) {
// Git not configured — ignore
}
},
// ── Tree Rendering ──────────────────
_render() {
if (!this.itemsEl) return;
this.itemsEl.innerHTML = '';
if (!this._treeData.length) {
this.itemsEl.innerHTML = '<div class="file-tree-empty">No files</div>';
return;
}
const tree = this._buildStructure(this._treeData);
this._renderNodes(tree, this.itemsEl, 0);
},
_buildStructure(files) {
const root = { name: '', path: '', isDir: true, children: [], file: null };
const dirs = new Map();
dirs.set('', root);
const sorted = [...files].sort((a, b) => {
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const f of sorted) {
const parts = f.path.split('/');
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
let parent = dirs.get(parentPath);
if (!parent) {
let accumulated = '';
for (let i = 0; i < parts.length - 1; i++) {
const prev = accumulated;
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
if (!dirs.has(accumulated)) {
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
dirs.set(accumulated, dirNode);
const p = dirs.get(prev);
if (p) p.children.push(dirNode);
}
}
parent = dirs.get(parentPath);
}
if (f.is_directory) {
let existing = dirs.get(f.path);
if (!existing) {
existing = { name, path: f.path, isDir: true, children: [], file: f };
dirs.set(f.path, existing);
if (parent) parent.children.push(existing);
} else {
existing.file = f;
}
} else {
const node = { name, path: f.path, isDir: false, children: [], file: f };
if (parent) parent.children.push(node);
}
}
return root.children;
},
_renderNodes(nodes, container, depth) {
const self = this;
for (const node of nodes) {
const row = document.createElement('div');
row.className = 'file-tree-row' + (node.path === this._activeFile ? ' active' : '');
row.style.paddingLeft = (12 + depth * 16) + 'px';
row.dataset.path = node.path;
if (node.isDir) {
const expanded = this._expandedDirs.has(node.path);
row.innerHTML =
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
'<span class="file-tree-icon">📁</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self._expandedDirs.has(node.path)) {
self._expandedDirs.delete(node.path);
} else {
self._expandedDirs.add(node.path);
}
self._render();
});
container.appendChild(row);
if (expanded && node.children.length) {
this._renderNodes(node.children, container, depth + 1);
}
} else {
row.innerHTML =
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self.onSelect) self.onSelect(node.path);
});
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
self._showContextMenu(e, node);
});
container.appendChild(row);
}
}
},
// ── Context Menu ────────────────────
_showContextMenu(e, node) {
const existing = document.querySelector('.file-tree-ctx-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.className = 'file-tree-ctx-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
const items = [
{ label: 'Open', action: () => { if (this.onSelect) this.onSelect(node.path); } },
{ label: 'Delete', action: () => { if (this.onDelete) this.onDelete(node.path); } },
];
for (const item of items) {
const row = document.createElement('div');
row.className = 'file-tree-ctx-item';
row.textContent = item.label;
row.addEventListener('click', () => { menu.remove(); item.action(); });
menu.appendChild(row);
}
document.body.appendChild(menu);
const dismiss = (ev) => {
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
};
setTimeout(() => document.addEventListener('click', dismiss), 0);
},
// ── Event Wiring ────────────────────
bind() {
this._on(this.newFileEl, 'click', () => {
if (this.onNewFile) this.onNewFile();
});
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
this._treeData = [];
this._expandedDirs = new Set(['']);
this._gitStatusMap.clear();
FileTree._instances.delete(this.id);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
FileTree._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ftEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ftFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();
const icons = {
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
md: '📝', txt: '📄', svg: '🖼',
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
dockerfile: '🐳', makefile: '🔧',
mod: '📦', sum: '📦', lock: '🔒',
};
return icons[ext] || '📄';
}

191
src/js/model-selector.js Normal file
View File

@@ -0,0 +1,191 @@
// ==========================================
// Chat Switchboard — ModelSelector Component
// ==========================================
// v0.25.0: Extracted from ui-core.js.
// Grouped dropdown: personas (global, team, personal), models, BYOK.
// Pattern: Go template partial (model-selector.html) + JS factory (this file).
//
// Usage:
// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } });
// sel.setModels(App.models);
// sel.select(modelId);
// sel.getSelected(); // → current model ID
// sel.destroy();
const ModelSelector = {
primary: null,
_instances: new Map(),
create(opts) {
const pfx = opts.id || '';
const instance = {
id: pfx,
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
labelEl: document.getElementById(pfx + 'modelDropdownLabel'),
menuEl: document.getElementById(pfx + 'modelDropdownMenu'),
capsEl: document.getElementById(pfx + 'modelCaps'),
onChange: opts.onChange || null,
_value: '',
_models: [],
_listeners: [],
// ── Selection ───────────────────────
getSelected() { return this._value; },
select(id, label) {
this._value = id;
if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model';
// Highlight
if (this.menuEl) {
this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === id);
});
}
this._updateCaps();
if (this.onChange) this.onChange(id, label);
},
// ── Model list ──────────────────────
setModels(models) {
this._models = models || [];
this._rebuild();
},
_rebuild() {
if (!this.menuEl) return;
this.menuEl.innerHTML = '';
if (this._models.length === 0) {
this.menuEl.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
this.select('', 'No models loaded');
return;
}
const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global');
const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team');
const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal');
const models = this._models.filter(m => !m.isPersona && !m.hidden);
const addGroup = (label, items) => {
if (items.length === 0) return;
const hdr = document.createElement('div');
hdr.className = 'model-dropdown-group';
hdr.textContent = label;
this.menuEl.appendChild(hdr);
items.forEach(m => this.menuEl.appendChild(this._createItem(m)));
};
addGroup('\u26A1 Personas', globalPersonas);
// Group team personas by team name
const teamGroups = {};
teamPersonas.forEach(m => {
const tn = m.personaTeamName || 'Team';
(teamGroups[tn] = teamGroups[tn] || []).push(m);
});
Object.keys(teamGroups).sort().forEach(tn => {
addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]);
});
addGroup('\uD83D\uDD27 My Personas', personalPersonas);
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal'));
},
_createItem(m) {
const self = this;
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.personaAvatar ? '<img src="' + _msEsc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + _msEsc(m.personaHandle) + '</span>' : '';
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + _msEsc(m.teamName) + '</span>' : '';
const provider = m.provider ? '<span class="item-provider">' + _msEsc(m.provider) + '</span>' : '';
div.innerHTML = avatar + '<span class="item-label">' + _msEsc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
div.addEventListener('click', () => {
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
self.close();
});
return div;
},
// ── Restore selection ───────────────
/**
* Restore selection from a preferred ID, falling back through
* admin default → first visible.
*/
restore(preferredId, adminDefault) {
const visible = this._models.filter(m => !m.hidden);
if (visible.length === 0) { this.select('', 'No visible models'); return; }
const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id);
const match = byId(preferredId) || byId(adminDefault) || visible[0];
this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : ''));
},
// ── Dropdown toggle ─────────────────
open() { if (this.menuEl) this.menuEl.classList.add('open'); },
close() { if (this.menuEl) this.menuEl.classList.remove('open'); },
toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); },
// ── Capability badges ───────────────
_updateCaps() {
if (!this.capsEl) return;
if (!this._value) { this.capsEl.innerHTML = ''; return; }
const m = this._models.find(m => m.id === this._value);
const caps = m?.capabilities;
if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; }
// Use global renderCapBadges if available (from ui-core.js)
if (typeof renderCapBadges === 'function') {
this.capsEl.innerHTML = renderCapBadges(caps);
}
},
getSelectedCaps() {
const m = this._models.find(m => m.id === this._value);
return m?.capabilities || {};
},
// ── Event wiring ────────────────────
bind() {
this._on(this.btnEl, 'click', (e) => {
e.stopPropagation();
this.toggle();
});
this._on(document, 'click', (e) => {
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
});
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
ModelSelector._instances.delete(this.id);
if (ModelSelector.primary === this) ModelSelector.primary = null;
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
ModelSelector._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// HTML-escape for model selector content
function _msEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

450
src/js/note-editor.js Normal file
View File

@@ -0,0 +1,450 @@
// ==========================================
// Chat Switchboard — NoteEditor Component
// ==========================================
// v0.25.0: Component wrapper for notes list + editor.
// Hydrates the server-rendered note-editor.html partial.
// Can run standalone (in editor tabbed pane) or coexist with
// the existing notes.js panel system (on chat/notes surfaces).
//
// Usage:
// const notes = NoteEditor.create({
// id: 'main', // matches template prefix
// onOpenGraph: () => { ... },
// });
// notes.bind();
// await notes.loadNotes();
// notes.destroy();
//
// The existing notes.js + PanelRegistry integration continues to
// work on the chat surface. This component is for NEW mount points
// (editor pane, future notes-studio layout).
const NoteEditor = {
_instances: new Map(),
create(opts) {
const pfx = opts.id || 'main';
const instance = {
id: pfx,
rootEl: document.getElementById(pfx + 'NoteEditor'),
listViewEl: document.getElementById(pfx + 'NotesListView'),
editorViewEl: document.getElementById(pfx + 'NotesEditorView'),
graphViewEl: document.getElementById(pfx + 'NotesGraphView'),
listEl: document.getElementById(pfx + 'NotesList'),
searchEl: document.getElementById(pfx + 'NotesSearchInput'),
folderFilterEl: document.getElementById(pfx + 'NotesFolderFilter'),
sortEl: document.getElementById(pfx + 'NotesSortSelect'),
titleEl: document.getElementById(pfx + 'NoteEditorTitle'),
folderEl: document.getElementById(pfx + 'NoteEditorFolder'),
tagsEl: document.getElementById(pfx + 'NoteEditorTags'),
contentContainerEl: document.getElementById(pfx + 'NoteEditorContentContainer'),
readTitleEl: document.getElementById(pfx + 'NoteReadTitle'),
readMetaEl: document.getElementById(pfx + 'NoteReadMeta'),
readContentEl: document.getElementById(pfx + 'NoteReadContent'),
editModeEl: document.getElementById(pfx + 'NoteEditMode'),
readModeEl: document.getElementById(pfx + 'NoteReadMode'),
backlinksEl: document.getElementById(pfx + 'NoteBacklinks'),
backlinksListEl: document.getElementById(pfx + 'NoteBacklinksList'),
backlinksCountEl: document.getElementById(pfx + 'NoteBacklinksCount'),
onOpenGraph: opts.onOpenGraph || null,
_editingNoteId: null,
_currentNote: null,
_sort: 'updated_desc',
_cmEditor: null,
_listeners: [],
// ── View Switching ───────────────────
showList() {
if (this.listViewEl) this.listViewEl.style.display = '';
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
this._destroyCmEditor();
},
showEditor() {
if (this.listViewEl) this.listViewEl.style.display = 'none';
if (this.editorViewEl) this.editorViewEl.style.display = '';
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
},
showGraph() {
if (this.listViewEl) this.listViewEl.style.display = 'none';
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
if (this.graphViewEl) this.graphViewEl.style.display = '';
},
// ── Note List ───────────────────────
async loadNotes(folder, searchQuery) {
if (!this.listEl) return;
this.listEl.innerHTML = '<div class="notes-loading">Loading…</div>';
try {
let notes;
if (searchQuery) {
const resp = await API.searchNotes(searchQuery);
notes = resp.data || [];
} else {
const folderVal = folder || this.folderFilterEl?.value || '';
const resp = await API.listNotes(100, 0, folderVal, '', this._sort);
notes = resp.data || [];
}
if (notes.length === 0) {
this.listEl.innerHTML = '<div class="notes-empty">' +
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
return;
}
this.listEl.innerHTML = '';
const self = this;
notes.forEach(note => {
const item = document.createElement('div');
item.className = 'note-item';
item.dataset.noteId = note.id;
const title = note.title || 'Untitled';
const preview = (note.content || '').slice(0, 80).replace(/\n/g, ' ');
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
item.innerHTML =
'<div class="note-content-col">' +
'<div class="note-title">' + _neEsc(title) + '</div>' +
'<div class="note-preview">' + _neEsc(preview) + '</div>' +
'<div class="note-date">' + _neEsc(date) + '</div>' +
'</div>';
item.addEventListener('click', () => self.openNote(note.id));
self.listEl.appendChild(item);
});
} catch (e) {
this.listEl.innerHTML = '<div class="notes-empty">Failed to load: ' + _neEsc(e.message) + '</div>';
}
},
async loadFolders() {
if (!this.folderFilterEl) return;
try {
const resp = await API.listNoteFolders();
const folders = resp.data || resp || [];
// Keep "All folders" option, rebuild the rest
this.folderFilterEl.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
this.folderFilterEl.appendChild(opt);
});
} catch (_) { /* ignore */ }
},
// ── Note CRUD ───────────────────────
async openNote(noteId) {
this.showEditor();
if (noteId) {
this._editingNoteId = noteId;
try {
this._currentNote = await API.getNote(noteId);
this._populateFields(this._currentNote);
this._showReadMode();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed to load note: ' + e.message, 'error');
this.showList();
}
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
this._showEditMode();
if (this.titleEl) this.titleEl.focus();
}
},
async saveNote() {
const title = this.titleEl?.value?.trim() || '';
const content = this._getContent();
const folder = this.folderEl?.value?.trim() || '';
const tagsStr = this.tagsEl?.value || '';
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
if (!title && !content) {
if (typeof UI !== 'undefined') UI.toast('Note is empty', 'warning');
return;
}
try {
if (this._editingNoteId) {
await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags });
} else {
const resp = await API.createNote(title, content, folder, tags);
this._editingNoteId = resp.id || resp.data?.id;
}
this._currentNote = { ...this._currentNote, title, content, folder_path: folder, tags };
this._showReadMode();
if (typeof UI !== 'undefined') UI.toast('Note saved', 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Save failed: ' + e.message, 'error');
}
},
async deleteNote() {
if (!this._editingNoteId) return;
const ok = typeof showConfirm === 'function'
? await showConfirm('Delete this note?')
: window.confirm('Delete this note?');
if (!ok) return;
try {
await API.deleteNote(this._editingNoteId);
if (typeof UI !== 'undefined') UI.toast('Note deleted', 'success');
this._editingNoteId = null;
this._currentNote = null;
this.showList();
this.loadNotes();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
}
},
// ── Read / Edit / Preview Modes ─────
_showReadMode() {
if (this.editModeEl) this.editModeEl.style.display = 'none';
if (this.readModeEl) this.readModeEl.style.display = '';
const n = this._currentNote;
if (!n) return;
if (this.readTitleEl) this.readTitleEl.textContent = n.title || 'Untitled';
if (this.readMetaEl) {
const parts = [];
if (n.folder_path) parts.push('📁 ' + n.folder_path);
if (n.tags?.length) parts.push('🏷 ' + n.tags.join(', '));
if (n.updated_at) parts.push(new Date(n.updated_at).toLocaleString());
this.readMetaEl.textContent = parts.join(' · ');
}
if (this.readContentEl) {
if (typeof marked !== 'undefined') {
const raw = typeof marked.parse === 'function' ? marked.parse(n.content || '') : marked(n.content || '');
this.readContentEl.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : raw;
} else {
this.readContentEl.textContent = n.content || '';
}
}
// Show edit/delete buttons for read mode
this._setBtn('NoteEditBtn', true);
this._setBtn('NotePreviewBtn', false);
this._setBtn('NoteCancelEditBtn', false);
this._setBtn('NoteDeleteBtn', true);
},
_showEditMode() {
if (this.editModeEl) this.editModeEl.style.display = '';
if (this.readModeEl) this.readModeEl.style.display = 'none';
this._setBtn('NoteEditBtn', false);
this._setBtn('NotePreviewBtn', true);
this._setBtn('NoteCancelEditBtn', !!this._currentNote);
this._setBtn('NoteDeleteBtn', !!this._editingNoteId);
},
_showPreview() {
// Toggle between edit and read-mode preview
if (this.readModeEl?.style.display === 'none') {
// Show preview of current edits
const tempNote = {
title: this.titleEl?.value || '',
content: this._getContent(),
folder_path: this.folderEl?.value || '',
tags: (this.tagsEl?.value || '').split(',').map(t => t.trim()).filter(Boolean),
};
const saved = this._currentNote;
this._currentNote = tempNote;
this._showReadMode();
this._currentNote = saved;
// Swap buttons for preview-of-edits state
this._setBtn('NoteEditBtn', true);
this._setBtn('NotePreviewBtn', false);
} else {
this._showEditMode();
}
},
// ── CM6 Editor Management ───────────
_getContent() {
if (this._cmEditor) return this._cmEditor.getValue();
const ta = this.contentContainerEl?.querySelector('textarea');
return ta ? ta.value : '';
},
_setContent(text) {
if (this._cmEditor) { this._cmEditor.setValue(text); return; }
// Lazy-init CM6
if (this.contentContainerEl && window.CM?.noteEditor) {
this.contentContainerEl.innerHTML = '';
this._cmEditor = CM.noteEditor(this.contentContainerEl, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onLink: (title) => this._navigateToLink(title),
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await API.searchNoteTitles(query, 8);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
return;
}
// Textarea fallback
const ta = this.contentContainerEl?.querySelector('textarea');
if (ta) ta.value = text;
},
_destroyCmEditor() {
if (this._cmEditor?.destroy) this._cmEditor.destroy();
this._cmEditor = null;
},
async _navigateToLink(title) {
try {
const resp = await API.searchNotes(title);
const notes = resp.data || [];
const match = notes.find(n => n.title?.toLowerCase() === title.toLowerCase());
if (match) {
this.openNote(match.id);
} else {
// Create new note with this title
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
if (this.titleEl) this.titleEl.value = title;
this._showEditMode();
}
} catch (_) {}
},
// ── Field Helpers ───────────────────
_populateFields(note) {
if (this.titleEl) this.titleEl.value = note.title || '';
if (this.folderEl) this.folderEl.value = note.folder_path || '';
if (this.tagsEl) this.tagsEl.value = (note.tags || []).join(', ');
this._setContent(note.content || '');
},
_clearFields() {
if (this.titleEl) this.titleEl.value = '';
if (this.folderEl) this.folderEl.value = '';
if (this.tagsEl) this.tagsEl.value = '';
this._setContent('');
},
_setBtn(suffix, show) {
const el = document.getElementById(pfx + suffix);
if (el) el.style.display = show ? '' : 'none';
},
// ── Event Wiring ────────────────────
bind() {
const $ = (id) => document.getElementById(pfx + id);
const self = this;
// Toolbar
this._on($('NotesNewBtn'), 'click', () => self.openNote(null));
this._on($('NotesTodayBtn'), 'click', () => self._openDailyNote());
this._on($('NotesGraphBtn'), 'click', () => {
if (self.onOpenGraph) self.onOpenGraph();
});
// Editor header
this._on($('NotesBackBtn'), 'click', () => { self.showList(); self.loadNotes(); self.loadFolders(); });
this._on($('NoteSaveBtn'), 'click', () => self.saveNote());
this._on($('NoteDeleteBtn'), 'click', () => self.deleteNote());
this._on($('NoteEditBtn'), 'click', () => self._showEditMode());
this._on($('NotePreviewBtn'), 'click', () => self._showPreview());
this._on($('NoteCancelEditBtn'), 'click', () => {
if (self._currentNote) { self._populateFields(self._currentNote); self._showReadMode(); }
else self.showList();
});
// Filters
this._on(this.folderFilterEl, 'change', () => {
if (self.searchEl) self.searchEl.value = '';
self.loadNotes(self.folderFilterEl.value);
});
this._on(this.sortEl, 'change', () => {
self._sort = self.sortEl.value;
self.loadNotes();
});
// Search with debounce
let timer;
this._on(this.searchEl, 'input', () => {
clearTimeout(timer);
const q = self.searchEl.value.trim();
timer = setTimeout(() => {
if (q.length >= 2) {
if (self.folderFilterEl) self.folderFilterEl.value = '';
self.loadNotes(null, q);
} else if (q.length === 0) {
self.loadNotes();
}
}, 300);
});
},
// ── Daily Note ──────────────────────
async _openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
try {
const resp = await API.searchNotes(today);
const notes = resp.data || [];
const daily = notes.find(n => n.title === today || n.title?.startsWith(today));
if (daily) {
this.openNote(daily.id);
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
if (this.titleEl) this.titleEl.value = today;
this._setContent('# ' + today + '\n\n');
this.showEditor();
this._showEditMode();
}
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed to open daily note: ' + e.message, 'error');
}
},
// ── Lifecycle ───────────────────────
destroy() {
this._destroyCmEditor();
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
this._editingNoteId = null;
this._currentNote = null;
NoteEditor._instances.delete(this.id);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
NoteEditor._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
function _neEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

554
src/js/pane-container.js Normal file
View File

@@ -0,0 +1,554 @@
// ==========================================
// 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);
}
// Drag logic
let startX, startY, leftEl, rightEl, startLeftBasis, startRightBasis;
const onMouseDown = (e) => {
e.preventDefault();
const allPanes = Array.from(splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split'));
// Find the two panes adjacent to this handle
const handleIdx = Array.from(splitEl.children).indexOf(handle);
leftEl = splitEl.children[handleIdx - 1];
rightEl = splitEl.children[handleIdx + 1];
if (!leftEl || !rightEl) return;
startX = e.clientX;
startY = e.clientY;
// Use current rendered size (matches what flex layout computed)
startLeftBasis = leftEl.getBoundingClientRect().width;
startRightBasis = rightEl.getBoundingClientRect().width;
// Immediately lock the non-flex panes to their current sizes
// 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');
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e) => {
const delta = direction === 'horizontal'
? e.clientX - startX
: e.clientY - startY;
const leftIsFlex = leftEl.dataset.paneFlex === '1';
const rightIsFlex = rightEl.dataset.paneFlex === '1';
// Cap: no single fixed pane should exceed 60% of container
const maxSize = splitEl.getBoundingClientRect().width * 0.6;
if (leftIsFlex) {
// Only adjust the right (fixed) pane; left flex absorbs remainder
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) {
// Only adjust the left (fixed) pane; right flex absorbs remainder
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
leftEl.style.flexBasis = newLeft + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
} else {
// Both fixed — adjust both (original behavior)
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';
}
};
const onMouseUp = () => {
handle.classList.remove('pane-handle--active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Persist sizes
_persistSizes(surfaceId, splitEl);
};
handle.addEventListener('mousedown', onMouseDown);
// Double-click resets to default
handle.addEventListener('dblclick', () => {
if (leftEl) {
if (leftEl.dataset.paneFlex === '1') {
leftEl.style.flex = '1'; leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
} else {
leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
}
}
if (rightEl) {
if (rightEl.dataset.paneFlex === '1') {
rightEl.style.flex = '1'; rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
} else {
rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
}
}
_persistSizes(surfaceId, splitEl);
});
return { handle, splitEl, index, direction };
}
// ── Persistence ─────────────────────────────
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));
} 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 | <chat, notes>
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 },
],
});

View File

@@ -9,7 +9,7 @@ const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions'],
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
monitoring: ['usage', 'audit', 'stats'],
};
@@ -17,7 +17,7 @@ const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
@@ -41,6 +41,7 @@ const ADMIN_LOADERS = {
settings: () => UI.loadAdminSettings(),
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
extensions: () => UI.loadAdminExtensions(),
surfaces: () => typeof _loadAdminSurfaces === 'function' ? _loadAdminSurfaces() : null,
health: () => UI.loadAdminHealth(),
routing: () => UI.loadAdminRouting(),
capabilities: () => UI.loadAdminCapabilities(),

View File

@@ -78,6 +78,11 @@ function renderPersonaForm(containerEl, options = {}) {
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
</div>
</div>
<div class="form-group tool-grants-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_toolsAll" checked> All tools (inherit from context)</label>
<p class="form-hint" style="margin:2px 0 6px">When unchecked, restrict this persona to only the selected tools below.</p>
<div id="${pfx}_toolsList" class="tool-grants-list" style="display:none;max-height:200px;overflow-y:auto;padding:4px 0"></div>
</div>
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
@@ -127,6 +132,18 @@ function renderPersonaForm(containerEl, options = {}) {
if (options.onSubmit) options.onSubmit(form.getValues());
});
// v0.25.0: Tool grants — toggle "All tools" checkbox shows/hides tool list
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
const toolsListEl = document.getElementById(`${pfx}_toolsList`);
if (toolsAllCb && toolsListEl) {
toolsAllCb.addEventListener('change', () => {
toolsListEl.style.display = toolsAllCb.checked ? 'none' : '';
if (!toolsAllCb.checked && toolsListEl.children.length === 0) {
form.loadToolList();
}
});
}
const form = {
getValues() {
const v = {
@@ -152,6 +169,14 @@ function renderPersonaForm(containerEl, options = {}) {
if (memCb) v.memory_enabled = memCb.checked;
const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim();
if (memPrompt) v.memory_extraction_prompt = memPrompt;
// v0.25.0: Tool grants
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
if (toolsAllCb && !toolsAllCb.checked) {
const checked = document.querySelectorAll(`#${pfx}_toolsList input[type="checkbox"]:checked`);
v.tool_grants = Array.from(checked).map(cb => cb.value);
} else {
v.tool_grants = []; // empty = inherit all
}
return v;
},
setValues(p) {
@@ -168,6 +193,8 @@ function renderPersonaForm(containerEl, options = {}) {
// Memory fields (v0.18.0)
if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false;
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
// v0.25.0: Tool grants — load and apply
if (p.id) form.loadToolGrants(p.id);
},
clearForm() {
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
@@ -209,6 +236,63 @@ function renderPersonaForm(containerEl, options = {}) {
},
getModelSelect() { return document.getElementById(`${pfx}_model`); },
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
// v0.25.0: Tool grants management
async loadToolList() {
const listEl = document.getElementById(`${pfx}_toolsList`);
if (!listEl) return;
try {
const resp = await API._get('/api/v1/tools');
const tools = resp.tools || resp.data || resp || [];
listEl.innerHTML = '';
// Group by category
const groups = {};
tools.forEach(t => {
const cat = t.category || 'other';
(groups[cat] = groups[cat] || []).push(t);
});
Object.keys(groups).sort().forEach(cat => {
const hdr = document.createElement('div');
hdr.className = 'tool-grants-category';
hdr.textContent = cat.charAt(0).toUpperCase() + cat.slice(1);
listEl.appendChild(hdr);
groups[cat].forEach(t => {
const label = document.createElement('label');
label.className = 'tool-grants-item';
label.innerHTML = `<input type="checkbox" value="${t.name}"> ${t.display_name || t.name}`;
listEl.appendChild(label);
});
});
} catch (e) {
listEl.innerHTML = '<div class="form-hint">Failed to load tools</div>';
}
},
async loadToolGrants(personaId) {
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
const listEl = document.getElementById(`${pfx}_toolsList`);
if (!toolsAllCb || !listEl) return;
try {
const resp = await API._get(`/api/v1/personas/${personaId}/tool-grants`);
const grants = resp.data || [];
if (grants.length === 0) {
toolsAllCb.checked = true;
listEl.style.display = 'none';
} else {
toolsAllCb.checked = false;
listEl.style.display = '';
await form.loadToolList();
const grantSet = new Set(grants);
listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = grantSet.has(cb.value);
});
}
} catch (_) {
// Endpoint may not exist yet — default to all tools
toolsAllCb.checked = true;
listEl.style.display = 'none';
}
},
};
return form;
}
@@ -247,8 +331,10 @@ const UI = {
},
restoreSidebar() {
const el = document.getElementById('sidebar');
if (!el) return; // not all surfaces have a sidebar (e.g. editor)
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
document.getElementById('sidebar').classList.add('collapsed');
el.classList.add('collapsed');
}
},
@@ -279,7 +365,8 @@ const UI = {
},
closeUserMenu() {
document.getElementById('userFlyout').classList.remove('open');
const el = document.getElementById('userFlyout');
if (el) el.classList.remove('open');
},
// ── Sidebar Section Collapse ──────────────
@@ -1191,6 +1278,7 @@ const UI = {
const letter = (name[0] || '?').toUpperCase();
const avatarEl = document.getElementById('userAvatar');
const letterEl = document.getElementById('avatarLetter');
if (!avatarEl || !letterEl) return; // not all surfaces have user menu DOM
// Show avatar image or initial letter
let existingImg = avatarEl.querySelector('.user-avatar-img');
if (API.user?.avatar) {
@@ -1214,11 +1302,13 @@ const UI = {
letterEl.style.display = '';
letterEl.textContent = letter;
}
document.getElementById('userName').textContent = name;
const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = name;
},
showAdminButton(show) {
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
const adminEl = document.getElementById('menuAdmin');
if (adminEl) adminEl.style.display = show ? '' : 'none';
var dbg = document.getElementById('menuDebug');
if (dbg) dbg.style.display = show ? '' : 'none';
},
@@ -1402,5 +1492,43 @@ const UI = {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
},
// ── Appearance (scale + font) ────────────
// Shared across ALL surfaces. Previously in ui-settings.js
// which only loaded on chat/settings — editor surface was skipped.
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas — covers both chat and editor surfaces
document.querySelectorAll(
'.sidebar, .workspace-primary, .workspace-secondary, ' +
'.modal-overlay, .admin-panel, ' +
'.surface-editor, .surface-notes'
).forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => {
if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t);
});
});
},
restoreAppearance() {
try {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
if (scale !== 100 || msgFont !== 14) {
UI.applyAppearance(scale, msgFont);
} else {
// Still set the CSS variable for default
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
}
} catch (_) { /* corrupt localStorage — ignore */ }
}
};

View File

@@ -256,19 +256,7 @@ Object.assign(UI, {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
},
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas + modals + panels (but NOT .app or banners)
document.querySelectorAll('.sidebar, .workspace-primary, .workspace-secondary, .modal-overlay, .admin-panel').forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles with new zoom
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => { if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t); });
});
},
// applyAppearance — moved to ui-core.js (v0.25.0-cs11.1) so all surfaces can use it.
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');

154
src/js/user-menu.js Normal file
View File

@@ -0,0 +1,154 @@
// ==========================================
// Chat Switchboard — UserMenu Component
// ==========================================
// v0.25.0: Extracted from chat.html + ui-core.js.
// Reusable across all authenticated surfaces.
// Pattern: Go template partial (user-menu.html) + JS factory (this file).
//
// Usage:
// const menu = UserMenu.create({ id: 'global' });
// menu.setUser({ display_name: 'Jeff', username: 'jeff', avatar: null });
// menu.showAdmin(true);
// menu.destroy();
const UserMenu = {
primary: null,
_instances: new Map(),
create(opts) {
const pfx = opts.id || '';
const instance = {
id: pfx,
btnEl: document.getElementById(pfx + 'userMenuBtn'),
avatarEl: document.getElementById(pfx + 'userAvatar'),
letterEl: document.getElementById(pfx + 'avatarLetter'),
nameEl: document.getElementById(pfx + 'userName'),
flyoutEl: document.getElementById(pfx + 'userFlyout'),
settingsEl: document.getElementById(pfx + 'menuSettings'),
adminEl: document.getElementById(pfx + 'menuAdmin'),
teamAdminEl: document.getElementById(pfx + 'menuTeamAdmin'),
debugEl: document.getElementById(pfx + 'menuDebug'),
signoutEl: document.getElementById(pfx + 'menuSignout'),
_open: false,
_listeners: [],
// ── User display ────────────────────
setUser(user) {
if (!user) return;
const name = user.display_name || user.username || 'User';
const letter = (name[0] || '?').toUpperCase();
// Avatar image or initial letter
if (this.avatarEl) {
let img = this.avatarEl.querySelector('.user-avatar-img');
if (user.avatar) {
if (this.letterEl) this.letterEl.style.display = 'none';
if (img) {
img.src = user.avatar;
} else {
img = document.createElement('img');
img.src = user.avatar;
img.alt = '';
img.className = 'user-avatar-img';
img.onerror = function() {
this.remove();
if (instance.letterEl) {
instance.letterEl.style.display = '';
instance.letterEl.textContent = letter;
}
};
this.avatarEl.insertBefore(img, this.letterEl);
}
} else {
if (img) img.remove();
if (this.letterEl) {
this.letterEl.style.display = '';
this.letterEl.textContent = letter;
}
}
}
if (this.nameEl) this.nameEl.textContent = name;
},
// ── Visibility controls ─────────────
showAdmin(show) {
if (this.adminEl) this.adminEl.style.display = show ? '' : 'none';
if (this.debugEl) this.debugEl.style.display = show ? '' : 'none';
},
showTeamAdmin(show) {
if (this.teamAdminEl) this.teamAdminEl.style.display = show ? '' : 'none';
},
// ── Flyout toggle ───────────────────
open() {
if (this.flyoutEl) this.flyoutEl.classList.add('open');
this._open = true;
},
close() {
if (this.flyoutEl) this.flyoutEl.classList.remove('open');
this._open = false;
},
toggle() {
if (this._open) this.close(); else this.open();
},
// ── Event wiring ────────────────────
/**
* Wire event handlers. Accepts callbacks for menu actions.
* Call once after create().
*/
bind(handlers) {
handlers = handlers || {};
// Toggle flyout on avatar click
this._on(this.btnEl, 'click', (e) => {
e.stopPropagation();
this.toggle();
});
// Close on outside click
this._on(document, 'click', (e) => {
if (this._open && !e.target.closest('#' + pfx + 'userMenuWrap')) {
this.close();
}
});
// Menu items
this._on(this.settingsEl, 'click', () => { this.close(); if (handlers.onSettings) handlers.onSettings(); });
this._on(this.adminEl, 'click', () => { this.close(); if (handlers.onAdmin) handlers.onAdmin(); });
this._on(this.teamAdminEl, 'click', () => { this.close(); if (handlers.onTeamAdmin) handlers.onTeamAdmin(); });
this._on(this.debugEl, 'click', () => { this.close(); if (handlers.onDebug) handlers.onDebug(); });
this._on(this.signoutEl, 'click', () => { this.close(); if (handlers.onSignout) handlers.onSignout(); });
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
UserMenu._instances.delete(this.id);
if (UserMenu.primary === this) UserMenu.primary = null;
},
// ── Internal ────────────────────────
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
UserMenu._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};