Changeset 0.22.6 (#148)

This commit is contained in:
2026-03-03 13:12:13 +00:00
parent 45fe965c32
commit d8e0664fa3
24 changed files with 983 additions and 2473 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,10 @@
// policy exists but the frontend doesn't
// check it.
//
// v0.22.5: Updated for server-rendered Go
// templates. HTML is now in server/pages/
// templates/ — not in src/index.html.
//
// Run: node --test src/js/__tests__/policy-gating.test.js
// ==========================================
@@ -16,6 +20,21 @@ const fs = require('fs');
const path = require('path');
const SRC = path.join(__dirname, '..');
const TEMPLATES = path.join(__dirname, '..', '..', '..', 'server', 'pages', 'templates');
// ── Helper: read all server template HTML ────
function readAllTemplates() {
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.html')) files.push(fs.readFileSync(full, 'utf-8'));
}
}
walk(TEMPLATES);
return files.join('\n');
}
// ── Source code audits ───────────────────────
// These tests read the actual source files and verify that required
@@ -28,7 +47,9 @@ describe('Policy wiring audit — source code', () => {
// Read all app-side files (app.js + extracted handler files replace old monolith app.js)
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js', 'chat.js', 'tokens.js', 'notes.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
// Pages.js — server-rendered page handlers (v0.22.5+)
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
const templateSrc = readAllTemplates();
// ── allow_user_byok ──
@@ -54,18 +75,18 @@ describe('Policy wiring audit — source code', () => {
'MISSING: allow_user_personas check in UI');
});
it('admin settings UI has adminUserPresetsToggle', () => {
assert.ok(indexSrc.includes('adminUserPresetsToggle'),
'MISSING: preset toggle in admin settings HTML');
it('admin settings template has user-personas toggle', () => {
assert.ok(templateSrc.includes('settUserPersonas'),
'MISSING: settUserPersonas toggle in admin settings template');
});
it('admin settings load reads allow_user_personas', () => {
assert.ok(uiSrc.includes("adminUserPresetsToggle"),
'MISSING: loadAdminSettings must read allow_user_personas into toggle');
it('Pages.saveSettings writes allow_user_personas', () => {
assert.ok(pagesSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas in Pages.saveSettings');
});
it('admin settings save writes allow_user_personas', () => {
assert.ok(appSrc.includes("allow_user_personas"),
it('admin settings save writes allow_user_personas (SPA bridge)', () => {
assert.ok(appSrc.includes('allow_user_personas'),
'MISSING: handleSaveAdminSettings must write allow_user_personas');
});
@@ -199,55 +220,101 @@ describe('Team member dropdown population', () => {
});
// ── Admin settings field mapping ─────────────
// v0.22.5: Server-rendered admin settings template uses new element IDs.
// Pages.saveSettings() in pages.js is the primary handler.
describe('Admin settings field mapping', () => {
// Maps what the frontend sends to what the backend expects
describe('Admin settings field mapping (server templates)', () => {
// New element IDs used by server-rendered admin/settings.html + pages.js
const settingsFieldMap = {
'adminRegToggle': 'allow_registration',
'adminRegDefaultState': 'default_user_active',
'adminUserProvidersToggle': 'allow_user_byok',
'adminUserPresetsToggle': 'allow_user_personas',
'adminBannerEnabled': 'banner',
'settRegEnabled': 'allow_registration',
'settRegDefaultState': 'default_user_active',
'settUserBYOK': 'allow_user_byok',
'settUserPersonas': 'allow_user_personas',
'settBannerEnabled': 'banner',
};
// Read all app-side files (handleSaveAdminSettings is in settings-handlers.js)
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
const templateSrc = readAllTemplates();
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
it(`HTML has element #${elementId}`, () => {
assert.ok(indexSrc.includes(`id="${elementId}"`),
`MISSING: #${elementId} in index.html — admin settings incomplete`);
it(`template has element #${elementId}`, () => {
assert.ok(templateSrc.includes(`id="${elementId}"`),
`MISSING: #${elementId} in server templates — admin settings incomplete`);
});
it(`frontend writes setting "${settingKey}"`, () => {
assert.ok(appSrc.includes(settingKey),
`MISSING: "${settingKey}" in handleSaveAdminSettings`);
it(`Pages.saveSettings writes setting "${settingKey}"`, () => {
assert.ok(pagesSrc.includes(settingKey),
`MISSING: "${settingKey}" in Pages.saveSettings`);
});
}
});
// ── SPA bridge field mapping (backward compat) ──
// The SPA chat surface still loads settings-handlers.js + ui-admin.js.
// These use legacy element IDs for handleSaveAdminSettings().
// Verified at the JS level (elements are SPA-modal DOM, not templates).
describe('SPA bridge — admin settings handler references policy keys', () => {
const appSrc = ['settings-handlers.js', 'admin-handlers.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
const requiredPolicies = [
'allow_registration',
'default_user_active',
'allow_user_byok',
'allow_user_personas',
];
for (const key of requiredPolicies) {
it(`SPA bridge writes policy "${key}"`, () => {
assert.ok(appSrc.includes(key),
`MISSING: "${key}" in SPA bridge handler — policy not saved`);
});
}
});
// ── HTML element existence checks ────────────
// v0.22.5: Elements now live in server templates, not index.html.
// Settings surface dynamic sections (providers, personas) have scaffold
// containers rendered by Go templates that JS then populates.
describe('Critical HTML elements exist', () => {
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
describe('Critical HTML elements exist in server templates', () => {
const templateSrc = readAllTemplates();
const requiredElements = [
'adminMemberUser', // Team member user dropdown
// Settings surface — provider section scaffold
'userPresetList', // User preset list container
'userAddPresetBtn', // New preset button (policy-gated)
'userAddPresetForm', // Preset form container
'userProvidersDisabled', // BYOK disabled notice
'providerShowAddBtn', // Add provider button (policy-gated)
'adminUserProvidersToggle', // Admin toggle for BYOK
'adminUserPresetsToggle', // Admin toggle for presets
// Admin settings — policy toggles
'settUserBYOK', // Admin toggle for BYOK (was adminUserProvidersToggle)
'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle)
];
for (const id of requiredElements) {
it(`#${id} exists in index.html`, () => {
assert.ok(indexSrc.includes(`id="${id}"`),
it(`#${id} exists in server templates`, () => {
assert.ok(templateSrc.includes(`id="${id}"`),
`MISSING element: #${id} — UI feature will break`);
});
}
});
// ── SPA bridge — dynamic DOM elements ────────
// adminMemberUser is created by ui-admin.js loadMemberUserDropdown()
// which runs inside the SPA chat surface. Verify the JS function exists.
describe('SPA bridge — dynamic element creators', () => {
const uiAdminSrc = fs.readFileSync(path.join(SRC, 'ui-admin.js'), 'utf-8');
it('ui-admin.js has loadMemberUserDropdown', () => {
assert.ok(uiAdminSrc.includes('loadMemberUserDropdown'),
'MISSING: loadMemberUserDropdown — team member add will break');
});
it('loadMemberUserDropdown references adminMemberUser', () => {
assert.ok(uiAdminSrc.includes('adminMemberUser'),
'MISSING: adminMemberUser reference in loadMemberUserDropdown');
});
});

View File

@@ -173,10 +173,6 @@ async function startApp() {
UI.restoreSidebar();
await loadSettings();
// Initialize surface system (v0.21.3) — must happen before extensions
// so that extensions can register surfaces during init().
if (typeof Surfaces !== 'undefined') Surfaces.init();
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
// are registered when messages are first rendered.
try {
@@ -211,25 +207,13 @@ async function startApp() {
UI.renderChatList();
UI.updateModelSelector();
// Check for workspaces on startup — register editor surface early (v0.21.6)
// This ensures the Editor button + Files tab appear without needing to browse first.
if (typeof EditorMode !== 'undefined') {
try { await EditorMode.checkStartup(); } catch (_) {}
}
// Initialize hash router (v0.21.6) — replaces sessionStorage chat restore.
// Reads current URL hash and routes to the right surface/chat/workspace.
if (typeof Router !== 'undefined') {
Router.init();
} else {
// Fallback: restore last-active chat from sessionStorage
try {
const savedChat = sessionStorage.getItem('cs-active-chat');
if (savedChat && App.chats.some(c => c.id === savedChat)) {
selectChat(savedChat);
}
} catch (_) {}
}
// Restore last-active chat from sessionStorage
try {
const savedChat = sessionStorage.getItem('cs-active-chat');
if (savedChat && App.chats.some(c => c.id === savedChat)) {
selectChat(savedChat);
}
} catch (_) {}
UI.updateUser();
UI.showAdminButton(API.isAdmin);
@@ -352,16 +336,17 @@ function updateTabArrows(tabs) {
// ── Auth Flow ────────────────────────────────
function showSplash(health) {
document.getElementById('splashGate').style.display = 'flex';
document.getElementById('appContainer').style.display = 'none';
if (health && health.registration_enabled === false) {
document.getElementById('authTabRegister').style.display = 'none';
}
// v0.22.6: Server-rendered architecture uses /login page.
// Redirect instead of showing the SPA splash gate.
const base = window.__BASE__ || '';
window.location.href = base + '/login';
}
function hideSplash() {
document.getElementById('splashGate').style.display = 'none';
document.getElementById('appContainer').style.display = '';
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
if (splash) splash.style.display = 'none';
if (app) app.style.display = '';
}
async function handleLogin() {

View File

@@ -262,10 +262,8 @@ async function selectChat(chatId) {
// Notify surfaces and extensions about channel switch (v0.21.6)
Events.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
// Sync URL hash (v0.21.6)
if (typeof Router !== 'undefined' && Router._initialized) {
Router.update('chat', { chatId });
}
// Update browser URL to reflect selected chat
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
}
// ── Chat Header Token Count ──────────────────
@@ -397,10 +395,7 @@ async function newChat() {
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
// Notify surfaces — no channel selected (v0.21.6)
Events.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
// Sync URL hash
if (typeof Router !== 'undefined' && Router._initialized) {
Router.update('chat');
}
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
}
async function deleteChat(chatId) {
@@ -539,10 +534,7 @@ async function sendMessage() {
UI.renderChatList();
// Notify surfaces about new chat creation (v0.21.6)
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
// Sync URL hash
if (typeof Router !== 'undefined' && Router._initialized) {
Router.update('chat', { chatId: chat.id });
}
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}

View File

@@ -1,11 +1,9 @@
// ==========================================
// Chat Switchboard Editor Surface (v0.21.5)
// Chat Switchboard Editor Surface (v0.22.6)
// ==========================================
// IDE-like experience built as a surface consuming workspace
// primitives (v0.21.0) and surface infrastructure (v0.21.3).
//
// Layout: file tree (sidebar) | code editor (CM6) + chat panel (split)
// Load order: surfaces.js → editor-mode.js (after app init)
// IDE-like experience for workspace file editing with CM6.
// Server-rendered via Go template (editor.html).
// Entry point: EditorMode.mountServerRendered(wsId, wsName)
// ==========================================
const EditorMode = {
@@ -14,7 +12,6 @@ const EditorMode = {
_wsId: null, // workspace ID
_wsName: '', // workspace display name
_gitBranch: null, // current git branch (nullable)
_registered: false, // surface registered?
_active: false, // currently active?
// DOM (built once, reused across activations)
@@ -40,145 +37,15 @@ const EditorMode = {
// ── Initialization ───────────────────────
/**
* Check if the current channel/project has a workspace.
* Called on channel switch and app init.
* No-op — kept for backward compat with projects-ui.js callers.
* Editor is now only reachable via server route (/editor).
*/
async check() {
const channelId = App.currentChatId;
if (!channelId) {
// Don't unregister on empty state — startup check may have registered
if (!this._registered) return;
// Only unregister if we were registered via a channel (not startup)
return;
}
try {
// Check local data first to avoid unnecessary API calls
const localChat = App.chats?.find(c => c.id === channelId);
let wsId = localChat?.workspace_id || null;
let projectId = localChat?.projectId || null;
// If no local workspace, fetch from server (has workspace_id column)
if (!wsId) {
try {
const ch = await API.getChannel(channelId);
wsId = ch.workspace_id || null;
projectId = projectId || ch.project_id || null;
} catch (_) {}
}
// Check project workspace if channel doesn't have one
if (!wsId && projectId) {
try {
const proj = await API.getProject(projectId);
if (proj.workspace_id) {
this._register(proj.workspace_id, proj.name || 'Workspace');
return;
}
} catch (_) { /* no project workspace */ }
}
if (wsId) {
try {
const ws = await API.getWorkspace(wsId);
this._register(wsId, ws.name || 'Workspace');
} catch (_) {
this._register(wsId, 'Workspace');
}
}
// Don't unregister just because this channel has no workspace
} catch (e) {
console.warn('[EditorMode] check failed:', e);
}
},
/**
* Startup check — register editor if any workspace exists.
* Called once from startApp(), independent of channel selection.
*/
async checkStartup() {
if (this._registered) return;
try {
// First: check active project for workspace
if (App.activeProjectId) {
const proj = App.projects?.find(p => p.id === App.activeProjectId);
if (proj?.workspace_id) {
this._register(proj.workspace_id, proj.name || 'Workspace');
return;
}
}
// Then: check all projects for workspaces
for (const p of (App.projects || [])) {
if (p.workspace_id) {
this._register(p.workspace_id, p.name || 'Workspace');
return;
}
}
// Finally: check if any workspaces exist at all
const resp = await API.listWorkspaces();
const workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
if (workspaces.length > 0) {
this._register(workspaces[0].id, workspaces[0].name || 'Workspace');
}
} catch (e) {
console.warn('[EditorMode] startup check failed:', e);
}
},
_register(wsId, name) {
this._wsId = wsId;
this._wsName = name;
if (this._registered) return;
this._registered = true;
// Build DOM early so file tree is available for sidebar Files tab
if (!this._built) this._build();
Surfaces.register('editor', {
label: 'Editor',
icon: 'code',
regions: ['surface-header', 'surface-main'],
activate: () => this._activate(),
deactivate: () => this._deactivate(),
// Layout: editor is primary, chat in secondary (handled internally)
primary: 'editor',
secondary: 'chat',
secondaryOpts: ['chat'],
});
// Show the Files tab in sidebar and populate it
if (typeof showSidebarFilesTab === 'function') showSidebarFilesTab(true);
const filesPanel = document.getElementById('sidebarFilesPanel');
if (filesPanel && this._els?.fileTree) {
filesPanel.innerHTML = '';
filesPanel.appendChild(this._els.fileTree);
}
this._refreshFileTree();
console.log(`[EditorMode] Registered for workspace ${wsId}`);
},
/**
* Direct open — bypass check(), register with a specific workspace.
* Used by Router for hash-based navigation (e.g. #/editor/ws_abc123).
*/
openDirect(wsId) {
if (this._wsId === wsId && this._registered) return;
if (this._registered && this._wsId !== wsId) this._unregister();
this._register(wsId, 'Workspace');
// Async: fetch real name
API.getWorkspace(wsId).then(ws => {
this._wsName = ws.name || 'Workspace';
}).catch(() => {});
},
async check() {},
async checkStartup() {},
/**
* Mount into server-rendered editor surface template containers.
* Called by the <script> in editor.html when __SURFACE__ === 'editor'.
* Bypasses Surfaces registry — the Go template owns the layout.
*/
mountServerRendered(wsId, wsName) {
this._wsId = wsId;
@@ -197,7 +64,6 @@ const EditorMode = {
}
this._active = true;
this._registered = true;
// Populate file tree and git info
this._refreshFileTree();
@@ -212,131 +78,6 @@ const EditorMode = {
console.log(`[EditorMode] Mounted server-rendered for workspace ${wsId}`);
},
_unregister() {
if (!this._registered) return;
if (this._active) {
Surfaces.activate('chat');
}
Surfaces.unregister('editor');
this._registered = false;
this._wsId = null;
this._built = false;
this._openFiles.clear();
this._activeFile = null;
// Clean up sidebar Files tab
if (typeof showSidebarFilesTab === 'function') showSidebarFilesTab(false);
const filesPanel = document.getElementById('sidebarFilesPanel');
if (filesPanel) filesPanel.innerHTML = '';
this._els = null;
console.log('[EditorMode] Unregistered');
},
// ── Surface Callbacks ────────────────────
_activate() {
this._active = true;
if (!this._built) {
this._build();
}
// Populate regions
const regions = Surfaces._regionEls;
// Header
const headerEl = regions.get('surface-header');
if (headerEl) headerEl.appendChild(this._els.header);
// Main → split pane (editor + chat). Footer lives inside left pane.
const mainEl = regions.get('surface-main');
if (mainEl) mainEl.appendChild(this._els.main);
// Hide the global footer region — editor has its own status bar in the left pane
const footerRegion = regions.get('surface-footer');
if (footerRegion) footerRegion.style.display = 'none';
// Embed saved chat DOM into our chat pane
this._embedChat();
// Auto-switch sidebar to Files tab
const filesTab = document.getElementById('sidebarFilesTab');
if (filesTab && !filesTab.classList.contains('active')) filesTab.click();
// Refresh file tree
this._refreshFileTree();
this._refreshGitBranch();
// Focus active editor
if (this._activeFile) {
const f = this._openFiles.get(this._activeFile);
if (f?.editor?.focus) setTimeout(() => f.editor.focus(), 50);
}
},
_deactivate() {
this._active = false;
// Auto-save all modified files before switching surfaces (v0.21.6)
for (const [path, f] of this._openFiles) {
if (f.modified) this._saveFile(path);
}
// Switch sidebar back to Chats tab
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
if (chatsTab && !chatsTab.classList.contains('active')) chatsTab.click();
// Restore global footer region visibility
const footerRegion = Surfaces._regionEls?.get('surface-footer');
if (footerRegion) footerRegion.style.display = '';
// Return chat DOM to Surfaces saved store before regions are saved
this._returnChat();
},
// ── Chat Panel Embedding ─────────────────
// Borrow chat DOM from the saved fragments so the chat panel
// shows real messages and the user can interact with AI.
_chatPane: null,
_chatMessagesSlot: null,
_chatInputSlot: null,
_embedChat() {
if (!this._chatPane) return;
// Grab saved chat fragments
const msgFrag = Surfaces.getSavedFragment('chat', 'surface-main');
const inputFrag = Surfaces.getSavedFragment('chat', 'surface-footer');
if (msgFrag) {
this._chatMessagesSlot.appendChild(msgFrag);
// Scroll to bottom
this._chatMessagesSlot.scrollTop = this._chatMessagesSlot.scrollHeight;
}
if (inputFrag) {
this._chatInputSlot.appendChild(inputFrag);
}
},
_returnChat() {
if (!this._chatPane) return;
// Collect chat DOM back into fragments and return to Surfaces store
const msgFrag = document.createDocumentFragment();
while (this._chatMessagesSlot.firstChild) {
msgFrag.appendChild(this._chatMessagesSlot.firstChild);
}
Surfaces.putSavedFragment('chat', 'surface-main', msgFrag);
const inputFrag = document.createDocumentFragment();
while (this._chatInputSlot.firstChild) {
inputFrag.appendChild(this._chatInputSlot.firstChild);
}
Surfaces.putSavedFragment('chat', 'surface-footer', inputFrag);
},
// ── DOM Construction ─────────────────────
_build() {

View File

@@ -248,14 +248,14 @@ const Extensions = {
* and can be restored later. Critical for CM6 state preservation.
*/
replace(regionId, element) {
if (typeof Surfaces !== 'undefined') Surfaces.replace(regionId, element);
console.warn(`[Extensions] ui.replace() requires surface system (removed in v0.22.6)`);
},
/**
* Restore a surface region's previously saved content.
*/
restore(regionId) {
if (typeof Surfaces !== 'undefined') Surfaces.restore(regionId);
console.warn(`[Extensions] ui.restore() requires surface system (removed in v0.22.6)`);
},
/** Inject an element into a named UI region (stub for future use). */
@@ -270,20 +270,19 @@ const Extensions = {
},
},
// Surface registration (v0.21.3)
// Surface registration (removed v0.22.6 — server-rendered surfaces)
surfaces: {
register: (id, opts) => {
if (typeof Surfaces !== 'undefined') Surfaces.register(id, opts);
console.warn(`[Extensions] surfaces.register() removed in v0.22.6`);
},
unregister: (id) => {
if (typeof Surfaces !== 'undefined') Surfaces.unregister(id);
console.warn(`[Extensions] surfaces.unregister() removed in v0.22.6`);
},
activate: (id) => {
if (typeof Surfaces !== 'undefined') Surfaces.activate(id);
console.warn(`[Extensions] surfaces.activate() removed in v0.22.6`);
},
getCurrent: () => {
if (typeof Surfaces !== 'undefined') return Surfaces.getCurrent();
return 'chat';
return window.__SURFACE__ || 'chat';
},
},

View File

@@ -205,6 +205,51 @@ const Pages = {
if (ok) _toast('Settings saved', 'success');
},
// ── Login ─────────────────────────────────
async doLogin() {
const username = _val('loginUsername');
const password = _val('loginPassword');
const errEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
if (!username || !password) {
if (errEl) { errEl.textContent = 'Enter username and password'; errEl.style.display = ''; }
return;
}
if (errEl) errEl.style.display = 'none';
if (btn) { btn.disabled = true; btn.textContent = 'Logging in…'; }
const base = window.__BASE__ || '';
try {
const resp = await fetch(base + '/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: username, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `Login failed (${resp.status})`);
}
const data = await resp.json();
// Save tokens in same format as API._setAuth / API.saveTokens
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
localStorage.setItem(storageKey, JSON.stringify({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: data.user,
}));
// Set cookie for server-rendered page auth
document.cookie = `sb_token=${data.access_token}; path=/; max-age=900; SameSite=Strict`;
// Redirect to chat surface
window.location.href = base + '/';
} catch (e) {
if (errEl) { errEl.textContent = e.message; errEl.style.display = ''; }
if (btn) { btn.disabled = false; btn.textContent = 'Log In'; }
}
},
// ── User Settings (settings surface) ─────
async saveProfile() {

View File

@@ -145,7 +145,6 @@ const REPL = {
if (typeof API !== 'undefined') globals.API = API;
if (typeof Events !== 'undefined') globals.Events = Events;
if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
if (typeof Surfaces !== 'undefined') globals.Surfaces = Surfaces;
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
if (typeof UI !== 'undefined') globals.UI = UI;

View File

@@ -1,322 +0,0 @@
// ==========================================
// Chat Switchboard Hash Router (v0.21.6)
// ==========================================
// URL-driven navigation. The hash IS the entry point.
//
// #/chat → default chat view
// #/chat/ch_abc123 → open specific chat
// #/editor → editor surface (workspace picker if none)
// #/editor/ws_abc123 → editor with specific workspace
//
// Bookmarkable. Browser back/forward works.
// ==========================================
const Router = {
_navigating: false, // prevents circular hash updates
_pending: null, // queued route when surface isn't registered yet
_initialized: false,
// ── Public API ───────────────────────────
/**
* Initialize router. Call once after Surfaces.init(), loadChats(), etc.
* Reads the current hash and routes to it.
*/
init() {
window.addEventListener('hashchange', () => this._onHashChange());
// Listen for surface registration to resolve pending routes
Events.on('surface.registered', () => this._tryPending());
// Keep hash in sync when surfaces or chats change externally
Events.on('surface.activated', (ev) => {
if (!this._navigating) this._syncHash();
});
this._initialized = true;
this.resolve();
console.log('[Router] Initialized');
},
/**
* Navigate programmatically.
* @param {string} path — e.g. '/editor/ws_abc123'
*/
navigate(path) {
const hash = '#' + (path.startsWith('/') ? path : '/' + path);
if (location.hash === hash) {
this.resolve(); // re-resolve even if same hash
} else {
location.hash = hash;
// hashchange event will fire → _onHashChange → resolve
}
},
/**
* Parse current hash and execute the route.
*/
resolve() {
const route = this._parse();
console.log('[Router] Resolving:', route);
this._navigating = true;
switch (route.surface) {
case 'chat':
this._routeChat(route);
break;
case 'editor':
this._routeEditor(route);
break;
default:
// Unknown route → default to chat
this._routeChat({ surface: 'chat', id: null });
}
this._navigating = false;
},
/**
* Update hash to reflect current app state.
* Called after external navigation (e.g. sidebar click).
*/
update(surface, params = {}) {
if (this._navigating) return;
let hash = '#/' + (surface || 'chat');
if (params.chatId) hash += '/' + params.chatId;
if (params.wsId) hash += '/' + params.wsId;
if (params.path) hash += '/' + params.path;
if (location.hash !== hash) {
this._navigating = true;
history.replaceState(null, '', hash);
this._navigating = false;
}
},
// ── Route Handlers ───────────────────────
_routeChat(route) {
// Ensure we're on chat surface
if (Surfaces.getCurrent() !== 'chat') {
Surfaces.activate('chat');
}
if (route.id) {
const chat = App.chats?.find(c => c.id === route.id);
if (chat) {
selectChat(route.id);
} else {
console.warn(`[Router] Chat ${route.id} not found`);
}
}
// No id → just show empty state (newChat was already showing)
},
_routeEditor(route) {
const wsId = route.id || null;
// If EditorMode is already registered, activate directly
if (Surfaces.get('editor')) {
// Update workspace if specified and different
if (wsId && typeof EditorMode !== 'undefined' && EditorMode._wsId !== wsId) {
EditorMode.openDirect(wsId);
}
Surfaces.activate('editor');
return;
}
// Surface not registered yet — try to register it
if (wsId && typeof EditorMode !== 'undefined') {
EditorMode.openDirect(wsId);
// After registration, activate
if (Surfaces.get('editor')) {
Surfaces.activate('editor');
return;
}
}
// No workspace specified — try active project's workspace
if (!wsId && typeof EditorMode !== 'undefined') {
const projWsId = this._activeProjectWorkspace();
if (projWsId) {
EditorMode.openDirect(projWsId);
if (Surfaces.get('editor')) {
Surfaces.activate('editor');
return;
}
}
}
// Still not registered — show workspace picker
if (!Surfaces.get('editor')) {
this._pending = route;
this._showWorkspacePicker('editor');
}
},
// ── Workspace Picker (inline) ────────────
async _showWorkspacePicker(targetSurface) {
let workspaces = [];
try {
const resp = await API.listWorkspaces();
workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
} catch (_) {}
if (workspaces.length === 1) {
// Auto-select single workspace
this._pickWorkspace(targetSurface, workspaces[0].id);
return;
}
// Build overlay
const overlay = document.createElement('div');
overlay.className = 'router-picker-overlay';
const label = 'Editor';
let inner = `
<div class="router-picker">
<h3>Open ${label}</h3>
<p>Choose a workspace to get started:</p>
<div class="router-picker-list">`;
if (workspaces.length === 0) {
inner += '<div class="router-picker-empty">No workspaces yet</div>';
}
for (const ws of workspaces) {
inner += `<button class="router-picker-item" data-ws="${ws.id}">
<span class="router-picker-icon">📁</span>
<span>${this._esc(ws.name || ws.id.slice(0, 8))}</span>
</button>`;
}
inner += `</div>
<div class="router-picker-actions">
<button class="btn-small btn-primary" id="routerPickerNew">+ New workspace</button>
<button class="btn-small" id="routerPickerCancel">Cancel</button>
</div>
</div>`;
overlay.innerHTML = inner;
// Wire clicks
overlay.querySelectorAll('.router-picker-item').forEach(btn => {
btn.addEventListener('click', () => {
overlay.remove();
this._pickWorkspace(targetSurface, btn.dataset.ws);
});
});
overlay.querySelector('#routerPickerNew')?.addEventListener('click', async () => {
const name = prompt('Workspace name:');
if (!name?.trim()) return;
try {
const ws = await API.createWorkspace({
name: name.trim(),
owner_type: 'user',
owner_id: API.user?.id || '',
});
overlay.remove();
this._pickWorkspace(targetSurface, ws.id);
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + (e.message || e), 'error');
}
});
overlay.querySelector('#routerPickerCancel')?.addEventListener('click', () => {
overlay.remove();
this._pending = null;
this.navigate('/chat');
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
this._pending = null;
this.navigate('/chat');
}
});
document.body.appendChild(overlay);
},
_pickWorkspace(targetSurface, wsId) {
if (typeof EditorMode !== 'undefined') {
EditorMode.openDirect(wsId);
if (Surfaces.get('editor')) Surfaces.activate('editor');
}
// Update hash
this.navigate('/editor/' + wsId);
},
// ── Hash Parsing ─────────────────────────
_parse() {
const hash = (location.hash || '').replace(/^#\/?/, '');
const parts = hash.split('/').filter(Boolean);
if (!parts.length) return { surface: 'chat', id: null };
const surface = parts[0];
const id = parts[1] || null;
const extra = parts.length > 2 ? parts.slice(2).join('/') : null;
return { surface, id, extra };
},
// ── Event Handlers ───────────────────────
_onHashChange() {
if (this._navigating) return;
this.resolve();
},
_tryPending() {
if (!this._pending) return;
const route = this._pending;
// Check if the target surface is now registered
if (Surfaces.get(route.surface)) {
this._pending = null;
this._navigating = true;
Surfaces.activate(route.surface);
this._navigating = false;
}
},
/**
* Sync hash to current app state (called when navigation happens
* outside the router, e.g. clicking a chat in the sidebar).
*/
_syncHash() {
const surface = Surfaces.getCurrent();
const params = {};
if (surface === 'chat' && App.currentChatId) {
params.chatId = App.currentChatId;
} else if (surface === 'editor' && typeof EditorMode !== 'undefined') {
params.wsId = EditorMode._wsId;
}
this.update(surface, params);
},
// ── Helpers ──────────────────────────────
_activeProjectWorkspace() {
if (!App.activeProjectId) return null;
const proj = App.projects?.find(p => p.id === App.activeProjectId);
return proj?.workspace_id || null;
},
_esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
},
};

View File

@@ -1,368 +0,0 @@
// ==========================================
// Chat Switchboard Surface Registry
// ==========================================
// Manages "modes" (surfaces) — chat, editor, etc.
// Each surface can take over named regions of the UI without
// destroying the DOM nodes of the previous surface.
//
// Load order: events.js → surfaces.js → extensions.js
//
// Key design: replace() detaches children (kept in memory),
// restore() re-attaches them. CM6 editor instances survive
// mode switches because their DOM isn't destroyed.
// ==========================================
const Surfaces = {
// ── State ────────────────────────────────
_registry: new Map(), // surfaceId → { label, icon, regions, activate, deactivate }
_current: 'chat', // active surface id
_saved: new Map(), // regionId → DocumentFragment (saved DOM children)
_regionEls: new Map(), // regionId → DOM element (cached lookups)
// ── Initialization ───────────────────────
/**
* Cache region elements and register chat as the implicit default surface.
* Called from app.js init after DOM is ready.
*/
init() {
// Cache all region containers
document.querySelectorAll('[data-surface-region]').forEach(el => {
const id = el.dataset.surfaceRegion;
this._regionEls.set(id, el);
});
// Chat is always registered as the default surface
this._registry.set('chat', {
label: 'Chat',
icon: 'message-square',
regions: ['surface-header', 'surface-main', 'surface-footer'],
activate: null, // chat activation is implicit (restore regions)
deactivate: null,
_isDefault: true,
// Layout declarations (v0.22.0)
primary: 'chat',
secondary: 'preview',
secondaryOpts: ['preview', 'notes', 'project'],
});
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
},
// ── Registration ─────────────────────────
/**
* Register a new surface (mode).
* @param {string} id — unique surface identifier
* @param {object} opts — { label, icon, regions[], activate(), deactivate(),
* primary?, secondary?, secondaryOpts? }
*
* Layout declarations (v0.22.0):
* primary — pane id that owns the workspace-primary slot ('chat', 'notes', 'editor')
* secondary — default pane id for workspace-secondary (null = hidden)
* secondaryOpts — array of pane ids allowed in secondary for this surface
*/
register(id, opts = {}) {
if (this._registry.has(id)) {
console.warn(`[Surfaces] ${id} already registered`);
return;
}
this._registry.set(id, {
label: opts.label || id,
icon: opts.icon || 'layout',
regions: opts.regions || [],
activate: opts.activate || null,
deactivate: opts.deactivate || null,
primary: opts.primary || id,
secondary: opts.secondary || null,
secondaryOpts: opts.secondaryOpts || [],
});
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
// Show mode selector if we now have >1 surface
this._updateModeSelector();
Events.emit('surface.registered', { surface: id }, { localOnly: true });
},
/**
* Unregister a surface. If it's currently active, switch back to chat.
*/
unregister(id) {
if (id === 'chat') return; // can't unregister chat
if (!this._registry.has(id)) return;
if (this._current === id) {
this.activate('chat');
}
this._registry.delete(id);
this._updateModeSelector();
Events.emit('surface.unregistered', { surface: id }, { localOnly: true });
},
// ── Activation ───────────────────────────
/**
* Switch to a different surface.
* Deactivates the current surface, saves its region DOM, and activates the new one.
*/
activate(id) {
if (!this._registry.has(id)) {
console.error(`[Surfaces] Unknown surface: ${id}`);
return;
}
if (this._current === id) return;
const previous = this._current;
const prevDef = this._registry.get(previous);
const nextDef = this._registry.get(id);
// Deactivate current surface
if (prevDef) {
// Save current region contents
for (const regionId of (prevDef.regions || [])) {
this._saveRegion(regionId);
}
// Call surface-specific deactivation
if (typeof prevDef.deactivate === 'function') {
try { prevDef.deactivate(); } catch (e) {
console.error(`[Surfaces] deactivate ${previous}:`, e);
}
}
}
Events.emit('surface.deactivated', { surface: previous }, { localOnly: true });
this._current = id;
// Activate new surface
if (typeof nextDef.activate === 'function') {
try { nextDef.activate(); } catch (e) {
console.error(`[Surfaces] activate ${id}:`, e);
}
} else {
// Default behavior: restore saved DOM for this surface's regions
for (const regionId of (nextDef.regions || [])) {
this._restoreRegion(regionId);
}
}
// Update mode selector active state
this._updateModeSelectorActive();
Events.emit('surface.activated', { surface: id, previous }, { localOnly: true });
console.log(`[Surfaces] Activated: ${id} (was: ${previous})`);
},
// ── Query ────────────────────────────────
/** Get the currently active surface id. */
getCurrent() {
return this._current;
},
/** Get surface definition by id. */
get(id) {
return this._registry.get(id) || null;
},
/** Get all registered surface ids. */
list() {
return Array.from(this._registry.keys());
},
/** True when more than just chat is registered. */
hasMultiple() {
return this._registry.size > 1;
},
/**
* Get the layout declarations for the current surface.
* Returns { primary, secondary, secondaryOpts } or defaults.
*/
getLayout(id) {
const def = this._registry.get(id || this._current);
if (!def) return { primary: 'chat', secondary: null, secondaryOpts: [] };
return {
primary: def.primary || id || 'chat',
secondary: def.secondary || null,
secondaryOpts: def.secondaryOpts || [],
};
},
/**
* Get a saved DocumentFragment for a specific surface + region.
* Used by surfaces like editor-mode that want to embed chat DOM
* inside their own layout.
* @param {string} surfaceId — the surface that owns the saved DOM
* @param {string} regionId — the region name
* @returns {DocumentFragment|null}
*/
getSavedFragment(surfaceId, regionId) {
const key = `${surfaceId}::${regionId}`;
return this._saved.get(key) || null;
},
/**
* Put a DocumentFragment back into the saved store.
* Used during deactivation to return borrowed DOM.
*/
putSavedFragment(surfaceId, regionId, frag) {
const key = `${surfaceId}::${regionId}`;
this._saved.set(key, frag);
},
// ── Region Management ────────────────────
/**
* Replace a region's content with a new element.
* The current children are saved (detached, not destroyed) and can be
* restored later with restore(). This is critical for CM6 state preservation.
*
* @param {string} regionId — data-surface-region value
* @param {Element} element — new content to insert
*/
replace(regionId, element) {
const container = this._regionEls.get(regionId);
if (!container) {
console.warn(`[Surfaces] Unknown region: ${regionId}`);
return;
}
// Save current children to a DocumentFragment (preserves DOM state)
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
// Insert new content
if (element) {
container.appendChild(element);
}
},
/**
* Restore a region's previously saved content.
* @param {string} regionId — data-surface-region value
*/
restore(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear current contents
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Re-attach saved DOM
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Internal: Save/Restore Regions ───────
/**
* Save the current DOM children of a region for the active surface.
* Called during deactivation.
*/
_saveRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
},
/**
* Restore the saved DOM children of a region for the active surface.
* Called during activation.
*/
_restoreRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear container
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Restore saved content
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Mode Selector UI ────────────────────
/**
* Rebuild the mode selector in the sidebar.
* Shown only when ≥1 extension surface is registered (i.e. more than just chat).
*/
_updateModeSelector() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
if (this._registry.size <= 1) {
wrap.style.display = 'none';
wrap.innerHTML = '';
return;
}
wrap.style.display = '';
wrap.innerHTML = '';
for (const [id, def] of this._registry) {
const btn = document.createElement('button');
btn.className = 'mode-btn' + (id === this._current ? ' active' : '');
btn.dataset.surface = id;
btn.title = def.label;
btn.innerHTML = `${this._iconSvg(def.icon)}<span class="mode-btn-label">${def.label}</span>`;
btn.addEventListener('click', () => this.activate(id));
wrap.appendChild(btn);
}
},
/** Update the active class on mode selector buttons. */
_updateModeSelectorActive() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
wrap.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.surface === this._current);
});
},
/**
* Return an SVG string for a lucide-style icon name.
* Only the icons we actually need are included here.
*/
_iconSvg(name) {
const icons = {
'message-square': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
'code': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
'file-text': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
'layout': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
'terminal': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
'globe': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
};
return icons[name] || icons['layout'];
},
};