From fcb998bff95c0aadfff206ca6226f093be85dfcd Mon Sep 17 00:00:00 2001 From: gobha Date: Sun, 22 Mar 2026 12:52:00 +0000 Subject: [PATCH] Changeset 0.37.13 (#225) Co-authored-by: gobha Co-committed-by: gobha --- CHANGELOG.md | 50 ++ VERSION | 2 +- docs/ROADMAP.md | 42 +- server/pages/templates/base.html | 33 +- server/version.go | 2 +- src/js/app-state.js | 139 ---- src/js/code-editor.js | 362 ---------- src/js/debug.js | 47 ++ src/js/file-tree.js | 290 -------- src/js/switchboard-sdk.js | 213 +----- src/js/ui-primitives.js | 1133 ------------------------------ src/js/user-menu.js | 206 ------ src/sw.js | 6 +- 13 files changed, 165 insertions(+), 2360 deletions(-) delete mode 100644 src/js/app-state.js delete mode 100644 src/js/code-editor.js delete mode 100644 src/js/file-tree.js delete mode 100644 src/js/ui-primitives.js delete mode 100644 src/js/user-menu.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 05df59f..f73d714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # 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 ### Summary diff --git a/VERSION b/VERSION index f0e92bc..fa6037a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.12 +0.37.13 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index aea58a9..47d00c9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,8 +48,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ v0.37.2 Primitives ✅ │ │ v0.37.3 SDK │ │ v0.37.4 Shell │ - │ v0.37.5–12 Surfaces ✅ │ - │ v0.37.13–18 Surfaces │ + │ v0.37.5–13 Surfaces ✅ │ + │ v0.37.14–18 Surfaces │ │ │ │ ══════╪════════════╪═════════════════╪══════ │ 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.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.13 | Workflow surfaces | Form renderer, review views | -| v0.37.14 | Pane audit | Collaborative walkthrough: each pane checked for form and function | -| v0.37.15 | Projects surface | Project views, note/channel associations | -| v0.37.16 | Workflow assignment | Workflow routing, assignment UX | +| 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: core features + theming checked | +| v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) | +| 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.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`, `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:** -Collaborative walkthrough of each pane (ChatPane, NotesPane) and surface -checking form and function. Fixes discovered during the audit are applied -in-place. No new architecture — just making everything work correctly. +Collaborative walkthrough of core features and theming. Each pane and surface +checked for form, function, and visual consistency. Fixes applied in-place. +No new architecture — making everything work correctly. - [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks - [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note - [ ] 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) **v0.37.15 — Projects Surface:** diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index d2977bf..faee5ee 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -93,34 +93,22 @@ - - {{/* api.js removed in v0.37.10. Extension surfaces using Switchboard.init() - should migrate to the Preact SDK (sw/sdk/). */}} + {{/* v0.37.13 Scorched Earth III: app-state.js, ui-primitives.js, + user-menu.js, file-tree.js, code-editor.js removed. + 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). */}} - - {{/* 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 */}} - - - - {{/* 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. */}} + {{/* v0.28.5: SDK — composition layer over globals. */}} {{/* ── Universal init: SDK boot for ALL surfaces ── */}} {{end}} - {{/* v0.28.5: UserMenu hydration moved into Switchboard.init() (switchboard-sdk.js). - The SDK handles create/bind/setUser for all non-chat surfaces automatically. */}} + {{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}} {{/* ── Debug Log Modal (all surfaces) ───── */}} -
-
-
- ${showModel ? `
` : ''} - ${showPrivate ? `
` : ''} -
- - -
- `; - - // ── Endpoint auto-fill on type change ── - const typeSelect = document.getElementById(`${pfx}_type`); - const endpointInput = document.getElementById(`${pfx}_endpoint`); - const endpoints = Providers.endpoints(); - typeSelect?.addEventListener('change', function () { - const epVal = endpointInput?.value || ''; - if (!epVal || Object.values(endpoints).includes(epVal)) { - endpointInput.value = endpoints[this.value] || ''; - } - }); - - // ── Submit / Cancel wiring ── - const submitBtn = document.getElementById(`${pfx}_submitBtn`); - const cancelBtn = document.getElementById(`${pfx}_cancelBtn`); - submitBtn?.addEventListener('click', () => { - if (opts.onSubmit) opts.onSubmit(form.getValues(), !!form._editId); - }); - cancelBtn?.addEventListener('click', () => { - if (opts.onCancel) opts.onCancel(); - }); - - const form = { - _editId: null, - container: containerEl, - - getValues() { - const v = { - name: document.getElementById(`${pfx}_name`)?.value.trim() || '', - provider: document.getElementById(`${pfx}_type`)?.value || 'openai', - endpoint: document.getElementById(`${pfx}_endpoint`)?.value.trim() || '', - api_key: document.getElementById(`${pfx}_key`)?.value || '', - }; - if (showModel) { - v.model_default = document.getElementById(`${pfx}_model`)?.value.trim() || ''; - } - if (showPrivate) { - v.is_private = document.getElementById(`${pfx}_private`)?.checked || false; - } - if (form._editId) v._editId = form._editId; - return v; - }, - - setValues(cfg) { - const el = id => document.getElementById(`${pfx}_${id}`); - if (el('name')) el('name').value = cfg.name || ''; - if (el('type')) el('type').value = cfg.provider || 'openai'; - if (el('endpoint')) el('endpoint').value = cfg.endpoint || ''; - if (el('key')) { - el('key').value = ''; - el('key').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'sk-...'; - } - if (showModel && el('model')) el('model').value = cfg.model_default || ''; - if (showPrivate && el('private')) el('private').checked = !!cfg.is_private; - }, - - clear() { - ['name', 'endpoint', 'key'].forEach(f => { - const el = document.getElementById(`${pfx}_${f}`); - if (el) el.value = ''; - }); - if (showModel) { - const m = document.getElementById(`${pfx}_model`); - if (m) m.value = ''; - } - if (showPrivate) { - const p = document.getElementById(`${pfx}_private`); - if (p) p.checked = false; - } - const typeSel = document.getElementById(`${pfx}_type`); - if (typeSel) typeSel.selectedIndex = 0; - const keyEl = document.getElementById(`${pfx}_key`); - if (keyEl) keyEl.placeholder = 'sk-...'; - form._editId = null; - if (submitBtn) submitBtn.textContent = 'Save'; - }, - - /** Switch form into edit mode for a given provider id. */ - setEditMode(id, cfg) { - form._editId = id; - form.setValues(cfg); - if (submitBtn) submitBtn.textContent = 'Update'; - }, - - /** Back to create mode. */ - setCreateMode() { - form.clear(); - }, - - /** Get the type select element (for extensions to hook into). */ - getTypeSelect() { return typeSelect; }, - }; - - return form; -} - -// ══════════════════════════════════════════════════════════════════════════════ -// §5 PROVIDER LIST -// ══════════════════════════════════════════════════════════════════════════════ - -/** - * Render a provider list into a container. - * - * @param {HTMLElement} containerEl - * @param {Object} opts - * @param {Function} opts.apiFetch - async () → provider array - * @param {Function} [opts.onEdit] - (provider) → void - * @param {Function} [opts.onDelete] - (provider) → void - * @param {Function} [opts.onRefresh] - (provider) → void (refresh models btn) - * @param {Function} [opts.onToggleActive] - (provider) → void (active/inactive toggle) - * @param {boolean} [opts.showEndpoint=false] - * @param {boolean} [opts.showPrivate=false] - * @param {boolean} [opts.showActive=false] - * @param {boolean} [opts.showRefresh=false] - * @param {string} [opts.emptyMsg] - * @returns {{ refresh, getCache }} - */ -function renderProviderList(containerEl, opts = {}) { - const cache = {}; - - async function refresh(quiet) { - if (!quiet) containerEl.innerHTML = '
Loading...
'; - try { - const raw = await opts.apiFetch(); - // Normalize — APIs return different shapes - const list = Array.isArray(raw) ? raw : (raw.configs || raw.providers || raw.data || []); - - // Clear and rebuild cache - Object.keys(cache).forEach(k => delete cache[k]); - list.forEach(c => { cache[c.id] = c; }); - - if (list.length === 0) { - containerEl.innerHTML = `
${esc(opts.emptyMsg || 'No providers configured.')}
`; - return; - } - - containerEl.innerHTML = list.map(c => { - const meta = []; - meta.push(esc(c.provider || '')); - if (c.has_key != null) meta.push(c.has_key ? '🔑' : '⚠️ no key'); - if (opts.showEndpoint && c.endpoint) meta.push(esc(c.endpoint)); - if (!opts.showEndpoint && c.model_default) meta.push(esc(c.model_default)); - - const badges = []; - if (opts.showPrivate && c.is_private) - badges.push('private'); - if (opts.showActive) { - const active = c.is_active !== false; - badges.push(`${active ? 'active' : 'inactive'}`); - } - - const actions = []; - if (opts.onRefresh) - actions.push(``); - if (opts.onEdit) - actions.push(``); - if (opts.onToggleActive) { - const active = c.is_active !== false; - actions.push(``); - } - if (opts.onDelete) - actions.push(``); - - return `
-
- ${esc(c.name)} - ${meta.join(' · ')} - ${badges.join(' ')} -
-
${actions.join('')}
-
`; - }).join(''); - - } catch (e) { - containerEl.innerHTML = `
${esc(e.message)}
`; - } - } - - // ── Delegate clicks ── - containerEl.addEventListener('click', (e) => { - const btn = e.target.closest('[data-action]'); - if (!btn) return; - const id = btn.dataset.id; - const provider = cache[id]; - if (!provider) return; - switch (btn.dataset.action) { - case 'edit': opts.onEdit?.(provider); break; - case 'delete': opts.onDelete?.(provider); break; - case 'refresh': opts.onRefresh?.(provider); break; - case 'toggle': opts.onToggleActive?.(provider); break; - } - }); - - return { refresh, getCache: () => ({ ...cache }) }; -} - -// ══════════════════════════════════════════════════════════════════════════════ -// §6 ROLE CONFIG -// ══════════════════════════════════════════════════════════════════════════════ - -/** - * Render role configuration (provider + model dropdowns per role). - * - * @param {HTMLElement} containerEl - * @param {Object} opts - * @param {string} opts.scope - 'admin' | 'personal' - * @param {Function} opts.fetchProviders - async () → provider array - * @param {Function} opts.fetchModels - async () → model array - * @param {Function} opts.fetchRoles - async () → { utility: {primary, fallback}, ... } - * @param {Function} opts.onSave - async (roleId, config) → void - * @param {Function} [opts.onClear] - async (roleId) → void (personal only) - * @param {Function} [opts.onTest] - async (roleId) → result (admin only) - * @param {boolean} [opts.showFallback=false] - * @param {string} [opts.modelIdField='model_id'] - field name for model ID on model objects - * @param {string} [opts.modelNameField='display_name'] - field for model display name - * @param {string} [opts.providerIdField='provider_config_id'] - field on models for their provider - * @param {string} [opts.noneLabel='— none —'] - * @returns {{ refresh }} - */ -function renderRoleConfig(containerEl, opts = {}) { - const scope = opts.scope || 'admin'; - const showFallback = opts.showFallback === true; - const midField = opts.modelIdField || 'model_id'; - const mnameField = opts.modelNameField || 'display_name'; - const pidField = opts.providerIdField || 'provider_config_id'; - const noneLabel = opts.noneLabel || '— none —'; - - // Cached data for provider-change handler - let _providers = []; - let _models = []; - let _roles = {}; - - function filterModels(providerId, roleId) { - const typeFilter = Roles.typeFor(roleId); - return _models.filter(m => { - if (m[pidField] !== providerId) return false; - const mt = m.model_type || ''; - // Embedding role: include models typed as 'embedding' OR untyped. - // Many providers don't report type for embedding models. - if (typeFilter === 'embedding') return !mt || mt === 'embedding'; - // Other roles: default untyped to 'chat' (existing behavior). - return (mt || 'chat') === typeFilter; - }); - } - - function onProviderChange(roleId, slot) { - const provSel = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`); - const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`); - const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`); - const toggleBtn = containerEl.querySelector(`[data-role-model-toggle="${roleId}-${slot}"]`); - if (!provSel || !modelSel) return; - - modelSel.innerHTML = ''; - const provId = provSel.value; - if (!provId) return; - - const models = filterModels(provId, roleId); - models.forEach(m => { - const opt = document.createElement('option'); - opt.value = m[midField]; - opt.textContent = m[mnameField] || m[midField]; - modelSel.appendChild(opt); - }); - - // Auto-switch to manual entry if dropdown has no models. - if (models.length === 0 && modelInput && toggleBtn) { - modelSel.style.display = 'none'; - modelInput.style.display = ''; - toggleBtn.title = 'Switch to dropdown'; - toggleBtn.textContent = '▾'; - } - } - - function toggleManualEntry(roleId, slot) { - const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`); - const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`); - const toggleBtn = containerEl.querySelector(`[data-role-model-toggle="${roleId}-${slot}"]`); - if (!modelSel || !modelInput || !toggleBtn) return; - - const showingDropdown = modelSel.style.display !== 'none'; - if (showingDropdown) { - // Switch to manual: copy dropdown value into input. - modelInput.value = modelSel.value || modelInput.value; - modelSel.style.display = 'none'; - modelInput.style.display = ''; - toggleBtn.title = 'Switch to dropdown'; - toggleBtn.textContent = '▾'; - } else { - // Switch to dropdown: try to select the typed value. - modelSel.style.display = ''; - modelInput.style.display = 'none'; - toggleBtn.title = 'Type model ID manually'; - toggleBtn.textContent = '✎'; - if (modelInput.value) { - modelSel.value = modelInput.value; - } - } - } - - function getBinding(roleId, slot) { - const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value; - const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`); - const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`); - // Use manual input if it's visible, otherwise use dropdown. - const model = (modelInput && modelInput.style.display !== 'none') - ? modelInput.value.trim() - : (modelSel?.value || ''); - return (prov && model) ? { provider_config_id: prov, model_id: model } : null; - } - - async function handleSave(roleId) { - const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`); - try { - const config = { primary: getBinding(roleId, 'primary') }; - if (showFallback) config.fallback = getBinding(roleId, 'fallback'); - await opts.onSave(roleId, config); - if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--success)'; } - // Reload after short delay so dropdowns reflect saved state - setTimeout(() => refresh(), 500); - } catch (e) { - if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; } - } - } - - async function handleTest(roleId) { - if (!opts.onTest) return; - const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`); - if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-3)'; } - try { - const result = await opts.onTest(roleId); - if (statusEl) { - const fb = result.used_fallback ? ' (fallback)' : ''; - statusEl.textContent = `✓ ${result.model}${fb}`; - statusEl.style.color = 'var(--success)'; - } - } catch (e) { - if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; } - } - } - - async function handleClear(roleId) { - if (!opts.onClear) return; - try { - await opts.onClear(roleId); - refresh(); - } catch (e) { UI.toast(e.message, 'error'); } - } - - function renderSlot(roleId, slotName, cfg, slotLabel) { - const binding = cfg?.[slotName]; - const selectedProv = binding?.provider_config_id || ''; - const selectedModel = binding?.model_id || ''; - - const provOptions = _providers.map(p => - `` - ).join(''); - - const dropdownModels = selectedProv ? filterModels(selectedProv, roleId) : []; - const modelOptions = dropdownModels.map(m => - `` - ).join(''); - - // Show manual entry if: saved model ID not in dropdown, or dropdown is empty with a provider selected. - const savedNotInDropdown = selectedModel && selectedProv && - !dropdownModels.some(m => m[midField] === selectedModel); - const showManual = savedNotInDropdown || (selectedProv && dropdownModels.length === 0); - - return ` -
- - - - - -
`; - } - - async function refresh() { - containerEl.innerHTML = '
Loading...
'; - try { - const [provRaw, modelRaw, roleData] = await Promise.all([ - opts.fetchProviders(), - opts.fetchModels(), - opts.fetchRoles(), - ]); - - // Normalize - _providers = Array.isArray(provRaw) ? provRaw : (provRaw.configs || provRaw.providers || provRaw.data || []); - _models = Array.isArray(modelRaw) ? modelRaw : (modelRaw.models || modelRaw.data || []); - _roles = roleData || {}; - - const roles = Roles.all(); - containerEl.innerHTML = roles.map(role => { - const cfg = _roles[role.id] || { primary: null, fallback: null }; - const hasClear = scope === 'personal' && opts.onClear && cfg.primary; - return ` -
- <${scope === 'admin' ? 'h3' : 'h4'} style="font-size:${scope === 'admin' ? 14 : 13}px;margin:0 0 ${scope === 'admin' ? 8 : 6}px;text-transform:capitalize"> - ${esc(role.label || role.id)} - ${scope === 'personal' && role.hint ? `(${esc(role.hint)})` : ''} - - ${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')} - ${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''} -
- - ${opts.onTest ? `` : ''} - ${hasClear ? `` : ''} - -
-
`; - }).join(''); - - } catch (e) { - containerEl.innerHTML = `
${esc(e.message)}
`; - } - } - - // ── Delegate events ── - containerEl.addEventListener('change', (e) => { - const provSel = e.target.closest('[data-role-provider]'); - if (!provSel) return; - const [roleId, slot] = provSel.dataset.roleProvider.split('-'); - onProviderChange(roleId, slot); - }); - - containerEl.addEventListener('click', (e) => { - const saveBtn = e.target.closest('[data-role-save]'); - if (saveBtn) { handleSave(saveBtn.dataset.roleSave); return; } - const testBtn = e.target.closest('[data-role-test]'); - if (testBtn) { handleTest(testBtn.dataset.roleTest); return; } - const clearBtn = e.target.closest('[data-role-clear]'); - if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; } - const toggleBtn = e.target.closest('[data-role-model-toggle]'); - if (toggleBtn) { - const [roleId, slot] = toggleBtn.dataset.roleModelToggle.split('-'); - toggleManualEntry(roleId, slot); - return; - } - }); - - return { refresh }; -} - -// ══════════════════════════════════════════════════════════════════════════════ -// §7 USAGE DASHBOARD -// ══════════════════════════════════════════════════════════════════════════════ - -/** - * Render usage stats + table into a container. - * - * @param {HTMLElement} containerEl - * @param {Object} opts - * @param {Function} opts.apiFetch - async ({period, group_by}) → { totals, results } - * @param {string} [opts.periodElId] - ID of period - * @param {boolean} [opts.compact=false] - Use compact sizing (user/team) - * @param {boolean} [opts.showUserColumn=false] - Include "User" in group_by options - * @param {Array} [opts.extraColumns] - [{header, render: (row) → html}] - * @returns {{ refresh }} - */ -function renderUsageDashboard(containerEl, opts = {}) { - const compact = opts.compact === true; - const cardStyle = compact ? ' style="padding:8px"' : ''; - const valueStyle = compact ? ' style="font-size:16px"' : ''; - const labelStyle = compact ? ' style="font-size:10px"' : ''; - const gridStyle = compact ? ' style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px"' : ''; - const tableStyle = compact ? ' style="font-size:12px"' : ' style="margin-top:12px"'; - - // We render into two sub-zones - let totalsEl, resultsEl; - - function ensureStructure() { - if (!containerEl.querySelector('[data-usage-totals]')) { - containerEl.innerHTML = ` -
-
- `; - } - totalsEl = containerEl.querySelector('[data-usage-totals]'); - resultsEl = containerEl.querySelector('[data-usage-results]'); - } - - async function refresh() { - ensureStructure(); - const period = opts.periodElId ? (document.getElementById(opts.periodElId)?.value || '30d') : '30d'; - const groupBy = opts.groupByElId ? (document.getElementById(opts.groupByElId)?.value || 'model') : 'model'; - - totalsEl.innerHTML = compact - ? '
Loading...
' - : '
Loading...
'; - resultsEl.innerHTML = ''; - - try { - const data = await opts.apiFetch({ period, group_by: groupBy }); - const t = data.totals || {}; - - if (!t.requests && compact) { - totalsEl.innerHTML = '
No usage recorded in this period
'; - return; - } - - totalsEl.innerHTML = ` -
-
${(t.requests || 0).toLocaleString()}
Requests
-
${(t.input_tokens || 0).toLocaleString()}
Input${compact ? '' : ' Tokens'}
-
${(t.output_tokens || 0).toLocaleString()}
Output${compact ? '' : ' Tokens'}
-
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
-
`; - - const results = data.results || []; - if (results.length === 0) { - resultsEl.innerHTML = '
No usage data for this period
'; - return; - } - - // Column header depends on groupBy - let firstCol; - if (groupBy === 'day') firstCol = 'Date'; - else if (groupBy === 'user') firstCol = 'User'; - else firstCol = 'Model'; - - const extraHeaders = (opts.extraColumns || []).map(c => - `${esc(c.header)}` - ).join(''); - - resultsEl.innerHTML = ` - - - - - - - - ${extraHeaders} - - ${results.map(r => { - const extraCells = (opts.extraColumns || []).map(c => - `` - ).join(''); - return ` - - - - - - ${extraCells} - `; - }).join('')} -
${firstCol}${compact ? 'Reqs' : 'Requests'}InputOutputCost
${c.render(r)}
${esc(r.label || r.group_key)}${(r.requests || 0).toLocaleString()}${(r.input_tokens || 0).toLocaleString()}${(r.output_tokens || 0).toLocaleString()}$${(r.total_cost || 0).toFixed(4)}
`; - } catch (e) { - totalsEl.innerHTML = `
${esc(e.message)}
`; - } - } - - return { refresh }; -} - -// ══════════════════════════════════════════════════════════════════════════════ -// §8 CONFIRM DIALOG -// ══════════════════════════════════════════════════════════════════════════════ - -/** - * Custom confirm dialog replacing native confirm(). - * Returns a Promise. - * - * @param {string} message - * @param {Object} [opts] - * @param {string} [opts.title='Confirm'] - * @param {string} [opts.ok='Confirm'] - * @param {string} [opts.cancel='Cancel'] - * @param {boolean} [opts.danger=false] - Styles the confirm button as destructive - * @returns {Promise} - */ -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; // default true for destructive actions - - const overlay = document.createElement('div'); - overlay.className = 'confirm-overlay'; - overlay.innerHTML = ` -
-
${esc(title)}
-
${esc(message)}
- -
`; - - 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(); - }); -} - -// §8b PROMPT DIALOG -// ══════════════════════════════════════════════════════════════════════════════ - -/** - * Custom prompt dialog replacing native prompt(). - * Returns a Promise — null means cancelled or empty. - * - * @param {Object} opts - * @param {string} [opts.title='Enter value'] - * @param {string} [opts.label] - Optional helper text below title - * @param {string} [opts.value=''] - Pre-filled value - * @param {string} [opts.placeholder=''] - * @param {string} [opts.ok='OK'] - * @param {string} [opts.cancel='Cancel'] - * @param {string} [opts.inputType='text'] - * @returns {Promise} - */ -function showPrompt(opts = {}) { - return new Promise(resolve => { - const title = opts.title || 'Enter value'; - const label = opts.label || ''; - const defVal = opts.value || ''; - const ph = opts.placeholder || ''; - const okText = opts.ok || 'OK'; - const caText = opts.cancel || 'Cancel'; - const iType = opts.inputType || 'text'; - - const overlay = document.createElement('div'); - overlay.className = 'confirm-overlay'; - overlay.innerHTML = ` -
-
${esc(title)}
- ${label ? `
${esc(label)}
` : ''} -
- -
- -
`; - - const input = overlay.querySelector('.prompt-input'); - - function close(result) { - overlay.remove(); - document.removeEventListener('keydown', onKey); - resolve(result); - } - - function onKey(e) { - if (e.key === 'Escape') { close(null); } - if (e.key === 'Enter') { const v = input.value.trim(); close(v || null); } - } - - overlay.querySelector('[data-action="ok"]').addEventListener('click', () => { - close(input.value.trim() || null); - }); - overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(null)); - overlay.addEventListener('click', e => { if (e.target === overlay) close(null); }); - document.addEventListener('keydown', onKey); - - document.body.appendChild(overlay); - requestAnimationFrame(() => { input.focus(); if (defVal) input.select(); }); - }); -} - -// ── Modal open/close ──────────────────────── -// Generic modal show/hide used by settings, admin, debug, team-admin modals. -// Moved here from app.js so all surfaces (not just chat) can use modals. - -function openModal(id) { - const el = document.getElementById(id); - if (!el) return; - el.classList.add('active'); - // Defer overflow check — browser needs a frame to lay out the modal - requestAnimationFrame(() => { - el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow); - }); -} - -function closeModal(id) { - const el = document.getElementById(id); - if (el) el.classList.remove('active'); -} - -// Toggle .is-scrollable on tab bars that overflow horizontally -// and inject arrow navigation buttons when needed. -function checkTabsOverflow(tabs) { - if (!tabs) return; - const scrollable = tabs.scrollWidth > tabs.clientWidth + 1; - - if (!scrollable) { - const wrap = tabs.parentElement; - if (wrap && wrap.classList.contains('modal-tabs-wrap')) { - wrap.parentElement.insertBefore(tabs, wrap); - wrap.remove(); - } - return; - } - - if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) { - const wrap = document.createElement('div'); - wrap.className = 'modal-tabs-wrap'; - tabs.parentElement.insertBefore(wrap, tabs); - wrap.appendChild(tabs); - - const left = document.createElement('button'); - left.className = 'tab-arrow tab-arrow-left'; - left.innerHTML = '‹'; - left.addEventListener('click', () => { tabs.scrollLeft -= 120; }); - - const right = document.createElement('button'); - right.className = 'tab-arrow tab-arrow-right'; - right.innerHTML = '›'; - right.addEventListener('click', () => { tabs.scrollLeft += 120; }); - - wrap.appendChild(left); - wrap.appendChild(right); - - tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true }); - } - - updateTabArrows(tabs); -} - -function updateTabArrows(tabs) { - const wrap = tabs.parentElement; - if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return; - - const left = wrap.querySelector('.tab-arrow-left'); - const right = wrap.querySelector('.tab-arrow-right'); - if (!left || !right) return; - - const atStart = tabs.scrollLeft <= 2; - const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2; - - left.classList.toggle('visible', !atStart); - right.classList.toggle('visible', !atEnd); -} - -// ══════════════════════════════════════════════════════════════════════════════ -// §9 THEME -// ══════════════════════════════════════════════════════════════════════════════ - -const Theme = { - _key: 'switchboard_theme', - _mqListener: null, - - init() { - this.set(localStorage.getItem(this._key) || 'system'); - }, - - set(mode) { - localStorage.setItem(this._key, mode); - // Remove any prior system-preference listener - if (this._mqListener) { - window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._mqListener); - this._mqListener = null; - } - if (mode === 'system') { - // Resolve current OS preference and apply it - const resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - document.documentElement.setAttribute('data-theme', resolved); - // Listen for OS preference changes while in system mode - this._mqListener = (e) => { - // Only react if still in system mode - if (this.get() === 'system') { - document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light'); - } - }; - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this._mqListener); - } else { - document.documentElement.setAttribute('data-theme', mode); - } - }, - - get() { - return localStorage.getItem(this._key) || 'system'; - }, - - resolved() { - const mode = this.get(); - if (mode !== 'system') return mode; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }, - - renderToggle(containerId) { - const el = document.getElementById(containerId); - if (!el) return; - const current = this.get(); - const options = [ - { id: 'light', label: 'Light', icon: '☀' }, - { id: 'dark', label: 'Dark', icon: '☾' }, - { id: 'system',label: 'System',icon: '⊞' }, - ]; - el.innerHTML = '
' + options.map(o => - `` - ).join('') + '
'; - el.querySelectorAll('.toggle-btn').forEach(btn => { - btn.addEventListener('click', () => { - this.set(btn.dataset.theme); - this.renderToggle(containerId); - }); - }); - }, -}; - -// ══════════════════════════════════════════════════════════════════════════════ -// §10 AVATAR UPLOAD -// ══════════════════════════════════════════════════════════════════════════════ - -/** - * Mount an avatar upload component into a container. - * - * @param {HTMLElement} containerEl - * @param {Object} opts - * @param {string} [opts.current] - Current avatar URL - * @param {number} [opts.size=80] - Avatar size in px - * @param {Function} [opts.onUpload] - Called with (file, dataURL) - * @returns {{ setImage }} - */ -function mountAvatarUpload(containerEl, opts = {}) { - const size = opts.size || 80; - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/*'; - input.style.display = 'none'; - - containerEl.className = 'avatar-upload'; - containerEl.style.width = size + 'px'; - containerEl.style.height = size + 'px'; - - function render(src) { - if (src) containerEl.innerHTML = `Avatar`; - else containerEl.innerHTML = '
Upload
Photo
'; - containerEl.appendChild(input); - } - - render(opts.current || null); - containerEl.addEventListener('click', () => input.click()); - input.addEventListener('change', () => { - const file = input.files[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => { - render(ev.target.result); - if (opts.onUpload) opts.onUpload(file, ev.target.result); - }; - reader.readAsDataURL(file); - }); - - return { setImage: render }; -} - -// ── Exports ───────────────────────────────── -sb.register('esc', esc); -sb.register('createComponentRegistry', createComponentRegistry); -sb.register('componentMixin', componentMixin); -sb.ns('Providers', Providers); -sb.ns('Roles', Roles); -sb.register('renderCapBadges', renderCapBadges); -sb.register('renderProviderForm', renderProviderForm); -sb.register('renderProviderList', renderProviderList); -sb.register('renderRoleConfig', renderRoleConfig); -sb.register('renderUsageDashboard', renderUsageDashboard); -sb.register('showConfirm', showConfirm); -sb.register('showPrompt', showPrompt); -sb.register('openModal', openModal); -sb.register('closeModal', closeModal); -sb.register('checkTabsOverflow', checkTabsOverflow); -sb.ns('Theme', Theme); -sb.register('mountAvatarUpload', mountAvatarUpload); diff --git a/src/js/user-menu.js b/src/js/user-menu.js deleted file mode 100644 index 631e815..0000000 --- a/src/js/user-menu.js +++ /dev/null @@ -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 = - '' + - '
' + - '' + - '' + - '' + - '' + - '
' + - '' + - '
'; - 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); diff --git a/src/sw.js b/src/sw.js index 098d71a..8c1bff3 100644 --- a/src/sw.js +++ b/src/sw.js @@ -35,13 +35,9 @@ const SHELL_FILES = [ './css/sw-notes-pane.css', './css/sw-notes-surface.css', // JS — platform globals (extension + debug deps) + // v0.37.13: app-state, ui-primitives, user-menu, file-tree, code-editor removed './js/sb.js', - './js/app-state.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/debug.js', './js/repl.js',