Changeset 0.37.13 (#225)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-22 12:52:00 +00:00
committed by xcaliber
parent aeda4fdd0c
commit fcb998bff9
13 changed files with 165 additions and 2360 deletions

View File

@@ -1,5 +1,55 @@
# Changelog # Changelog
## [0.37.13] — 2026-03-22
### Summary
Scorched Earth III — third dead-code purge. 5 files deleted (2,269 net lines).
The massive `ui-primitives.js` (1,133 LOC) is gone; its few remaining consumers
(`debug.js`, `switchboard-sdk.js`) received inlined replacements. Theme management
migrated from the global `Theme` object to the Preact SDK's `createTheme` module.
SDK slimmed by removing 6 dead factory wrappers.
### Removed
- `src/js/ui-primitives.js` (1,133 LOC) — `esc()`, `createComponentRegistry()`,
`componentMixin()`, `Providers`, `Roles`, `showConfirm()`, `showPrompt()`,
`openModal()`, `closeModal()`, `Theme`, render helpers. All consumers deleted
or inlined.
- `src/js/code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor, zero consumers
- `src/js/file-tree.js` (290 LOC) — workspace file browser widget, zero consumers
- `src/js/user-menu.js` (206 LOC) — vanilla flyout menu; all surfaces use
Preact `sw/shell/user-menu.js`
- `src/js/app-state.js` (139 LOC) — global `window.App` state; Preact surfaces
use local state hooks; `debug.js` state snapshot already guards with `typeof`
### Changed
- `src/js/debug.js` — inlined `openModal()`, `closeModal()`, `showConfirm()`,
`_escHtml()` (~44 LOC) to replace dependency on deleted `ui-primitives.js`
- `src/js/switchboard-sdk.js` — removed 6 dead factory wrappers (`sw.notes()`,
`sw.panels()`, `sw.fileTree()`, `sw.codeEditor()`, `sw.layout()`,
`sw.userMenu()` + `_hydrateUserMenu()`); replaced global `Theme` with
`import { createTheme } from './sw/sdk/theme.js'`; inlined `esc()` and
`openModal`/`closeModal` helpers; net 213 LOC
- `server/pages/templates/base.html` — removed 5 script tags (`app-state.js`,
`ui-primitives.js`, `user-menu.js`, `file-tree.js`, `code-editor.js`);
consolidated removal comments
- `src/sw.js` — removed 5 entries from `SHELL_FILES` cache manifest
### Design Notes
- **6 old JS files survive:** `sb.js` (debug modal onclick handlers),
`events.js` (extension bridge), `switchboard-sdk.js` (extension compatibility),
`debug.js` + `repl.js` (active tooling), `workflow-surfaces.js` (deferred to
Preact rewrite). Targeted for removal as extensions migrate.
- **`user-menu.css` kept:** extension surface template still renders server-side
user-menu HTML; CSS provides styling for the placeholder.
- **Cumulative scorched earth:** 49 files deleted, ~17,710 lines across three
passes (v0.37.10, v0.37.12, v0.37.13).
---
## [0.37.12] — 2026-03-22 ## [0.37.12] — 2026-03-22
### Summary ### Summary

View File

@@ -1 +1 @@
0.37.12 0.37.13

View File

@@ -48,8 +48,8 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ v0.37.2 Primitives ✅ │ │ v0.37.2 Primitives ✅ │
│ v0.37.3 SDK │ │ v0.37.3 SDK │
│ v0.37.4 Shell │ │ v0.37.4 Shell │
│ v0.37.512 Surfaces ✅ │ │ v0.37.513 Surfaces ✅ │
│ v0.37.1318 Surfaces │ │ v0.37.1418 Surfaces │
│ │ │ │ │ │
══════╪════════════╪═════════════════╪══════ ══════╪════════════╪═════════════════╪══════
│ MVP v0.50.0 │ │ MVP v0.50.0 │
@@ -637,10 +637,10 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| v0.37.10 | Chat surface ✅ | Scorched earth: 21 files deleted (12,841 net), Preact surface + ChatPane kit upgrades | | v0.37.10 | Chat surface ✅ | Scorched earth: 21 files deleted (12,841 net), Preact surface + ChatPane kit upgrades |
| v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix | | v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix |
| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned | | v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned |
| v0.37.13 | Workflow surfaces | Form renderer, review views | | v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact |
| v0.37.14 | Pane audit | Collaborative walkthrough: each pane checked for form and function | | v0.37.14 | Pane audit | Collaborative walkthrough: core features + theming checked |
| v0.37.15 | Projects surface | Project views, note/channel associations | | v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) |
| v0.37.16 | Workflow assignment | Workflow routing, assignment UX | | v0.37.16 | Projects surface | Project views, note/channel associations (design-first) |
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI | | v0.37.17 | Debug / model surface | Debug tooling, model configuration UI |
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified | | v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified |
@@ -695,15 +695,39 @@ Kept (11 JS files — extension surface + debug dependencies):
`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`, `file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`,
`repl.js`, `workflow-surfaces.js` `repl.js`, `workflow-surfaces.js`
**v0.37.13 — Scorched Earth III ✅:**
Third dead-code purge. 5 files deleted (2,269 net lines). The 1,133-line
`ui-primitives.js` is gone; Theme management migrated to Preact SDK module.
SDK slimmed by removing 6 dead factory wrappers.
Deleted (5 files):
- [x] `ui-primitives.js` (1,133 LOC) — `esc`, component lifecycle, Providers,
Roles, modal/confirm/prompt, Theme, render helpers
- [x] `code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor
- [x] `file-tree.js` (290 LOC) — workspace file browser
- [x] `user-menu.js` (206 LOC) — vanilla flyout menu
- [x] `app-state.js` (139 LOC) — global `window.App` state
- [x] 5 script tags removed from `base.html`
- [x] 5 entries removed from `sw.js` SHELL_FILES
- [x] `switchboard-sdk.js` slimmed: 6 dead wrappers removed, Theme→Preact SDK
Kept (6 JS files — extension + debug dependencies):
`sb.js`, `events.js`, `switchboard-sdk.js`, `debug.js`, `repl.js`,
`workflow-surfaces.js`
Cumulative scorched earth: 49 files, ~17,710 lines (v0.37.10 + v0.37.12 + v0.37.13)
**v0.37.14 — Pane Audit:** **v0.37.14 — Pane Audit:**
Collaborative walkthrough of each pane (ChatPane, NotesPane) and surface Collaborative walkthrough of core features and theming. Each pane and surface
checking form and function. Fixes discovered during the audit are applied checked for form, function, and visual consistency. Fixes applied in-place.
in-place. No new architecture — just making everything work correctly. No new architecture — making everything work correctly.
- [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks - [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks
- [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note - [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note
- [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts - [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts
- [ ] Theming: light/dark mode consistency audit across all surfaces
- [ ] Kit enhancement backlog triage (above items evaluated for inclusion) - [ ] Kit enhancement backlog triage (above items evaluated for inclusion)
**v0.37.15 — Projects Surface:** **v0.37.15 — Projects Surface:**

View File

@@ -93,34 +93,22 @@
</script> </script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script> {{/* v0.37.13 Scorched Earth III: app-state.js, ui-primitives.js,
{{/* api.js removed in v0.37.10. Extension surfaces using Switchboard.init() user-menu.js, file-tree.js, code-editor.js removed.
should migrate to the Preact SDK (sw/sdk/). */}} Earlier: api.js, ui-core.js, pages.js (v0.37.10),
ui-format.js, virtual-scroll.js, model-selector.js, drag-resize.js (v0.37.12),
chat-pane.js, pane-container.js (v0.37.10). */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives.js?v={{.Version}}"></script> {{/* v0.28.5: SDK — composition layer over globals. */}}
{{/* marked.min.js + purify.min.js removed from base in v0.37.12 — surfaces load their own copies */}}
{{/* ui-format.js, virtual-scroll.js removed in v0.37.12 */}}
{{/* ui-core.js + pages.js removed in v0.37.10 */}}
{{/* v0.25.0: Component scripts — available on all surfaces */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/user-menu.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
{{/* chat-pane.js + pane-container.js removed in v0.37.10 — see sw/components/chat-pane/ */}}
{{/* model-selector.js, drag-resize.js removed in v0.37.12 */}}
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
primitives/components it wraps, before extensions.js. */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>
{{/* ── Universal init: SDK boot for ALL surfaces ── */}} {{/* ── Universal init: SDK boot for ALL surfaces ── */}}
<script type="module" nonce="{{.CSPNonce}}"> <script type="module" nonce="{{.CSPNonce}}">
// v0.28.5: SDK handles theme, appearance, and UserMenu hydration. // v0.28.5: SDK handles theme + appearance. Idempotent.
// Surfaces that load app.js call Switchboard.init() again (idempotent).
Switchboard.init(); Switchboard.init();
// v0.28.0: Universal logout — available on every surface. // Universal logout — available on every surface.
// Chat/editor/notes load app.js which overrides this with a // Preact surfaces override with richer versions.
// richer version (showConfirm, App.chats cleanup, etc.).
// Extension surfaces get this baseline version.
function handleLogout() { function handleLogout() {
if (!confirm('Sign out?')) return; if (!confirm('Sign out?')) return;
if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); } if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); }
@@ -141,8 +129,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
{{end}} {{end}}
{{/* v0.28.5: UserMenu hydration moved into Switchboard.init() (switchboard-sdk.js). {{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
The SDK handles create/bind/setUser for all non-chat surfaces automatically. */}}
{{/* ── Debug Log Modal (all surfaces) ───── */}} {{/* ── Debug Log Modal (all surfaces) ───── */}}
<div id="debugModal" class="modal-overlay"> <div id="debugModal" class="modal-overlay">

View File

@@ -1,3 +1,3 @@
package main package main
const Version = "0.37.12" const Version = "0.37.13"

View File

@@ -1,139 +0,0 @@
// ==========================================
// Chat Switchboard Shared Application State
// ==========================================
// Loaded by base.html for ALL surfaces. Contains the App state
// object and universal data-loading functions (models, settings).
// Surface-specific logic lives in app.js (chat), admin-scaffold.js, etc.
//
// Exports: window.App, window.fetchModels
// ==========================================
const App = {
chats: [],
models: [],
hiddenModels: new Set(),
isGenerating: false,
abortController: null,
serverSettings: {},
policies: {},
stagedFiles: [],
channelFiles: {},
storageConfigured: false,
projects: [],
collapsedProjects: {},
activeProjectId: null,
showArchivedProjects: false,
// v0.23.2: Unified active conversation
activeConversation: null, // { id, type } where type = direct|dm|group|channel
// v0.23.1: Multi-user
channels: [], // DMs + named channels (sidebar display data)
folders: [], // chat folders (user-scoped)
collapsedFolders: {},
presence: {}, // { userId: 'online' | 'offline' }
users: [], // v0.23.2: [{id, username, displayName}] for @mention autocomplete
activeParticipants: [], // v0.24.0: user participants of active channel (for scoped autocomplete)
/** Get active conversation ID (shorthand). */
get activeId() { return this.activeConversation?.id || null; },
/** Get active conversation type. */
get activeType() { return this.activeConversation?.type || null; },
/** Set the active conversation. Clears if id is null. */
setActive(id, type) {
this.activeConversation = id ? { id, type: type || 'direct' } : null;
this.activeParticipants = [];
},
/** Find the active conversation's chat object (with cached messages). */
getActiveChat() {
const id = this.activeConversation?.id;
if (!id) return null;
return this.chats.find(c => c.id === id) || null;
},
findModel(id) {
if (!id) return null;
let m = App.models.find(m => m.id === id);
if (m) return m;
return App.models.find(m => m.baseModelId === id) || null;
},
settings: {
model: '',
stream: true,
showThinking: false,
systemPrompt: '',
maxTokens: 0,
temperature: 0.7,
},
};
// ── Capability Resolution ──────────────────
// Backend is source of truth. Frontend passes through as-is.
function resolveCapabilities(backendCaps, modelId) {
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
}
// ── Model Loading ──────────────────────────
async function fetchModels() {
try {
const data = await API.listEnabledModels();
App.defaultModel = data.default_model || '';
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.data || prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
}
App.models = (data.data || data.models || []).map(m => {
const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || '';
const id = isPersona
? (m.persona_id || m.id)
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: cfgId || null,
model_type: m.model_type || 'chat',
capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPersona,
personaId: m.persona_id || null,
personaHandle: m.persona_handle || null,
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
source: m.source || (isPersona ? 'persona' : 'global'),
teamName: m.persona_team_name || null,
hidden: !isPersona && App.hiddenModels.has((cfgId || '') + ':' + baseModelId),
};
});
const scopeOrder = { global: 0, team: 1, personal: 2 };
App.models.sort((a, b) => {
if (a.isPersona && !b.isPersona) return -1;
if (!a.isPersona && b.isPersona) return 1;
if (a.isPersona && b.isPersona) {
const sa = scopeOrder[a.personaScope] ?? 9;
const sb = scopeOrder[b.personaScope] ?? 9;
if (sa !== sb) return sa - sb;
}
return a.name.localeCompare(b.name);
});
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPersona).length} personas, ${App.models.filter(m => m.hidden).length} hidden)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
}
sb.ns('App', App);
sb.register('fetchModels', fetchModels);

View File

@@ -1,362 +0,0 @@
// ==========================================
// 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();
//
// Exports: window.CodeEditor
const CodeEditor = {
...createComponentRegistry('CodeEditor'),
create(opts) {
const pfx = opts.id || 'editor';
const instance = componentMixin({
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(),
_activeFile: null,
// ── 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">' + esc(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);
}
});
},
_cleanup() {
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;
},
// ── 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);
},
}, CodeEditor);
CodeEditor._register(pfx, instance);
return instance;
},
// v0.31.0: Self-mount — creates DOM + binds in one call.
mount(container, opts) {
const pfx = opts.id || 'ce';
const el = document.createElement('div');
el.className = 'code-editor';
el.id = pfx + 'CodeEditor';
el.innerHTML =
'<div class="code-editor-tabs" id="' + pfx + 'EditorTabs"></div>' +
'<div class="code-editor-content" id="' + pfx + 'EditorContent">' +
'<div class="code-editor-welcome" id="' + pfx + 'EditorWelcome">' +
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">' +
'<div style="text-align:center;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>' +
'<p style="margin:0;font-size:14px;">Open a file to start editing</p>' +
'<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="code-editor-statusbar" id="' + pfx + 'EditorStatus">' +
'<span id="' + pfx + 'EditorStatusFile"></span>' +
'<span id="' + pfx + 'EditorStatusLang"></span>' +
'<span id="' + pfx + 'EditorStatusBranch"></span>' +
'</div>';
container.appendChild(el);
const instance = this.create(opts);
instance.bind();
return instance;
},
};
// ── Helpers ─────────────────────────────────
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';
}
// ── Exports ─────────────────────────────────
sb.ns('CodeEditor', CodeEditor);

View File

@@ -575,6 +575,53 @@ const DebugLog = {
} }
}; };
// ── Inlined from ui-primitives.js (Scorched Earth III, v0.37.13) ──
function _escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function openModal(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add('active');
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
}
function showConfirm(message, opts = {}) {
return new Promise(resolve => {
const title = opts.title || 'Confirm';
const okText = opts.ok || 'Confirm';
const caText = opts.cancel || 'Cancel';
const danger = opts.danger !== false;
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-dialog">
<div class="confirm-header">${_escHtml(title)}</div>
<div class="confirm-body">${_escHtml(message)}</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${_escHtml(caText)}</button>
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${_escHtml(okText)}</button>
</div>
</div>`;
function close(result) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(result); }
function onKey(e) { if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
overlay.querySelector('[data-action="cancel"]').focus();
});
}
// ── Modal Controls ────────────────────────── // ── Modal Controls ──────────────────────────
function openDebugModal() { function openDebugModal() {

View File

@@ -1,290 +0,0 @@
// ==========================================
// 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();
//
// Exports: window.FileTree
const FileTree = {
...createComponentRegistry('FileTree'),
create(opts) {
const pfx = opts.id || 'editor';
const instance = componentMixin({
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,
// ── 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">' + esc(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">' + esc(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();
});
},
_cleanup() {
this._treeData = [];
this._expandedDirs = new Set(['']);
this._gitStatusMap.clear();
},
}, FileTree);
FileTree._register(pfx, instance);
return instance;
},
// v0.31.0: Self-mount — creates DOM + binds in one call.
mount(container, opts) {
const pfx = opts.id || 'ft';
const el = document.createElement('div');
el.className = 'file-tree';
el.id = pfx + 'FileTree';
el.innerHTML =
'<div class="file-tree-header" id="' + pfx + 'TreeHeader">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' +
'<span class="file-tree-title">Files</span>' +
'<button class="icon-btn" id="' + pfx + 'TreeNewFile" title="New file">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
'</button>' +
'</div>' +
'<div class="file-tree-items" id="' + pfx + 'TreeItems"></div>';
container.appendChild(el);
const instance = this.create(opts);
instance.bind();
return instance;
},
};
// ── Helpers ─────────────────────────────────
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] || '📄';
}
// ── Exports ─────────────────────────────────
sb.ns('FileTree', FileTree);

View File

@@ -3,6 +3,9 @@
// ========================================== // ==========================================
// v0.28.5: Composition layer over platform globals. Surface and // v0.28.5: Composition layer over platform globals. Surface and
// extension authors consume this instead of hunting through 15 JS files. // extension authors consume this instead of hunting through 15 JS files.
// v0.37.13: Scorched Earth III — removed dead wrappers (FileTree,
// CodeEditor, UserMenu, NotePanel, PaneContainer, PaneLayout),
// inlined Theme via Preact SDK module, inlined esc/modal helpers.
// //
// Usage: // Usage:
// const sw = Switchboard.init(); // const sw = Switchboard.init();
@@ -10,12 +13,12 @@
// sw.on('chat.message.*', (payload) => { ... }); // sw.on('chat.message.*', (payload) => { ... });
// sw.pipe.render(50, (ctx) => { ... }); // sw.pipe.render(50, (ctx) => { ... });
// //
// Load order: after ui-core.js + pane-container.js, before extensions.js. // Load order: after sb.js + events.js, before extensions.js.
// //
// Exports: window.Switchboard, window.sw (convenience alias) // Exports: window.Switchboard, window.sw (convenience alias)
// ========================================== // ==========================================
'use strict'; import { createTheme } from './sw/sdk/theme.js';
const Switchboard = { const Switchboard = {
@@ -190,45 +193,10 @@ const Switchboard = {
Events.emit(label, payload, opts); Events.emit(label, payload, opts);
}; };
// ── Theme ──────────────────────────────────────────── // ── Theme (v0.37.13: uses Preact SDK module directly) ──
sw.theme = { const _theme = createTheme((label, payload, opts) => Events.emit(label, payload, opts));
/** Resolved theme: 'dark' or 'light' (never 'system'). */ sw.theme = _theme;
get current() {
if (typeof Theme !== 'undefined') return Theme.resolved();
return 'dark';
},
/** User preference: 'dark', 'light', or 'system'. */
get mode() {
if (typeof Theme !== 'undefined') return Theme.get();
return 'system';
},
/** Set theme mode. */
set(mode) {
if (typeof Theme !== 'undefined') Theme.set(mode);
},
/**
* Subscribe to theme changes.
* @param {string} event — 'change'
* @param {Function} fn — receives resolved theme string
* @returns {Function} unsubscribe
*/
on(event, fn) {
if (event === 'change') {
return Events.on('theme.changed', (payload) => {
const resolved = typeof Theme !== 'undefined'
? Theme.resolved()
: 'dark';
fn(resolved);
});
}
console.warn(`[Switchboard] theme.on: unknown event '${event}'`);
return () => {};
},
};
// ── UI Primitives ──────────────────────────────────── // ── UI Primitives ────────────────────────────────────
@@ -239,16 +207,19 @@ const Switchboard = {
}; };
sw.confirm = function (message, opts) { sw.confirm = function (message, opts) {
// v0.37.13: delegate to debug.js's showConfirm if loaded, else native
if (typeof showConfirm === 'function') return showConfirm(message, opts); if (typeof showConfirm === 'function') return showConfirm(message, opts);
return Promise.resolve(window.confirm(message)); return Promise.resolve(window.confirm(message));
}; };
sw.modal = { sw.modal = {
open(contentOrId) { open(id) {
if (typeof openModal === 'function') openModal(contentOrId); const el = document.getElementById(id);
if (el) el.classList.add('active');
}, },
close(id) { close(id) {
if (typeof closeModal === 'function') closeModal(id); const el = document.getElementById(id);
if (el) el.classList.remove('active');
}, },
}; };
@@ -284,7 +255,8 @@ const Switchboard = {
} }
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : ''); btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : '');
btn.innerHTML = (item.icon || '') + '<span>' + (typeof esc === 'function' ? esc(item.label) : item.label) + '</span>'; const d = document.createElement('div'); d.textContent = item.label;
btn.innerHTML = (item.icon || '') + '<span>' + d.innerHTML + '</span>';
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
result.close(); result.close();
if (item.onClick) item.onClick(); if (item.onClick) item.onClick();
@@ -470,87 +442,8 @@ const Switchboard = {
return ChatPane.mount(container, opts || {}); return ChatPane.mount(container, opts || {});
}; };
/** // v0.37.13: sw.notes, sw.panels, sw.fileTree, sw.codeEditor,
* Mount a NotePanel instance into a container element. // sw.layout removed (Scorched Earth III — globals deleted).
* Builds DOM + creates + wires events in one call.
*
* @param {HTMLElement} container — mount target
* @param {object} opts — { projectId, onOpenGraph }
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
*/
sw.notes = function (container, opts) {
// v0.37.9: Prefer new Preact NotesPane via sw.notesPane() when SDK is loaded
if (window.sw?.notesPane) {
return window.sw.notesPane(container, opts || {});
}
console.warn('[Switchboard] sw.notes() — use sw.notesPane() instead (v0.37.9+)');
return null;
};
/**
* Access the PanelRegistry through the SDK.
* Returns a thin wrapper with open/close/toggle/isOpen/active/cycle/register.
*
* @returns {object|null} Panel control object
*/
sw.panels = function () {
if (typeof PanelRegistry === 'undefined') {
console.warn('[Switchboard] PanelRegistry not available');
return null;
}
return {
open(name) { PanelRegistry.open(name); },
close(name) { PanelRegistry.close(name); },
toggle(name) { PanelRegistry.toggle(name); },
isOpen(name) { return PanelRegistry.isOpen(name); },
active() { return PanelRegistry.active(); },
cycle() { PanelRegistry.cycle(); },
register(name, opts) { PanelRegistry.register(name, opts); },
};
};
/**
* Mount a FileTree into a container.
* @param {HTMLElement} container — mount target
* @param {object} opts — { id, workspaceId, onSelect, onDelete, onNewFile }
* @returns {object|null} FileTree instance
*/
sw.fileTree = function (container, opts) {
if (typeof FileTree === 'undefined') {
console.error('[Switchboard] FileTree not available');
return null;
}
return FileTree.mount(container, opts || {});
};
/**
* Mount a CodeEditor into a container.
* @param {HTMLElement} container — mount target
* @param {object} opts — { id, workspaceId, onSave, onActivate }
* @returns {object|null} CodeEditor instance
*/
sw.codeEditor = function (container, opts) {
if (typeof CodeEditor === 'undefined') {
console.error('[Switchboard] CodeEditor not available');
return null;
}
return CodeEditor.mount(container, opts || {});
};
/**
* Mount a PaneContainer layout into a container.
* @param {HTMLElement} container — mount target
* @param {string} layoutId — layout preset name (e.g. 'editor')
* @param {object} opts — { workspaceId }
* @returns {object|null} PaneContainer layout instance
*/
sw.layout = function (container, layoutId, opts) {
if (typeof PaneContainer === 'undefined') {
console.error('[Switchboard] PaneContainer not available');
return null;
}
return PaneContainer.mount(container, layoutId, opts || {});
};
// ── Workflow ───────────────────────────────────────── // ── Workflow ─────────────────────────────────────────
// v0.30.2: Workflow stage surface management. // v0.30.2: Workflow stage surface management.
@@ -780,77 +673,15 @@ const Switchboard = {
_runRender(ctx) { return _runChain('render', ctx); }, _runRender(ctx) { return _runChain('render', ctx); },
}; };
// ── UserMenu ───────────────────────────────────────── // v0.37.13: sw.userMenu + _hydrateUserMenu removed
// (Scorched Earth III — all surfaces use Preact UserMenu).
const _defaultMenuHandlers = {
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: () => { sb.call('openDebugModal'); },
onSignout: () => { sb.call('handleLogout'); },
};
/**
* Mount a UserMenu into a container.
* @param {HTMLElement} container — mount target
* @param {object} [opts] — { flyout: 'down'|'up', compact, user, handlers }
* @returns {object} UserMenu instance
*/
sw.userMenu = function (container, opts) {
if (typeof UserMenu === 'undefined') {
console.error('[Switchboard] UserMenu not available');
return null;
}
const _opts = Object.assign({
user: window.__USER__ || null,
handlers: _defaultMenuHandlers,
_positionFn: _positionFlyout,
}, opts || {});
const instance = UserMenu.mount(container, _opts);
if (!UserMenu.primary) UserMenu.primary = instance;
return instance;
};
/**
* Hydrate server-rendered UserMenu (non-chat surfaces).
* Falls back to mount() if server-rendered DOM not found.
*/
function _hydrateUserMenu() {
if (typeof UserMenu === 'undefined') return;
if (UserMenu.primary) return;
const surface = window.__SURFACE__ || '';
if (surface === 'chat') return;
// Extension surfaces: package owns its own menu via sw.userMenu().
// Hide the server-rendered placeholder so the package can mount fresh.
const isExtension = document.querySelector('.extension-surface') !== null;
if (isExtension) {
const placeholder = document.getElementById('userMenuWrap');
if (placeholder) placeholder.style.display = 'none';
return;
}
// Non-extension surfaces (admin, settings, notes): hydrate server-rendered DOM
const btn = document.getElementById('userMenuBtn');
if (btn) {
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
const user = window.__USER__ || {};
menu.setUser(user);
menu.showAdmin(user.role === 'admin');
menu.bind(_defaultMenuHandlers);
}
}
// ── Perform Init ───────────────────────────────────── // ── Perform Init ─────────────────────────────────────
// Theme + appearance (safe on all surfaces) // Theme (v0.37.13: uses imported Preact SDK module)
if (typeof Theme !== 'undefined') Theme.init(); _theme.init();
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance(); if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
// UserMenu hydration
_hydrateUserMenu();
// Emit ready event // Emit ready event
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } })); document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));

File diff suppressed because it is too large Load Diff

View File

@@ -1,206 +0,0 @@
// ==========================================
// 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();
//
// Exports: window.UserMenu
const UserMenu = {
...createComponentRegistry('UserMenu'),
create(opts) {
const pfx = opts.id || '';
const instance = componentMixin({
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,
// ── 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(); });
},
}, UserMenu);
UserMenu._register(pfx, instance);
return instance;
},
};
// Self-mount: creates DOM + create + bind in one call.
// opts.flyout: 'down' (default) or 'up' (sidebar-style)
// opts.compact: hide name label
// opts.user: user object for setUser()
// opts.handlers: { onSettings, onAdmin, onDebug, onSignout }
UserMenu.mount = function (container, opts) {
const _opts = opts || {};
const pfx = _opts.id || 'um' + Date.now().toString(36).slice(-4);
const flyout = _opts.flyout || 'down';
const el = document.createElement('div');
el.className = 'user-menu-wrap';
el.id = pfx + 'userMenuWrap';
el.dataset.flyout = flyout;
el.innerHTML =
'<button id="' + pfx + 'userMenuBtn" class="user-btn">' +
'<div id="' + pfx + 'userAvatar" class="user-avatar">' +
'<span id="' + pfx + 'avatarLetter">?</span>' +
'</div>' +
'<span id="' + pfx + 'userName" class="sb-label"' + (_opts.compact ? ' style="display:none"' : '') + '>User</span>' +
'</button>' +
'<div id="' + pfx + 'userFlyout" class="sw-menu-flyout" data-position="' + flyout + '">' +
'<button id="' + pfx + 'menuSettings" class="flyout-item">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' +
'<span>Settings</span>' +
'</button>' +
'<button id="' + pfx + 'menuAdmin" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
'<span>Admin</span>' +
'</button>' +
'<button id="' + pfx + 'menuTeamAdmin" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' +
'<span>Team Admin</span>' +
'</button>' +
'<button id="' + pfx + 'menuDebug" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M12 6V12L16 14"/></svg>' +
'<span>Debug Log</span>' +
'</button>' +
'<hr class="flyout-divider">' +
'<button id="' + pfx + 'menuSignout" class="flyout-item flyout-danger">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' +
'<span>Sign Out</span>' +
'</button>' +
'</div>';
container.appendChild(el);
const instance = this.create({ id: pfx });
// Wire fixed-positioning for flyouts inside overflow-clipped containers
if (_opts._positionFn) {
const _origOpen = instance.open.bind(instance);
instance.open = function () {
_opts._positionFn(instance.flyoutEl, instance.btnEl, flyout);
_origOpen();
};
}
instance.bind(_opts.handlers || {});
if (_opts.user) {
instance.setUser(_opts.user);
instance.showAdmin(_opts.user.role === 'admin');
}
return instance;
};
// ── Exports ─────────────────────────────────
sb.ns('UserMenu', UserMenu);

View File

@@ -35,13 +35,9 @@ const SHELL_FILES = [
'./css/sw-notes-pane.css', './css/sw-notes-pane.css',
'./css/sw-notes-surface.css', './css/sw-notes-surface.css',
// JS — platform globals (extension + debug deps) // JS — platform globals (extension + debug deps)
// v0.37.13: app-state, ui-primitives, user-menu, file-tree, code-editor removed
'./js/sb.js', './js/sb.js',
'./js/app-state.js',
'./js/events.js', './js/events.js',
'./js/ui-primitives.js',
'./js/user-menu.js',
'./js/file-tree.js',
'./js/code-editor.js',
'./js/switchboard-sdk.js', './js/switchboard-sdk.js',
'./js/debug.js', './js/debug.js',
'./js/repl.js', './js/repl.js',