Changeset 0.28.3.1 (#188)

This commit is contained in:
2026-03-14 14:43:55 +00:00
parent f68a58b073
commit 205a770c74
46 changed files with 720 additions and 406 deletions

View File

@@ -1,5 +1,79 @@
# Changelog # Changelog
## [0.28.3.1] — 2026-03-14
### Summary
Frontend decomposition Phases 2 + 3. Phase 2: onclick→data-action
delegation migration (143 → 5 unconvertible). Phase 3: `sb.js` action
registry replacing all `window.*` exports with centralized
`sb.register()`/`sb.ns()` dispatch.
### Added
#### Action Registry — `sb.js` (Phase 3)
- New file `src/js/sb.js`, loaded first in `base.html`.
- `sb.register(name, fn)` — register standalone action, dual-writes to
`window[name]` for backward compat until ES modules land.
- `sb.ns(name, obj)` — register namespace object (API, UI, App, etc.),
methods accessible via dot notation: `sb.resolve('UI.copyMessage')`.
- `sb.resolve(name)` — centralized action resolution for flat names
and dot-notation namespace methods. Falls back to `window[name]`.
- `sb.call(name, ...args)` — template bridge for Go onclick handlers.
- `sb.callEvent(event, name, ...args)` — event passthrough variant.
- `sb.list()` — introspection: list all registered actions/namespaces.
- `sb.has(name)` — check if action is registered.
### Changed
#### Phase 2 — onclick→data-action Delegation
- 138 of 143 dynamic inline onclick handlers converted to `data-action`
+ `data-args` attributes with centralized event delegation.
- `_uiDispatch(container)`: idempotent delegated click handler wired on
`document.body`. Supports `data-args` (JSON), `data-pass-event`
(context menu positioning), `data-pass-el` (checkbox state).
- `_ctxDispatch(container)`: idempotent delegated handler for context
menus and panel buttons (auto-dismisses menu after action).
- Wrapper functions for compound expressions: `_toggleArchivedProjects`,
`_submitEditFromDOM`, `_copyCodeBlock`, `_openNoteFromTool`,
`_toggleNoteSelectCb`, `_noteItemClick`.
- 5 unconvertible onclick remain (self-referencing DOM removal,
computed onclick from variable).
**Files converted (onclick):** `projects-ui.js` (26→0), `ui-core.js`
(25→1), `ui-admin.js` (28→1), `ui-format.js` (10→0),
`notifications.js` (8→0), `notes.js` (8→0), `channel-models.js` (5→0),
`ui-settings.js` (8→0), `files.js` (3→0), `admin-handlers.js` (2→0),
`app.js` (4→1), `workflow-queue.js` (6→0), `workflow-admin.js` (5→0),
`task-settings.js` (1→0), `task-sidebar.js` (3→1).
#### Phase 3 — Action Registry Migration
- 234 top-level `window.X = X` exports across 35 files converted to
`sb.register('X', X)` or `sb.ns('X', X)`. 28 namespace objects,
206 standalone functions.
- 14 in-body `window.X = function` assignments across 6 files
(`settings-handlers.js`, `task-admin.js`, `task-settings.js`,
`task-sidebar.js`, `workflow-admin.js`, `workflow-queue.js`)
converted to `sb.register('X', function)` or local const + `sb.ns()`.
- `_uiDispatch` and `_ctxDispatch` dispatch updated to use
`sb.resolve()` instead of `window[]` walking.
- `base.html` loads `sb.js` before `app-state.js`.
- `sb.register/sb.ns` dual-writes to `window.*` for backward
compatibility — this side-effect is removed when files convert to
ES module imports (Phase 4).
### Metrics
| | v0.28.3 | v0.28.3.1 |
|--|---------|-----------|
| Dynamic inline onclick | 143 | 5 (unconvertible) |
| data-action delegated | 0 | 163 |
| `window.*` exports | 248 | 1 (debug fetch patch) |
| `sb.register` calls | 0 | 220 |
| `sb.ns` calls | 0 | 30 |
| Action resolution | `window[]` | `sb.resolve()` centralized |
## [0.28.3] — 2026-03-13 ## [0.28.3] — 2026-03-13
### Summary ### Summary

View File

@@ -1 +1 @@
0.28.3 0.28.3.1

View File

@@ -174,9 +174,13 @@ decomposition groundwork.
`api.js` — uses `API.accessToken` instead `api.js` — uses `API.accessToken` instead
- [ ] ICD runner gains test tiers: `crud`, `envelope`, `security`, `sdk` - [ ] ICD runner gains test tiers: `crud`, `envelope`, `security`, `sdk`
- [ ] Runner coverage target: 100% of ICD-documented endpoints have at least one test - [ ] Runner coverage target: 100% of ICD-documented endpoints have at least one test
- [ ] Phase 2: onclick → addEventListener migration (143 dynamic + ~50 template handlers) - [x] Phase 2: onclick → addEventListener migration (143 dynamic → 5 unconvertible,
- [ ] Phase 3: ES module conversion 163 data-action delegated via `_uiDispatch` on document.body)
- [ ] Phase 4: Template handler shim - [x] Phase 3: Action registry (`sb.js`) — 248 `window.*` exports replaced with
`sb.register()`/`sb.ns()`. `sb.resolve()` centralized dispatch.
`sb.call()` template bridge ready for Go template migration.
- [ ] Phase 3b: Go template onclick → `sb.call()` migration (~50 handlers)
- [ ] Phase 4: ES module conversion (remove IIFE wrappers, add import/export)
### v0.28.4 — Security Tier (ICD Runner Red Team) ### v0.28.4 — Security Tier (ICD Runner Red Team)
New `security` tier in ICD test runner. Multi-user fixtures already exist. New `security` tier in ICD test runner. Multi-user fixtures already exist.

View File

@@ -91,6 +91,7 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}} {{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
</script> </script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script> <script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/api.js?v={{.Version}}"></script> <script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/api.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}"> <script nonce="{{.CSPNonce}}">

View File

@@ -785,8 +785,8 @@ function editAdminExtension(id) {
${scriptEditor} ${scriptEditor}
</div> </div>
<div style="display:flex;gap:8px;justify-content:flex-end"> <div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-small" onclick="_closeExtEditForm('${id}')">Cancel</button> <button class="btn-small" data-action="_closeExtEditForm" data-args='${JSON.stringify([id])}'">Cancel</button>
<button class="btn-small btn-primary" onclick="saveAdminExtension('${id}')">Save Changes</button> <button class="btn-small btn-primary" data-action="saveAdminExtension" data-args='${JSON.stringify([id])}'">Save Changes</button>
</div> </div>
</div> </div>
`; `;
@@ -888,34 +888,34 @@ async function saveAdminExtension(id) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window._adminPersonaForm = _adminPersonaForm; sb.register('_adminPersonaForm', _adminPersonaForm);
window._initAdminListeners = _initAdminListeners; sb.register('_initAdminListeners', _initAdminListeners);
window.adminResetUserPassword = adminResetUserPassword; sb.register('adminResetUserPassword', adminResetUserPassword);
window.bulkSetUserModelVisibility = bulkSetUserModelVisibility; sb.register('bulkSetUserModelVisibility', bulkSetUserModelVisibility);
window.cycleModelVisibility = cycleModelVisibility; sb.register('cycleModelVisibility', cycleModelVisibility);
window.deleteAdminExtension = deleteAdminExtension; sb.register('deleteAdminExtension', deleteAdminExtension);
window.deleteAdminPersona = deleteAdminPersona; sb.register('deleteAdminPersona', deleteAdminPersona);
window.deleteGroup = deleteGroup; sb.register('deleteGroup', deleteGroup);
window.deleteTeam = deleteTeam; sb.register('deleteTeam', deleteTeam);
window.deleteUser = deleteUser; sb.register('deleteUser', deleteUser);
window.deleteUserPersona = deleteUserPersona; sb.register('deleteUserPersona', deleteUserPersona);
window.editAdminExtension = editAdminExtension; sb.register('editAdminExtension', editAdminExtension);
window.editAdminPersona = editAdminPersona; sb.register('editAdminPersona', editAdminPersona);
window.ensureAdminPersonaForm = ensureAdminPersonaForm; sb.register('ensureAdminPersonaForm', ensureAdminPersonaForm);
window.removeGroupMember = removeGroupMember; sb.register('removeGroupMember', removeGroupMember);
window.removeTeamMember = removeTeamMember; sb.register('removeTeamMember', removeTeamMember);
window.saveGrant = saveGrant; sb.register('saveGrant', saveGrant);
window.settingsDeleteTeamPersona = settingsDeleteTeamPersona; sb.register('settingsDeleteTeamPersona', settingsDeleteTeamPersona);
window.settingsRemoveTeamMember = settingsRemoveTeamMember; sb.register('settingsRemoveTeamMember', settingsRemoveTeamMember);
window.settingsUpdateTeamMember = settingsUpdateTeamMember; sb.register('settingsUpdateTeamMember', settingsUpdateTeamMember);
window.showApproveForm = showApproveForm; sb.register('showApproveForm', showApproveForm);
window.toggleAdminExtension = toggleAdminExtension; sb.register('toggleAdminExtension', toggleAdminExtension);
window.toggleAdminPersona = toggleAdminPersona; sb.register('toggleAdminPersona', toggleAdminPersona);
window.toggleTeamActive = toggleTeamActive; sb.register('toggleTeamActive', toggleTeamActive);
window.toggleUserActive = toggleUserActive; sb.register('toggleUserActive', toggleUserActive);
window.toggleUserModelVisibility = toggleUserModelVisibility; sb.register('toggleUserModelVisibility', toggleUserModelVisibility);
window.toggleUserRole = toggleUserRole; sb.register('toggleUserRole', toggleUserRole);
window.updateTeamMember = updateTeamMember; sb.register('updateTeamMember', updateTeamMember);
window._closeExtEditForm = _closeExtEditForm; sb.register('_closeExtEditForm', _closeExtEditForm);
window.saveAdminExtension = saveAdminExtension; sb.register('saveAdminExtension', saveAdminExtension);
})(); })();

View File

@@ -198,5 +198,5 @@ async function _loadSurfaceList() {
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window._loadAdminSurfaces = _loadAdminSurfaces; sb.register('_loadAdminSurfaces', _loadAdminSurfaces);
})(); })();

View File

@@ -993,5 +993,5 @@ const API = {
} }
}; };
window.API = API; sb.ns('API', API);
})(); })();

View File

@@ -137,6 +137,6 @@ async function fetchModels() {
} catch (e) { console.warn('Model fetch failed:', e.message); } } catch (e) { console.warn('Model fetch failed:', e.message); }
} }
window.App = App; sb.ns('App', App);
window.fetchModels = fetchModels; sb.register('fetchModels', fetchModels);
})(); })();

View File

@@ -30,17 +30,17 @@ async function init() {
`<strong>Network proxy blocked this request</strong><br>` + `<strong>Network proxy blocked this request</strong><br>` +
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` + `Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` + `<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`; `<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') { } else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
splashErr.innerHTML = splashErr.innerHTML =
`<strong>Connection timed out</strong><br>` + `<strong>Connection timed out</strong><br>` +
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` + `<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`; `<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
} else { } else {
splashErr.innerHTML = splashErr.innerHTML =
`<strong>Cannot reach server</strong><br>` + `<strong>Cannot reach server</strong><br>` +
`<span class="splash-error-hint">${API._esc(e.message)}. ` + `<span class="splash-error-hint">${API._esc(e.message)}. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`; `<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
} }
showSplash(null); showSplash(null);
return; return;
@@ -582,10 +582,10 @@ async function initBanners() {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.handleLogin = handleLogin; sb.register('handleLogin', handleLogin);
window.handleRegister = handleRegister; sb.register('handleRegister', handleRegister);
window.handleLogout = handleLogout; sb.register('handleLogout', handleLogout);
window.switchAuthTab = switchAuthTab; sb.register('switchAuthTab', switchAuthTab);
window.startApp = startApp; sb.register('startApp', startApp);
window.initBanners = initBanners; sb.register('initBanners', initBanners);
})(); })();

View File

@@ -71,11 +71,11 @@ const ChannelModels = {
const html = this._roster.map(m => ` const html = this._roster.map(m => `
<span class="ch-model-pill${m.is_default ? ' ch-model-default' : ''}" <span class="ch-model-pill${m.is_default ? ' ch-model-default' : ''}"
data-model-id="${esc(m.id)}" title="${esc(m.model_id)}"> data-model-id="${esc(m.id)}" title="${esc(m.model_id)}">
<span class="ch-model-pill-name" onclick="ChannelModels.setDefault('${esc(m.id)}')">${esc(m.display_name)}</span> <span class="ch-model-pill-name" data-action="ChannelModels.setDefault" data-args='${JSON.stringify([esc(m.id)])}'">${esc(m.display_name)}</span>
<button class="ch-model-pill-remove" onclick="ChannelModels.remove('${esc(m.id)}')" title="Remove model">✕</button> <button class="ch-model-pill-remove" data-action="ChannelModels.remove" data-args='${JSON.stringify([esc(m.id)])}'" title="Remove model">✕</button>
</span> </span>
`).join('') + ` `).join('') + `
<button class="ch-model-add-btn" onclick="ChannelModels.showAddDialog()" title="Add model to channel">+ Add</button> <button class="ch-model-add-btn" data-action="ChannelModels.showAddDialog" " title="Add model to channel">+ Add</button>
`; `;
container.innerHTML = html; container.innerHTML = html;
}, },
@@ -132,8 +132,8 @@ const ChannelModels = {
<input type="text" id="chModelAddName" placeholder="e.g. Claude-3-Opus" maxlength="50"> <input type="text" id="chModelAddName" placeholder="e.g. Claude-3-Opus" maxlength="50">
</label> </label>
<div class="ch-model-add-actions"> <div class="ch-model-add-actions">
<button class="btn-secondary" onclick="ChannelModels.closeAddDialog()">Cancel</button> <button class="btn-secondary" data-action="ChannelModels.closeAddDialog" ">Cancel</button>
<button class="btn-primary" onclick="ChannelModels.submitAdd()">Add</button> <button class="btn-primary" data-action="ChannelModels.submitAdd" ">Add</button>
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -436,5 +436,5 @@ function _shortName(fullName) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.ChannelModels = ChannelModels; sb.ns('ChannelModels', ChannelModels);
})(); })();

View File

@@ -152,5 +152,5 @@ const ChatPane = {
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.ChatPane = ChatPane; sb.ns('ChatPane', ChatPane);
})(); })();

View File

@@ -1258,25 +1258,25 @@ async function rejectWorkflowStage() {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.ChatInput = ChatInput; sb.ns('ChatInput', ChatInput);
window.loadChats = loadChats; sb.register('loadChats', loadChats);
window.selectChat = selectChat; sb.register('selectChat', selectChat);
window.newChat = newChat; sb.register('newChat', newChat);
window.newGroupChat = newGroupChat; sb.register('newGroupChat', newGroupChat);
window.deleteChat = deleteChat; sb.register('deleteChat', deleteChat);
window.startRenameChat = startRenameChat; sb.register('startRenameChat', startRenameChat);
window.sendMessage = sendMessage; sb.register('sendMessage', sendMessage);
window.stopGeneration = stopGeneration; sb.register('stopGeneration', stopGeneration);
window.reloadActivePath = reloadActivePath; sb.register('reloadActivePath', reloadActivePath);
window.regenerateMessage = regenerateMessage; sb.register('regenerateMessage', regenerateMessage);
window.regenerate = regenerate; sb.register('regenerate', regenerate);
window.editMessage = editMessage; sb.register('editMessage', editMessage);
window.submitEdit = submitEdit; sb.register('submitEdit', submitEdit);
window.cancelEdit = cancelEdit; sb.register('cancelEdit', cancelEdit);
window.switchSibling = switchSibling; sb.register('switchSibling', switchSibling);
window.advanceWorkflowStage = advanceWorkflowStage; sb.register('advanceWorkflowStage', advanceWorkflowStage);
window.rejectWorkflowStage = rejectWorkflowStage; sb.register('rejectWorkflowStage', rejectWorkflowStage);
window._initChatListeners = _initChatListeners; sb.register('_initChatListeners', _initChatListeners);
window.summarizeAndContinue = summarizeAndContinue; sb.register('summarizeAndContinue', summarizeAndContinue);
window.toggleChannelAutoCompact = toggleChannelAutoCompact; sb.register('toggleChannelAutoCompact', toggleChannelAutoCompact);
})(); })();

View File

@@ -331,5 +331,5 @@ function _ceDetectLanguage(path) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.CodeEditor = CodeEditor; sb.ns('CodeEditor', CodeEditor);
})(); })();

View File

@@ -668,12 +668,12 @@ async function purgeCache() {
DebugLog.init(); DebugLog.init();
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.DebugLog = DebugLog; sb.ns('DebugLog', DebugLog);
window.openDebugModal = openDebugModal; sb.register('openDebugModal', openDebugModal);
window.switchDebugTab = switchDebugTab; sb.register('switchDebugTab', switchDebugTab);
window.clearDebugLog = clearDebugLog; sb.register('clearDebugLog', clearDebugLog);
window.copyDebugLog = copyDebugLog; sb.register('copyDebugLog', copyDebugLog);
window.exportDebugLog = exportDebugLog; sb.register('exportDebugLog', exportDebugLog);
window.runDebugDiagnostics = runDebugDiagnostics; sb.register('runDebugDiagnostics', runDebugDiagnostics);
window.purgeCache = purgeCache; sb.register('purgeCache', purgeCache);
})(); })();

View File

@@ -99,5 +99,5 @@ function _clientPos(e, isHoriz) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.initDragResize = initDragResize; sb.register('initDragResize', initDragResize);
})(); })();

View File

@@ -331,5 +331,5 @@ const Events = {
} }
}; };
window.Events = Events; sb.ns('Events', Events);
})(); })();

View File

@@ -488,5 +488,5 @@ const Extensions = {
}; };
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.Extensions = Extensions; sb.ns('Extensions', Extensions);
})(); })();

View File

@@ -268,5 +268,5 @@ function _ftFileIcon(name) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.FileTree = FileTree; sb.ns('FileTree', FileTree);
})(); })();

View File

@@ -350,7 +350,7 @@ function _renderStrip() {
<span class="att-name" title="${esc(a.filename)}">${esc(name)}</span> <span class="att-name" title="${esc(a.filename)}">${esc(name)}</span>
<span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span> <span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span>
</span> </span>
<button class="att-remove" onclick="unstageFile('${a.localId}')" title="Remove">✕</button> <button class="att-remove" data-action="unstageFile" data-args='${JSON.stringify([a.localId])}'" title="Remove">✕</button>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -423,7 +423,7 @@ function renderMessageFiles(msgId) {
const visionHint = !hasVision const visionHint = !hasVision
? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>' ? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>'
: ''; : '';
return `<div class="msg-att msg-att-image" onclick="openLightbox('${esc(att.id)}')"> return `<div class="msg-att msg-att-image" data-action="openLightbox" data-args='${JSON.stringify([esc(att.id)])}'">
<img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}" <img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}"
style="max-width:${w}px" loading="lazy"> style="max-width:${w}px" loading="lazy">
${visionHint} ${visionHint}
@@ -433,7 +433,7 @@ function renderMessageFiles(msgId) {
// Document chip // Document chip
const icon = _docIcon(att.content_type); const icon = _docIcon(att.content_type);
const size = formatFileSize(att.size_bytes); const size = formatFileSize(att.size_bytes);
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadFile('${esc(att.id)}','${esc(att.filename)}');return false"> return `<a class="msg-att msg-att-doc" href="#" data-action="downloadFile" data-args='${JSON.stringify([esc(att.id), esc(att.filename)])}' data-prevent-default">
<span class="att-doc-icon">${icon}</span> <span class="att-doc-icon">${icon}</span>
<span class="att-doc-info"> <span class="att-doc-info">
<span class="att-doc-name">${esc(att.filename)}</span> <span class="att-doc-name">${esc(att.filename)}</span>
@@ -839,22 +839,22 @@ function _hasDragFiles(e) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window._initFileListeners = _initFileListeners; sb.register('_initFileListeners', _initFileListeners);
window._pollTimers = _pollTimers; sb.register('_pollTimers', _pollTimers);
window._startPolling = _startPolling; sb.register('_startPolling', _startPolling);
window._stopPolling = _stopPolling; sb.register('_stopPolling', _stopPolling);
window.clearStaged = clearStaged; sb.register('clearStaged', clearStaged);
window.closeLightbox = closeLightbox; sb.register('closeLightbox', closeLightbox);
window.consumeStaged = consumeStaged; sb.register('consumeStaged', consumeStaged);
window.getStagedFileIds = getStagedFileIds; sb.register('getStagedFileIds', getStagedFileIds);
window.hasStagedFiles = hasStagedFiles; sb.register('hasStagedFiles', hasStagedFiles);
window.initFiles = initFiles; sb.register('initFiles', initFiles);
window.isSendBlocked = isSendBlocked; sb.register('isSendBlocked', isSendBlocked);
window.loadAdminStorage = loadAdminStorage; sb.register('loadAdminStorage', loadAdminStorage);
window.loadAuthImages = loadAuthImages; sb.register('loadAuthImages', loadAuthImages);
window.loadChannelFiles = loadChannelFiles; sb.register('loadChannelFiles', loadChannelFiles);
window.renderMessageFiles = renderMessageFiles; sb.register('renderMessageFiles', renderMessageFiles);
window.unstageFile = unstageFile; sb.register('unstageFile', unstageFile);
window.openLightbox = openLightbox; sb.register('openLightbox', openLightbox);
window.downloadFile = downloadFile; sb.register('downloadFile', downloadFile);
})(); })();

View File

@@ -369,5 +369,5 @@ function _debounce(fn, ms) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.MemoryUI = MemoryUI; sb.ns('MemoryUI', MemoryUI);
})(); })();

View File

@@ -174,5 +174,5 @@ const ModelSelector = {
}; };
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.ModelSelector = ModelSelector; sb.ns('ModelSelector', ModelSelector);
})(); })();

View File

@@ -440,5 +440,5 @@ const NoteEditor = {
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.NoteEditor = NoteEditor; sb.ns('NoteEditor', NoteEditor);
})(); })();

View File

@@ -495,10 +495,10 @@ function invalidateNoteGraph() {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.openNoteGraph = openNoteGraph; sb.register('openNoteGraph', openNoteGraph);
window.closeNoteGraph = closeNoteGraph; sb.register('closeNoteGraph', closeNoteGraph);
window._render = _render; sb.register('_render', _render);
window._graphResetZoom = _graphResetZoom; sb.register('_graphResetZoom', _graphResetZoom);
window._graphToggleOrphans = _graphToggleOrphans; sb.register('_graphToggleOrphans', _graphToggleOrphans);
window.invalidateNoteGraph = invalidateNoteGraph; sb.register('invalidateNoteGraph', invalidateNoteGraph);
})(); })();

View File

@@ -66,9 +66,9 @@ function _noteListItem(note, isSearch) {
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}"> <div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}"> <div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked} <input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
onclick="event.stopPropagation(); _toggleNoteSelect('${note.id}', this.checked)"> data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
</div> </div>
<div class="note-content-col" onclick="${_notesSelectMode ? `_toggleNoteSelect('${note.id}')` : `openNoteEditor('${note.id}')`}"> <div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
<div class="note-item-header"> <div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span> <span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span> <span class="note-item-time">${time}</span>
@@ -422,7 +422,7 @@ async function _loadBacklinks(noteId) {
countEl.textContent = links.length; countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none'; container.style.display = links.length > 0 ? '' : 'none';
listEl.innerHTML = links.map(n => ` listEl.innerHTML = links.map(n => `
<div class="note-backlink-item" onclick="openNoteEditor('${n.id}')"> <div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
<span class="note-backlink-title">${esc(n.title)}</span> <span class="note-backlink-title">${esc(n.title)}</span>
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span> <span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
</div> </div>
@@ -454,12 +454,12 @@ function _renderWikilinksInReadMode(containerEl) {
const tid = `transclusion-${++transclusionId}`; const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}"> return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<div class="transclusion-header"> <div class="transclusion-header">
<span class="wikilink-chip wikilink-transclusion" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span> <span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div> </div>
<div class="transclusion-content"><span class="text-muted">Loading…</span></div> <div class="transclusion-content"><span class="text-muted">Loading…</span></div>
</div>`; </div>`;
} }
return `<span class="wikilink-chip" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`; return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
} }
); );
containerEl.innerHTML = rendered; containerEl.innerHTML = rendered;
@@ -818,7 +818,7 @@ function _registerNotesPanel() {
</div> </div>
<!-- Backlinks --> <!-- Backlinks -->
<div id="noteBacklinks" class="note-backlinks" style="display:none;"> <div id="noteBacklinks" class="note-backlinks" style="display:none;">
<div class="note-backlinks-header" onclick="toggleBacklinks()"> <div class="note-backlinks-header" data-action="toggleBacklinks">
Backlinks <span id="noteBacklinksCount" class="badge">0</span> Backlinks <span id="noteBacklinksCount" class="badge">0</span>
</div> </div>
<div id="noteBacklinksList" class="note-backlinks-list"></div> <div id="noteBacklinksList" class="note-backlinks-list"></div>
@@ -832,8 +832,8 @@ function _registerNotesPanel() {
<span id="graphNodeCount">0</span> notes · <span id="graphEdgeCount">0</span> links <span id="graphNodeCount">0</span> notes · <span id="graphEdgeCount">0</span> links
</span> </span>
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn-small" onclick="_graphToggleOrphans()" title="Toggle orphan nodes">Orphans</button> <button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
<button class="btn-small" onclick="_graphResetZoom()" title="Reset zoom">Reset</button> <button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>
</div> </div>
<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas> <canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>
</div>`; </div>`;
@@ -944,17 +944,33 @@ function _initNotesListeners() {
// All wiring moved into _registerNotesPanel(). // All wiring moved into _registerNotesPanel().
} }
// ── Wrappers for compound onclick expressions ──
// Checkbox toggle: reads .checked from the clicked element
function _toggleNoteSelectCb(el, noteId) {
_toggleNoteSelect(noteId, el.checked);
}
// Note list item click: mode-dependent dispatch
function _noteItemClick(noteId) {
if (_notesSelectMode) {
_toggleNoteSelect(noteId);
} else {
openNoteEditor(noteId);
}
}
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window._currentNote = _currentNote; sb.register('_currentNote', _currentNote);
window._editingNoteId = _editingNoteId; sb.register('_editingNoteId', _editingNoteId);
window._initNotesListeners = _initNotesListeners; sb.register('_initNotesListeners', _initNotesListeners);
window._registerNotesPanel = _registerNotesPanel; sb.register('_registerNotesPanel', _registerNotesPanel);
window.deleteNote = deleteNote; sb.register('deleteNote', deleteNote);
window.openNoteEditor = openNoteEditor; sb.register('openNoteEditor', openNoteEditor);
window.openNotes = openNotes; sb.register('openNotes', openNotes);
window.saveMessageToNote = saveMessageToNote; sb.register('saveMessageToNote', saveMessageToNote);
window.saveNote = saveNote; sb.register('saveNote', saveNote);
window._toggleNoteSelect = _toggleNoteSelect; sb.register('_toggleNoteSelect', _toggleNoteSelect);
window._navigateToLinkedNote = _navigateToLinkedNote; sb.register('_navigateToLinkedNote', _navigateToLinkedNote);
window.toggleBacklinks = toggleBacklinks; sb.register('toggleBacklinks', toggleBacklinks);
sb.register('_toggleNoteSelectCb', _toggleNoteSelectCb);
sb.register('_noteItemClick', _noteItemClick);
})(); })();

View File

@@ -168,5 +168,5 @@ NotifPrefs._getAdminSmtp = function() {
}; };
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.NotifPrefs = NotifPrefs; sb.ns('NotifPrefs', NotifPrefs);
})(); })();

View File

@@ -120,7 +120,7 @@ const Notifications = {
let html = `<div class="notif-dd-header"> let html = `<div class="notif-dd-header">
<span>Notifications</span> <span>Notifications</span>
<button class="notif-mark-all" onclick="Notifications.markAllRead()">Mark all ✓</button> <button class="notif-mark-all" data-action="Notifications.markAllRead">Mark all ✓</button>
</div><div class="notif-dd-list">`; </div><div class="notif-dd-list">`;
for (const n of items.slice(0, 10)) { for (const n of items.slice(0, 10)) {
@@ -128,7 +128,7 @@ const Notifications = {
const dot = unread ? '●' : '○'; const dot = unread ? '●' : '○';
const cls = unread ? 'notif-item notif-unread' : 'notif-item'; const cls = unread ? 'notif-item notif-unread' : 'notif-item';
const ago = _timeAgo(n.created_at); const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" onclick="Notifications._onItemClick(this)"> html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onItemClick" data-pass-el>
<span class="notif-dot">${dot}</span> <span class="notif-dot">${dot}</span>
<div class="notif-body"> <div class="notif-body">
<div class="notif-title">${esc(n.title)}</div> <div class="notif-title">${esc(n.title)}</div>
@@ -140,7 +140,7 @@ const Notifications = {
html += `</div>`; html += `</div>`;
html += `<div class="notif-dd-footer"> html += `<div class="notif-dd-footer">
<button class="notif-view-all" onclick="Notifications.openPanel()">View all →</button> <button class="notif-view-all" data-action="Notifications.openPanel">View all →</button>
</div>`; </div>`;
return html; return html;
@@ -227,12 +227,12 @@ const Notifications = {
el.style.display = 'none'; el.style.display = 'none';
el.innerHTML = `<div class="notif-panel" id="notifPanelInner"> el.innerHTML = `<div class="notif-panel" id="notifPanelInner">
<div class="notif-panel-toolbar"> <div class="notif-panel-toolbar">
<button class="btn-small" onclick="Notifications.markAllRead()">Mark all read</button> <button class="btn-small" data-action="Notifications.markAllRead">Mark all read</button>
<button class="btn-small btn-danger" onclick="Notifications.clearAll()">Clear all</button> <button class="btn-small btn-danger" data-action="Notifications.clearAll">Clear all</button>
</div> </div>
<div class="notif-panel-list" id="notifPanelList"></div> <div class="notif-panel-list" id="notifPanelList"></div>
<div class="notif-panel-more" id="notifPanelMore" style="display:none"> <div class="notif-panel-more" id="notifPanelMore" style="display:none">
<button class="btn-small" onclick="Notifications.loadMore()">Load more</button> <button class="btn-small" data-action="Notifications.loadMore">Load more</button>
</div> </div>
</div>`; </div>`;
body.appendChild(el); body.appendChild(el);
@@ -278,7 +278,7 @@ const Notifications = {
const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item'; const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item';
const dot = unread ? '●' : '○'; const dot = unread ? '●' : '○';
const ago = _timeAgo(n.created_at); const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" onclick="Notifications._onPanelItemClick(this)"> html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onPanelItemClick" data-pass-el>
<div class="notif-panel-row"> <div class="notif-panel-row">
<span class="notif-dot">${dot}</span> <span class="notif-dot">${dot}</span>
<div class="notif-body"> <div class="notif-body">
@@ -286,7 +286,7 @@ const Notifications = {
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''} ${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div> <div class="notif-time">${ago}</div>
</div> </div>
<button class="notif-delete" onclick="event.stopPropagation(); Notifications._deleteItem('${n.id}')" title="Delete">✕</button> <button class="notif-delete" data-action="Notifications._deleteItem" data-args='${JSON.stringify([n.id])}'" title="Delete">✕</button>
</div> </div>
</div>`; </div>`;
} }
@@ -430,5 +430,5 @@ function _timeAgo(isoStr) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.Notifications = Notifications; sb.ns('Notifications', Notifications);
})(); })();

View File

@@ -347,6 +347,6 @@ async function _api(method, path, body) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.Pages = Pages; sb.ns('Pages', Pages);
window._val = _val; // used by pages-splash.js sb.register('_val', _val); // used by pages-splash.js
})(); })();

View File

@@ -588,5 +588,5 @@ PaneContainer.registerPreset('split', {
}); });
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.PaneContainer = PaneContainer; sb.ns('PaneContainer', PaneContainer);
})(); })();

View File

@@ -448,10 +448,10 @@ function switchSidePanelTab(tab) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.PanelRegistry = PanelRegistry; sb.ns('PanelRegistry', PanelRegistry);
window.toggleSidePanelFullscreen = toggleSidePanelFullscreen; sb.register('toggleSidePanelFullscreen', toggleSidePanelFullscreen);
window._initWorkspaceResize = _initWorkspaceResize; sb.register('_initWorkspaceResize', _initWorkspaceResize);
window._initPanelSwipe = _initPanelSwipe; sb.register('_initPanelSwipe', _initPanelSwipe);
window._initPanelResponsive = _initPanelResponsive; sb.register('_initPanelResponsive', _initPanelResponsive);
window._initPanelOverlay = _initPanelOverlay; sb.register('_initPanelOverlay', _initPanelOverlay);
})(); })();

View File

@@ -182,7 +182,7 @@ async function savePersonaKBs(personaId, kbPicker, apiPrefix) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.renderPersonaKBPicker = renderPersonaKBPicker; sb.register('renderPersonaKBPicker', renderPersonaKBPicker);
window.loadPersonaKBs = loadPersonaKBs; sb.register('loadPersonaKBs', loadPersonaKBs);
window.savePersonaKBs = savePersonaKBs; sb.register('savePersonaKBs', savePersonaKBs);
})(); })();

View File

@@ -267,18 +267,18 @@ function showChatContextMenu(e, chatId) {
const order = getProjectChannelOrder(chat.projectId); const order = getProjectChannelOrder(chat.projectId);
const idx = order.indexOf(chatId); const idx = order.indexOf(chatId);
if (idx > 0) { if (idx > 0) {
items += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',-1);dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="_moveChatInProject" data-args='${JSON.stringify([chat.projectId, chatId, -1])}'>
<span class="ctx-icon">↑</span> Move up <span class="ctx-icon">↑</span> Move up
</button>`; </button>`;
} }
if (idx >= 0 && idx < order.length - 1) { if (idx >= 0 && idx < order.length - 1) {
items += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',1);dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="_moveChatInProject" data-args='${JSON.stringify([chat.projectId, chatId, 1])}'>
<span class="ctx-icon">↓</span> Move down <span class="ctx-icon">↓</span> Move down
</button>`; </button>`;
} }
if (idx >= 0) items += '<div class="ctx-divider"></div>'; if (idx >= 0) items += '<div class="ctx-divider"></div>';
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}', null);dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="moveChatToProject" data-args='${JSON.stringify([chatId, null])}'>
<span class="ctx-icon">↩</span> Remove from project <span class="ctx-icon">↩</span> Remove from project
</button>`; </button>`;
items += '<div class="ctx-divider"></div>'; items += '<div class="ctx-divider"></div>';
@@ -287,7 +287,7 @@ function showChatContextMenu(e, chatId) {
App.projects.forEach(p => { App.projects.forEach(p => {
if (p.id === chat.projectId) return; // skip current if (p.id === chat.projectId) return; // skip current
const dot = p.color ? `<span class="project-dot" style="background:${esc(p.color)}"></span>` : ''; const dot = p.color ? `<span class="project-dot" style="background:${esc(p.color)}"></span>` : '';
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}','${p.id}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="moveChatToProject" data-args='${JSON.stringify([chatId, p.id])}'>
${dot}<span>${esc(p.name)}</span> ${dot}<span>${esc(p.name)}</span>
</button>`; </button>`;
}); });
@@ -297,13 +297,13 @@ function showChatContextMenu(e, chatId) {
} }
items += '<div class="ctx-divider"></div>'; items += '<div class="ctx-divider"></div>';
items += `<button class="ctx-item" onclick="createProjectAndMove('${chatId}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="createProjectAndMove" data-args='${JSON.stringify([chatId])}'>
<span class="ctx-icon">+</span> New project… <span class="ctx-icon">+</span> New project…
</button>`; </button>`;
// Workspace override (v0.21.5) // Workspace override (v0.21.5)
items += '<div class="ctx-divider"></div>'; items += '<div class="ctx-divider"></div>';
items += `<button class="ctx-item" onclick="showWorkspacePicker('${chatId}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="showWorkspacePicker" data-args='${JSON.stringify([chatId])}'>
<span class="ctx-icon">📁</span> Set workspace… <span class="ctx-icon">📁</span> Set workspace…
</button>`; </button>`;
@@ -312,13 +312,13 @@ function showChatContextMenu(e, chatId) {
if (folders.length > 0 || chat.folderId) { if (folders.length > 0 || chat.folderId) {
items += '<div class="ctx-divider"></div>'; items += '<div class="ctx-divider"></div>';
if (chat.folderId) { if (chat.folderId) {
items += `<button class="ctx-item" onclick="moveChatToFolder('${chatId}',null);dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="moveChatToFolder" data-args='${JSON.stringify([chatId, null])}'>
<span class="ctx-icon">↩</span> Remove from folder <span class="ctx-icon">↩</span> Remove from folder
</button>`; </button>`;
} }
folders.forEach(f => { folders.forEach(f => {
if (f.id === chat.folderId) return; // skip current if (f.id === chat.folderId) return; // skip current
items += `<button class="ctx-item" onclick="moveChatToFolder('${chatId}','${f.id}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="moveChatToFolder" data-args='${JSON.stringify([chatId, f.id])}'>
<span class="ctx-icon">📂</span> ${esc(f.name)} <span class="ctx-icon">📂</span> ${esc(f.name)}
</button>`; </button>`;
}); });
@@ -331,6 +331,7 @@ function showChatContextMenu(e, chatId) {
menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';
document.body.appendChild(menu); document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu; _ctxMenu = menu;
// Dismiss on click outside // Dismiss on click outside
@@ -341,6 +342,31 @@ function dismissChatContextMenu() {
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; } if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
} }
// ── Delegated action dispatcher for context menus and panel buttons ──
// Buttons use data-action="fnName" data-args='[...]' instead of onclick.
// One listener per menu/panel handles all clicks.
function _ctxDispatch(container) {
if (container._ctxBound) return; // idempotent — don't stack listeners
container._ctxBound = true;
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.stopPropagation();
const fn = sb.resolve(btn.dataset.action);
if (typeof fn !== 'function') {
console.warn('[ctx] Unknown action:', btn.dataset.action);
return;
}
const args = JSON.parse(btn.dataset.args || '[]');
fn(...args);
if (btn.dataset.closeOverlay === 'true') {
btn.closest('.modal-overlay')?.remove();
} else {
dismissChatContextMenu();
}
});
}
async function createProjectAndMove(chatId) { async function createProjectAndMove(chatId) {
const name = prompt('New project name:'); const name = prompt('New project name:');
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
@@ -467,21 +493,21 @@ function showProjectMenu(e, projectId) {
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'project-ctx-menu'; menu.className = 'project-ctx-menu';
menu.innerHTML = ` menu.innerHTML = `
<button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()"> <button class="ctx-item" data-action="toggleActiveProject" data-args='${JSON.stringify([projectId])}'>
<span class="ctx-icon">${isActive ? '📍' : '📌'}</span> ${activeLabel} <span class="ctx-icon">${isActive ? '📍' : '📌'}</span> ${activeLabel}
</button> </button>
<button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()"> <button class="ctx-item" data-action="openProjectPanel" data-args='${JSON.stringify([projectId])}'>
<span class="ctx-icon">⚙</span> Project settings <span class="ctx-icon">⚙</span> Project settings
</button> </button>
<div class="ctx-divider"></div> <div class="ctx-divider"></div>
<button class="ctx-item" onclick="renameProject('${projectId}');dismissChatContextMenu()"> <button class="ctx-item" data-action="renameProject" data-args='${JSON.stringify([projectId])}'>
<span class="ctx-icon">✏</span> Rename <span class="ctx-icon">✏</span> Rename
</button> </button>
<button class="ctx-item" onclick="setProjectColor('${projectId}');dismissChatContextMenu()"> <button class="ctx-item" data-action="setProjectColor" data-args='${JSON.stringify([projectId])}'>
<span class="ctx-icon">🎨</span> Color <span class="ctx-icon">🎨</span> Color
</button> </button>
<div class="ctx-divider"></div> <div class="ctx-divider"></div>
<button class="ctx-item ctx-danger" onclick="deleteProject('${projectId}');dismissChatContextMenu()"> <button class="ctx-item ctx-danger" data-action="deleteProject" data-args='${JSON.stringify([projectId])}'>
<span class="ctx-icon">🗑</span> Delete project <span class="ctx-icon">🗑</span> Delete project
</button>`; </button>`;
@@ -489,6 +515,7 @@ function showProjectMenu(e, projectId) {
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
document.body.appendChild(menu); document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu; _ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
} }
@@ -684,8 +711,9 @@ async function _renderProjectKBs(projectId) {
list.innerHTML = kbs.map(kb => ` list.innerHTML = kbs.map(kb => `
<div class="project-panel-item"> <div class="project-panel-item">
<span class="project-panel-item-name">${esc(kb.name || kb.kb_id)}</span> <span class="project-panel-item-name">${esc(kb.name || kb.kb_id)}</span>
<button class="project-panel-item-remove" onclick="_removeProjectKB('${projectId}','${kb.kb_id}')" title="Remove">✕</button> <button class="project-panel-item-remove" data-action="_removeProjectKB" data-args='${JSON.stringify([projectId, kb.kb_id])}' title="Remove">✕</button>
</div>`).join(''); </div>`).join('');
_ctxDispatch(list);
} catch (_) { } catch (_) {
list.innerHTML = '<div class="project-panel-empty">Failed to load KBs</div>'; list.innerHTML = '<div class="project-panel-empty">Failed to load KBs</div>';
} }
@@ -733,12 +761,13 @@ async function _showKBPicker() {
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px'; menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
menu.style.top = (rect.bottom + 4) + 'px'; menu.style.top = (rect.bottom + 4) + 'px';
menu.innerHTML = available.map(kb => ` menu.innerHTML = available.map(kb => `
<button class="ctx-item" onclick="_addProjectKB('${_projectPanelId}','${kb.id}');dismissChatContextMenu()"> <button class="ctx-item" data-action="_addProjectKB" data-args='${JSON.stringify([_projectPanelId, kb.id])}'>
${esc(kb.name)} ${esc(kb.name)}
</button>`).join(''); </button>`).join('');
document.body.appendChild(menu); document.body.appendChild(menu);
_ctxMenu = menu; _ctxDispatch(menu);
_ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
} catch (e) { } catch (e) {
UI.toast('Failed to load KBs', 'error'); UI.toast('Failed to load KBs', 'error');
@@ -768,8 +797,9 @@ async function _renderProjectNotes(projectId) {
list.innerHTML = notes.map(n => ` list.innerHTML = notes.map(n => `
<div class="project-panel-item"> <div class="project-panel-item">
<span class="project-panel-item-name">${esc(n.title || n.note_id)}</span> <span class="project-panel-item-name">${esc(n.title || n.note_id)}</span>
<button class="project-panel-item-remove" onclick="_removeProjectNote('${projectId}','${n.note_id}')" title="Remove">✕</button> <button class="project-panel-item-remove" data-action="_removeProjectNote" data-args='${JSON.stringify([projectId, n.note_id])}' title="Remove">✕</button>
</div>`).join(''); </div>`).join('');
_ctxDispatch(list);
} catch (_) { } catch (_) {
list.innerHTML = '<div class="project-panel-empty">Failed to load notes</div>'; list.innerHTML = '<div class="project-panel-empty">Failed to load notes</div>';
} }
@@ -1575,28 +1605,28 @@ function showChannelContextMenu(e, channelId) {
menu.className = 'project-ctx-menu'; menu.className = 'project-ctx-menu';
let items = ''; let items = '';
items += `<button class="ctx-item" onclick="openParticipantsPanel('${channelId}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="openParticipantsPanel" data-args='${JSON.stringify([channelId])}'>
<span class="ctx-icon">👥</span> Members <span class="ctx-icon">👥</span> Members
</button>`; </button>`;
items += `<button class="ctx-item" onclick="renameChannel('${channelId}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="renameChannel" data-args='${JSON.stringify([channelId])}'>
<span class="ctx-icon">✏</span> Rename <span class="ctx-icon">✏</span> Rename
</button>`; </button>`;
if (ch.type === 'channel' || ch.type === 'group') { if (ch.type === 'channel' || ch.type === 'group') {
items += `<button class="ctx-item" onclick="editChannelTopic('${channelId}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="editChannelTopic" data-args='${JSON.stringify([channelId])}'>
<span class="ctx-icon">💬</span> Set topic <span class="ctx-icon">💬</span> Set topic
</button>`; </button>`;
items += `<button class="ctx-item" onclick="cycleChannelAiMode('${channelId}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="cycleChannelAiMode" data-args='${JSON.stringify([channelId])}'>
<span class="ctx-icon">🤖</span> AI mode: ${esc(ch.aiMode || 'auto')} <span class="ctx-icon">🤖</span> AI mode: ${esc(ch.aiMode || 'auto')}
</button>`; </button>`;
} }
items += '<div class="ctx-divider"></div>'; items += '<div class="ctx-divider"></div>';
items += `<button class="ctx-item" onclick="archiveChannel('${channelId}');dismissChatContextMenu()"> items += `<button class="ctx-item" data-action="archiveChannel" data-args='${JSON.stringify([channelId])}'>
<span class="ctx-icon">📦</span> Archive <span class="ctx-icon">📦</span> Archive
</button>`; </button>`;
// Delete gated by retention mode (default=flexible allows delete) // Delete gated by retention mode (default=flexible allows delete)
const retentionMode = App.policies?.channel_retention_mode || 'flexible'; const retentionMode = App.policies?.channel_retention_mode || 'flexible';
if (retentionMode === 'flexible') { if (retentionMode === 'flexible') {
items += `<button class="ctx-item ctx-danger" onclick="deleteChannel('${channelId}');dismissChatContextMenu()"> items += `<button class="ctx-item ctx-danger" data-action="deleteChannel" data-args='${JSON.stringify([channelId])}'>
<span class="ctx-icon">🗑</span> Delete <span class="ctx-icon">🗑</span> Delete
</button>`; </button>`;
} }
@@ -1606,6 +1636,7 @@ function showChannelContextMenu(e, channelId) {
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
document.body.appendChild(menu); document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu; _ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
} }
@@ -1725,17 +1756,18 @@ function showFolderMenu(e, folderId) {
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'project-ctx-menu'; menu.className = 'project-ctx-menu';
menu.innerHTML = ` menu.innerHTML = `
<button class="ctx-item" onclick="renameFolder('${folderId}');dismissChatContextMenu()"> <button class="ctx-item" data-action="renameFolder" data-args='${JSON.stringify([folderId])}'>
<span class="ctx-icon">✏</span> Rename <span class="ctx-icon">✏</span> Rename
</button> </button>
<div class="ctx-divider"></div> <div class="ctx-divider"></div>
<button class="ctx-item ctx-danger" onclick="deleteFolder('${folderId}');dismissChatContextMenu()"> <button class="ctx-item ctx-danger" data-action="deleteFolder" data-args='${JSON.stringify([folderId])}'>
<span class="ctx-icon">🗑</span> Delete folder <span class="ctx-icon">🗑</span> Delete folder
</button>`; </button>`;
menu.style.left = Math.min(e.clientX, window.innerWidth - 160) + 'px'; menu.style.left = Math.min(e.clientX, window.innerWidth - 160) + 'px';
menu.style.top = Math.min(e.clientY, window.innerHeight - 100) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 100) + 'px';
document.body.appendChild(menu); document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu; _ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
} }
@@ -1920,9 +1952,10 @@ async function _refreshParticipantsList(channelId) {
<span style="flex:1;font-size:13px;">${esc(name)}</span> <span style="flex:1;font-size:13px;">${esc(name)}</span>
<span style="font-size:11px;color:var(--text-3);">${esc(p.participant_type)} · ${esc(role)}</span> <span style="font-size:11px;color:var(--text-3);">${esc(p.participant_type)} · ${esc(role)}</span>
${role !== 'owner' ? `<button class="btn-small" style="color:var(--danger);" ${role !== 'owner' ? `<button class="btn-small" style="color:var(--danger);"
onclick="_removeParticipant('${channelId}','${p.id}')">✕</button>` : ''} data-action="_removeParticipant" data-args='${JSON.stringify([channelId, p.id])}'>✕</button>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
_ctxDispatch(el);
} catch (e) { } catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`; el.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
} }
@@ -1946,6 +1979,7 @@ async function _showAddParticipantSearch(channelId, pType) {
const input = overlay.querySelector('#_addPartSearch'); const input = overlay.querySelector('#_addPartSearch');
const results = overlay.querySelector('#_addPartResults'); const results = overlay.querySelector('#_addPartResults');
_ctxDispatch(results); // delegate data-action clicks on search results
input.focus(); input.focus();
let debounce = null; let debounce = null;
@@ -1972,7 +2006,7 @@ async function _showAddParticipantSearch(channelId, pType) {
results.innerHTML = items.slice(0, 10).map(item => results.innerHTML = items.slice(0, 10).map(item =>
`<div style="padding:6px 8px;cursor:pointer;border-radius:4px;font-size:13px;" `<div style="padding:6px 8px;cursor:pointer;border-radius:4px;font-size:13px;"
class="hover-bg" class="hover-bg"
onclick="_addParticipant('${channelId}','${pType}','${item.id}');this.closest('.modal-overlay').remove();"> data-action="_addParticipant" data-args='${JSON.stringify([channelId, pType, item.id])}' data-close-overlay="true">
${pType === 'persona' ? '⚡' : '👤'} ${esc(item.name)} ${pType === 'persona' ? '⚡' : '👤'} ${esc(item.name)}
</div>` </div>`
).join(''); ).join('');
@@ -2008,52 +2042,52 @@ async function _removeParticipant(channelId, recordId) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window._registerProjectPanel = _registerProjectPanel; sb.register('_registerProjectPanel', _registerProjectPanel);
window.createProject = createProject; sb.register('createProject', createProject);
window.cycleChannelAiMode = cycleChannelAiMode; sb.register('cycleChannelAiMode', cycleChannelAiMode);
window.deleteChannel = deleteChannel; sb.register('deleteChannel', deleteChannel);
window.deleteFolder = deleteFolder; sb.register('deleteFolder', deleteFolder);
window.deleteProject = deleteProject; sb.register('deleteProject', deleteProject);
window.loadChannels = loadChannels; sb.register('loadChannels', loadChannels);
window.loadFolders = loadFolders; sb.register('loadFolders', loadFolders);
window.loadProjectChannelPositions = loadProjectChannelPositions; sb.register('loadProjectChannelPositions', loadProjectChannelPositions);
window.loadProjects = loadProjects; sb.register('loadProjects', loadProjects);
window.loadUsers = loadUsers; sb.register('loadUsers', loadUsers);
window.newChannelOrDM = newChannelOrDM; sb.register('newChannelOrDM', newChannelOrDM);
window.newFolder = newFolder; sb.register('newFolder', newFolder);
window.onChatDragEnd = onChatDragEnd; sb.register('onChatDragEnd', onChatDragEnd);
window.onChatDragStart = onChatDragStart; sb.register('onChatDragStart', onChatDragStart);
window.onChatSectionDrop = onChatSectionDrop; sb.register('onChatSectionDrop', onChatSectionDrop);
window.onFolderDrop = onFolderDrop; sb.register('onFolderDrop', onFolderDrop);
window.onProjectDragLeave = onProjectDragLeave; sb.register('onProjectDragLeave', onProjectDragLeave);
window.onProjectDragOver = onProjectDragOver; sb.register('onProjectDragOver', onProjectDragOver);
window.onProjectDrop = onProjectDrop; sb.register('onProjectDrop', onProjectDrop);
window.onUnfiledDrop = onUnfiledDrop; sb.register('onUnfiledDrop', onUnfiledDrop);
window.openParticipantsPanel = openParticipantsPanel; sb.register('openParticipantsPanel', openParticipantsPanel);
window.openProjectPanel = openProjectPanel; sb.register('openProjectPanel', openProjectPanel);
window.selectChannel = selectChannel; sb.register('selectChannel', selectChannel);
window.showChannelContextMenu = showChannelContextMenu; sb.register('showChannelContextMenu', showChannelContextMenu);
window.showChatContextMenu = showChatContextMenu; sb.register('showChatContextMenu', showChatContextMenu);
window.showFolderMenu = showFolderMenu; sb.register('showFolderMenu', showFolderMenu);
window.showProjectMenu = showProjectMenu; sb.register('showProjectMenu', showProjectMenu);
window.toggleProjectCollapse = toggleProjectCollapse; sb.register('toggleProjectCollapse', toggleProjectCollapse);
window.renameProject = renameProject; sb.register('renameProject', renameProject);
window.setProjectColor = setProjectColor; sb.register('setProjectColor', setProjectColor);
window.toggleActiveProject = toggleActiveProject; sb.register('toggleActiveProject', toggleActiveProject);
window.moveChatToProject = moveChatToProject; sb.register('moveChatToProject', moveChatToProject);
window.dismissChatContextMenu = dismissChatContextMenu; sb.register('dismissChatContextMenu', dismissChatContextMenu);
window.createProjectAndMove = createProjectAndMove; sb.register('createProjectAndMove', createProjectAndMove);
window.moveChatToFolder = moveChatToFolder; sb.register('moveChatToFolder', moveChatToFolder);
window._removeProjectKB = _removeProjectKB; sb.register('_removeProjectKB', _removeProjectKB);
window._addProjectKB = _addProjectKB; sb.register('_addProjectKB', _addProjectKB);
window._removeProjectNote = _removeProjectNote; sb.register('_removeProjectNote', _removeProjectNote);
window.showWorkspacePicker = showWorkspacePicker; sb.register('showWorkspacePicker', showWorkspacePicker);
window._moveChatInProject = _moveChatInProject; sb.register('_moveChatInProject', _moveChatInProject);
window.renameChannel = renameChannel; sb.register('renameChannel', renameChannel);
window.editChannelTopic = editChannelTopic; sb.register('editChannelTopic', editChannelTopic);
window.archiveChannel = archiveChannel; sb.register('archiveChannel', archiveChannel);
window.renameFolder = renameFolder; sb.register('renameFolder', renameFolder);
window._showAddParticipantSearch = _showAddParticipantSearch; sb.register('_showAddParticipantSearch', _showAddParticipantSearch);
window._addParticipant = _addParticipant; sb.register('_addParticipant', _addParticipant);
window._removeParticipant = _removeParticipant; sb.register('_removeParticipant', _removeParticipant);
})(); })();

View File

@@ -556,5 +556,5 @@ const REPL = {
// ensuring API.user.role is available for admin gate check. // ensuring API.user.role is available for admin gate check.
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.REPL = REPL; sb.ns('REPL', REPL);
})(); })();

133
src/js/sb.js Normal file
View File

@@ -0,0 +1,133 @@
// ==========================================
// Chat Switchboard Action Registry (sb.js)
// ==========================================
// Central registry for all dispatchable actions. Loaded first
// in base.html, before all other JS files.
//
// Files register their exports:
// sb.ns('UI', UI); // namespace object
// sb.register('deleteChat', fn); // standalone action
//
// Dispatch (from data-action attributes):
// sb.resolve('deleteChat') → fn
// sb.resolve('UI.copyMessage') → UI.copyMessage.bind(UI)
//
// Template bridge (Go onclick handlers):
// sb.call('deleteChat', id) → fn(id)
// sb.call('UI.toggleFolder', id) → UI.toggleFolder(id)
//
// Introspection:
// sb.list() → { actions: [...], namespaces: [...] }
//
// Extension hook (future):
// Extensions register actions into the same registry.
// Platform and extension actions are dispatched identically.
//
// Exports: window.sb
'use strict';
const sb = {
_actions: new Map(),
_namespaces: new Map(),
/**
* Register a standalone action function.
* Also sets window[name] for backward compatibility with cross-file
* direct calls. This side-effect is removed when files convert to
* ES module imports (Phase 4).
*/
register(name, fn) {
if (typeof fn !== 'function') {
console.warn('[sb] register: not a function:', name);
return;
}
this._actions.set(name, fn);
window[name] = fn; // backward compat — remove when ES modules land
},
/**
* Register a namespace object (API, UI, App, etc.).
* Methods are accessible via dot notation: sb.resolve('UI.toast').
* Also sets window[name] for backward compatibility.
*/
ns(name, obj) {
if (obj == null || typeof obj !== 'object') {
console.warn('[sb] ns: not an object:', name);
return;
}
this._namespaces.set(name, obj);
window[name] = obj; // backward compat
},
/**
* Resolve an action by name. Returns the function or null.
* Supports flat names ('deleteChat') and dot notation ('UI.copyMessage').
*/
resolve(name) {
// Flat action
const flat = this._actions.get(name);
if (flat) return flat;
// Dot notation: namespace.method
const dot = name.indexOf('.');
if (dot > 0) {
const nsName = name.slice(0, dot);
const method = name.slice(dot + 1);
const obj = this._namespaces.get(nsName);
if (obj && typeof obj[method] === 'function') {
return obj[method].bind(obj);
}
// Try nested: UI._deleteRoutingPolicy → UI['_deleteRoutingPolicy']
if (obj && typeof obj[method] !== 'undefined') {
return typeof obj[method] === 'function' ? obj[method].bind(obj) : null;
}
}
// Fallback: check window (catches un-migrated globals)
if (typeof window[name] === 'function') return window[name];
return null;
},
/**
* Call an action by name. Template bridge.
* Go templates use: onclick="sb.call('action', arg1, arg2)"
*/
call(name, ...args) {
const fn = this.resolve(name);
if (fn) return fn(...args);
console.warn('[sb] Unknown action:', name);
},
/**
* Call with event passthrough (for context menu positioning).
* Go templates use: onclick="sb.callEvent(event, 'showProjectMenu', id)"
*/
callEvent(event, name, ...args) {
event?.stopPropagation?.();
const fn = this.resolve(name);
if (fn) return fn(event, ...args);
console.warn('[sb] Unknown action:', name);
},
/**
* Introspection: list all registered actions and namespaces.
*/
list() {
return {
actions: [...this._actions.keys()].sort(),
namespaces: [...this._namespaces.keys()].sort(),
total: this._actions.size + this._namespaces.size,
};
},
/**
* Check if an action is registered.
*/
has(name) {
return this.resolve(name) !== null;
},
};
window.sb = sb;

View File

@@ -3,10 +3,6 @@
// ========================================== // ==========================================
// Settings save, provider CRUD, avatar, command palette, // Settings save, provider CRUD, avatar, command palette,
// user model roles. // user model roles.
//
// Exports: window.loadSettings,window.saveSettings,window.updateAvatarPreview,window._userProvForm,window._userProvList,window.handleSaveAdminSettings,window._userRoleConfig,window.toggleCmdPalette,window.closeCmdPalette,window._initSettingsListeners,window._initAdminSettingsToggles
// (also: window.toggleUserModelVisibility, window.bulkSetUserModelVisibility,
// window.deleteUserPersona, window.loadTeamWorkflows — self-assigned)
(function() { (function() {
'use strict'; 'use strict';
@@ -793,7 +789,7 @@ async function _importAdminSettings(file) {
// chat and admin surfaces). // chat and admin surfaces).
if (typeof toggleUserModelVisibility === 'undefined') { if (typeof toggleUserModelVisibility === 'undefined') {
window.toggleUserModelVisibility = async function(modelId, providerConfigId, currentlyHidden) { sb.register('toggleUserModelVisibility', async function(modelId, providerConfigId, currentlyHidden) {
const compositeKey = (providerConfigId || '') + ':' + modelId; const compositeKey = (providerConfigId || '') + ':' + modelId;
try { try {
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden); await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
@@ -802,11 +798,11 @@ if (typeof toggleUserModelVisibility === 'undefined') {
await UI.loadUserModels(); await UI.loadUserModels();
if (typeof fetchModels === 'function') await fetchModels(); if (typeof fetchModels === 'function') await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
}; });
} }
if (typeof bulkSetUserModelVisibility === 'undefined') { if (typeof bulkSetUserModelVisibility === 'undefined') {
window.bulkSetUserModelVisibility = async function(show) { sb.register('bulkSetUserModelVisibility', async function(show) {
const models = (App.models || []).filter(m => !m.isPersona); const models = (App.models || []).filter(m => !m.isPersona);
if (!models.length) return; if (!models.length) return;
const shouldHide = !show; const shouldHide = !show;
@@ -827,11 +823,11 @@ if (typeof bulkSetUserModelVisibility === 'undefined') {
if (typeof fetchModels === 'function') await fetchModels(); if (typeof fetchModels === 'function') await fetchModels();
UI.toast(`${toUpdate.length} model${toUpdate.length !== 1 ? 's' : ''} ${show ? 'shown' : 'hidden'}`); UI.toast(`${toUpdate.length} model${toUpdate.length !== 1 ? 's' : ''} ${show ? 'shown' : 'hidden'}`);
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
}; });
} }
if (typeof deleteUserPersona === 'undefined') { if (typeof deleteUserPersona === 'undefined') {
window.deleteUserPersona = async function(id, name) { sb.register('deleteUserPersona', async function(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return; if (!await showConfirm(`Delete persona "${name}"?`)) return;
try { try {
await API.deleteUserPersona(id); await API.deleteUserPersona(id);
@@ -839,12 +835,12 @@ if (typeof deleteUserPersona === 'undefined') {
await UI.loadUserPersonas(); await UI.loadUserPersonas();
if (typeof fetchModels === 'function') await fetchModels(); if (typeof fetchModels === 'function') await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
}; });
} }
// v0.27.0: Team Workflows settings section — lists workflows for user's teams. // v0.27.0: Team Workflows settings section — lists workflows for user's teams.
// Loaded as a dynamic section in the settings surface. // Loaded as a dynamic section in the settings surface.
window.loadTeamWorkflows = async function() { sb.register('loadTeamWorkflows', async function() {
var el = document.getElementById('settingsDynamic'); var el = document.getElementById('settingsDynamic');
if (!el) return; if (!el) return;
@@ -898,18 +894,18 @@ window.loadTeamWorkflows = async function() {
} catch (e) { } catch (e) {
el.innerHTML = '<div class="settings-placeholder">Failed to load workflows: ' + esc(e.message) + '</div>'; el.innerHTML = '<div class="settings-placeholder">Failed to load workflows: ' + esc(e.message) + '</div>';
} }
}; });
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.loadSettings = loadSettings; sb.register('loadSettings', loadSettings);
window.saveSettings = saveSettings; sb.register('saveSettings', saveSettings);
window.updateAvatarPreview = updateAvatarPreview; sb.register('updateAvatarPreview', updateAvatarPreview);
window._userProvForm = _userProvForm; sb.register('_userProvForm', _userProvForm);
window._userProvList = _userProvList; sb.register('_userProvList', _userProvList);
window.handleSaveAdminSettings = handleSaveAdminSettings; sb.register('handleSaveAdminSettings', handleSaveAdminSettings);
window._userRoleConfig = _userRoleConfig; sb.register('_userRoleConfig', _userRoleConfig);
window.toggleCmdPalette = toggleCmdPalette; sb.register('toggleCmdPalette', toggleCmdPalette);
window.closeCmdPalette = closeCmdPalette; sb.register('closeCmdPalette', closeCmdPalette);
window._initSettingsListeners = _initSettingsListeners; sb.register('_initSettingsListeners', _initSettingsListeners);
window._initAdminSettingsToggles = _initAdminSettingsToggles; sb.register('_initAdminSettingsToggles', _initAdminSettingsToggles);
})(); })();

View File

@@ -239,7 +239,7 @@
} }
} }
window._loadAdminTasks = async function() { sb.register('_loadAdminTasks', async function() {
var container = document.getElementById('adminDynamic'); var container = document.getElementById('adminDynamic');
if (!container) return; if (!container) return;
container.innerHTML = SCAFFOLD_HTML; container.innerHTML = SCAFFOLD_HTML;
@@ -251,5 +251,5 @@
document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig); document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig);
await loadTasks(); await loadTasks();
}; });
})(); })();

View File

@@ -105,7 +105,7 @@
'<p style="color:var(--text-2);font-size:12px;margin:0 0 12px">Quick-start with a pre-configured task. You can customize after creation.</p>' + '<p style="color:var(--text-2);font-size:12px;margin:0 0 12px">Quick-start with a pre-configured task. You can customize after creation.</p>' +
'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">'; '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">';
TEMPLATES.forEach(function(tmpl) { TEMPLATES.forEach(function(tmpl) {
html += '<div class="settings-section" style="padding:10px 12px;cursor:pointer" onclick="window._createFromTemplate(\'' + esc(tmpl.name) + '\')">' + html += '<div class="settings-section" style="padding:10px 12px;cursor:pointer" data-action="_createFromTemplate" data-args=\'' + JSON.stringify([esc(tmpl.name)]) + '\'>' +
'<div style="font-weight:600;font-size:13px">' + esc(tmpl.name) + '</div>' + '<div style="font-weight:600;font-size:13px">' + esc(tmpl.name) + '</div>' +
'<div style="font-size:11px;color:var(--text-2);margin-top:4px">' + esc(tmpl.schedule) + '</div>' + '<div style="font-size:11px;color:var(--text-2);margin-top:4px">' + esc(tmpl.schedule) + '</div>' +
'</div>'; '</div>';
@@ -269,13 +269,13 @@
} }
// Template launcher // Template launcher
window._createFromTemplate = function(name) { sb.register('_createFromTemplate', function(name) {
var tmpl = TEMPLATES.find(function(t) { return t.name === name; }); var tmpl = TEMPLATES.find(function(t) { return t.name === name; });
if (tmpl) showCreateForm({ name: tmpl.name, prompt: tmpl.prompt, schedule: tmpl.schedule }); if (tmpl) showCreateForm({ name: tmpl.name, prompt: tmpl.prompt, schedule: tmpl.schedule });
}; });
// ── Entry point (called from settings loader) ── // ── Entry point (called from settings loader) ──
window._loadSettingsTasks = function() { sb.register('_loadSettingsTasks', function() {
loadTaskList(); loadTaskList();
}; });
})(); })();

View File

@@ -7,7 +7,7 @@
(function() { (function() {
'use strict'; 'use strict';
window.TaskSidebar = { const TaskSidebar = {
_loaded: false, _loaded: false,
@@ -26,7 +26,7 @@
section.className = 'sb-section'; section.className = 'sb-section';
section.id = 'sbSectionTasks'; section.id = 'sbSectionTasks';
section.innerHTML = section.innerHTML =
'<div class="sb-section-header" onclick="UI.toggleSidebarSection(\'tasks\')">' + '<div class="sb-section-header" data-action="UI.toggleSidebarSection" data-args=\'["tasks"]\'>' +
'<span class="sb-section-arrow" id="sbArrowTasks">\u25be</span>' + '<span class="sb-section-arrow" id="sbArrowTasks">\u25be</span>' +
'<span class="sb-section-label">Tasks</span>' + '<span class="sb-section-label">Tasks</span>' +
'<span class="sb-queue-badge" id="sbTaskBadge" style="display:none">0</span>' + '<span class="sb-queue-badge" id="sbTaskBadge" style="display:none">0</span>' +
@@ -107,7 +107,7 @@
'<span class="sb-queue-icon">' + icon + '</span>' + '<span class="sb-queue-icon">' + icon + '</span>' +
'<span class="sb-queue-label">' + label + '</span>' + '<span class="sb-queue-label">' + label + '</span>' +
'<button class="btn-small" style="padding:0 4px;font-size:10px" ' + '<button class="btn-small" style="padding:0 4px;font-size:10px" ' +
'onclick="event.stopPropagation();TaskSidebar.runNow(\'' + t.id + '\')" title="Run Now">\u25b6</button>' + 'data-action="TaskSidebar.runNow" data-args=\'' + JSON.stringify([t.id]) + '\' title="Run Now">\u25b6</button>' +
'</div>'; '</div>';
}); });
@@ -129,6 +129,7 @@
} }
} }
}; };
sb.ns('TaskSidebar', TaskSidebar);
function _esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; } function _esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }

View File

@@ -164,7 +164,7 @@ function dismissContextWarning() {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.Tokens = Tokens; sb.ns('Tokens', Tokens);
window.updateInputTokens = updateInputTokens; sb.register('updateInputTokens', updateInputTokens);
window.updateContextWarning = updateContextWarning; sb.register('updateContextWarning', updateContextWarning);
})(); })();

View File

@@ -146,7 +146,7 @@ Object.assign(UI, {
document.getElementById('teamAdminPicker').style.display = ''; document.getElementById('teamAdminPicker').style.display = '';
document.getElementById('teamAdminContent').style.display = 'none'; document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => ` document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')"> <div class="team-card" style="cursor:pointer" data-action="UI.openTeamManage" data-args='${JSON.stringify([t.id, esc(t.name)])}'>
<div class="team-card-info"> <div class="team-card-info">
<strong>${esc(t.name)}</strong> <strong>${esc(t.name)}</strong>
<span class="badge-admin">admin</span> <span class="badge-admin">admin</span>
@@ -220,12 +220,12 @@ Object.assign(UI, {
<td class="admin-user-time">${lastSeen}</td> <td class="admin-user-time">${lastSeen}</td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
${isPending ${isPending
? `<button class="icon-btn" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>` ? `<button class="icon-btn" data-action="showApproveForm" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Approve">✓</button>`
: `<button class="icon-btn" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>` : `<button class="icon-btn" data-action="toggleUserActive" data-args='${JSON.stringify([u.id, false])}' title="Disable">⏸</button>`
} }
<button class="icon-btn" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button> <button class="icon-btn" data-action="toggleUserRole" data-args='${JSON.stringify([u.id, u.role === "admin" ? "user" : "admin"])}' title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button class="icon-btn" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button> <button class="icon-btn" data-action="adminResetUserPassword" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Reset PW">🔑</button>
<button class="icon-btn icon-btn-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button> <button class="icon-btn icon-btn-danger" data-action="deleteUser" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Delete">🗑</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -426,9 +426,9 @@ Object.assign(UI, {
<td><span class="badge-provider-type">${esc(ext.tier)}</span></td> <td><span class="badge-provider-type">${esc(ext.tier)}</span></td>
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td> <td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button> <button class="icon-btn" data-action="editAdminExtension" data-args='${JSON.stringify([ext.id])}' title="Edit">✎</button>
<button class="icon-btn" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button> <button class="icon-btn" data-action="toggleAdminExtension" data-args='${JSON.stringify([ext.id, !ext.is_enabled])}' title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button> <button class="icon-btn icon-btn-danger" data-action="deleteAdminExtension" data-args='${JSON.stringify([ext.id, esc(ext.name)])}' title="Uninstall">🗑</button>
</td> </td>
</tr>`; </tr>`;
}).join('')}</tbody> }).join('')}</tbody>
@@ -652,7 +652,7 @@ Object.assign(UI, {
<td style="font-weight:500;font-family:var(--font-mono,monospace);font-size:12px">${esc(m.model_id || m.id)}</td> <td style="font-weight:500;font-family:var(--font-mono,monospace);font-size:12px">${esc(m.model_id || m.id)}</td>
<td style="font-size:12px;color:var(--text-3)">${esc(m.provider_name || '')}</td> <td style="font-size:12px;color:var(--text-3)">${esc(m.provider_name || '')}</td>
<td>${badges}</td> <td>${badges}</td>
<td><button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}','${vis}')">${visLabel}</button></td> <td><button class="admin-model-toggle ${visClass}" data-action="cycleModelVisibility" data-args='${JSON.stringify([m.id, vis])}'>${visLabel}</button></td>
</tr>`; </tr>`;
}).join('')}</tbody> }).join('')}</tbody>
</table> </table>
@@ -733,10 +733,10 @@ Object.assign(UI, {
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td> <td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td> <td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona=&quot;${p.id}&quot;]')" title="Access">🔒</button> <button class="icon-btn" data-action="UI.openGrantPicker" data-args='${JSON.stringify(["persona", p.id, esc(p.name), `[data-grant-persona="${p.id}"]`])}' title="Access">🔒</button>
<button class="icon-btn" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button> <button class="icon-btn" data-action="editAdminPersona" data-args='${JSON.stringify([p.id])}' title="Edit">✎</button>
<button class="icon-btn" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button> <button class="icon-btn" data-action="toggleAdminPersona" data-args='${JSON.stringify([p.id, !p.is_active])}' title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button> <button class="icon-btn icon-btn-danger" data-action="deleteAdminPersona" data-args='${JSON.stringify([p.id, esc(p.name)])}' title="Delete">🗑</button>
</td> </td>
</tr>`; </tr>`;
}).join('')}</tbody> }).join('')}</tbody>
@@ -775,9 +775,9 @@ Object.assign(UI, {
</td> </td>
<td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td> <td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button> <button class="icon-btn" data-action="UI.openTeamDetail" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Members">👥</button>
<button class="icon-btn" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button> <button class="icon-btn" data-action="toggleTeamActive" data-args='${JSON.stringify([t.id, !t.is_active])}' title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button> <button class="icon-btn icon-btn-danger" data-action="deleteTeam" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Delete">🗑</button>
</td> </td>
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table> </table>
@@ -830,7 +830,7 @@ Object.assign(UI, {
${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''} ${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''}
</td> </td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn icon-btn-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button> <button class="icon-btn icon-btn-danger" data-action="removeTeamMember" data-args='${JSON.stringify([teamId, m.id, esc(m.email)])}' title="Remove">🗑</button>
</td> </td>
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table> </table>
@@ -885,8 +885,8 @@ Object.assign(UI, {
<td>${scopeBadge}</td> <td>${scopeBadge}</td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td> <td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button> <button class="icon-btn" data-action="UI.openGroupDetail" data-args='${JSON.stringify([g.id, esc(g.name)])}' title="Members">👥</button>
<button class="icon-btn icon-btn-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button> <button class="icon-btn icon-btn-danger" data-action="deleteGroup" data-args='${JSON.stringify([g.id, esc(g.name)])}' title="Delete">🗑</button>
</td> </td>
</tr>`; </tr>`;
}).join('')}</tbody> }).join('')}</tbody>
@@ -1011,7 +1011,7 @@ Object.assign(UI, {
</td> </td>
<td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td> <td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn icon-btn-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button> <button class="icon-btn icon-btn-danger" data-action="removeGroupMember" data-args='${JSON.stringify([groupId, m.user_id, esc(m.username || m.email)])}' title="Remove">🗑</button>
</td> </td>
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table> </table>
@@ -1064,7 +1064,7 @@ Object.assign(UI, {
</label>`).join('') : '<span class="text-muted" style="font-size:12px">No groups exist yet — create one in People → Groups</span>'} </label>`).join('') : '<span class="text-muted" style="font-size:12px">No groups exist yet — create one in People → Groups</span>'}
</div> </div>
<div class="form-row" style="margin-top:8px"> <div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="saveGrant('${resourceType}', '${resourceId}')">Save</button> <button class="btn-small btn-primary" data-action="saveGrant" data-args='${JSON.stringify([resourceType, resourceId])}'>Save</button>
<button class="btn-small" onclick="this.closest('.admin-inline-form').remove()">Cancel</button> <button class="btn-small" onclick="this.closest('.admin-inline-form').remove()">Cancel</button>
</div> </div>
</div>`; </div>`;
@@ -1185,9 +1185,9 @@ Object.assign(UI, {
<td class="admin-user-time">${p.priority}</td> <td class="admin-user-time">${p.priority}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td> <td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button> <button class="icon-btn" data-action="UI._showRoutingForm" data-args='${JSON.stringify([esc(p.id)])}' title="Edit">✎</button>
<button class="icon-btn" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button> <button class="icon-btn" data-action="UI._toggleRoutingPolicy" data-args='${JSON.stringify([esc(p.id), !p.is_active])}' title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button> <button class="icon-btn icon-btn-danger" data-action="UI._deleteRoutingPolicy" data-args='${JSON.stringify([esc(p.id), esc(p.name)])}' title="Delete">🗑</button>
</td> </td>
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table> </table>
@@ -1341,7 +1341,7 @@ Object.assign(UI, {
<td>${esc(o.provider_config_id?.slice(0, 8) || 'any')}</td> <td>${esc(o.provider_config_id?.slice(0, 8) || 'any')}</td>
<td>${esc(o.field)}</td> <td>${esc(o.field)}</td>
<td><strong>${esc(String(o.value))}</strong></td> <td><strong>${esc(String(o.value))}</strong></td>
<td><button class="btn-small btn-danger" onclick="UI._deleteCapOverride('${esc(o.model_id)}', '${esc(o.id)}')">×</button></td> <td><button class="btn-small btn-danger" data-action="UI._deleteCapOverride" data-args='${JSON.stringify([esc(o.model_id), esc(o.id)])}'>×</button></td>
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table>`; </table>`;
} catch (e) { } catch (e) {
@@ -1546,7 +1546,7 @@ Object.assign(UI, {
<td>${esc(ch.owner_name)}</td> <td>${esc(ch.owner_name)}</td>
<td>${ch.message_count}</td> <td>${ch.message_count}</td>
<td>${age}</td> <td>${age}</td>
<td><button class="btn-small btn-danger" onclick="UI._purgeChannel('${ch.id}')">Purge</button></td> <td><button class="btn-small btn-danger" data-action="UI._purgeChannel" data-args='${JSON.stringify([ch.id])}'>Purge</button></td>
</tr>`; </tr>`;
} }
html += '</tbody></table>'; html += '</tbody></table>';
@@ -1570,7 +1570,7 @@ Object.assign(UI, {
}); });
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.ADMIN_SECTIONS = ADMIN_SECTIONS; sb.ns('ADMIN_SECTIONS', ADMIN_SECTIONS);
window.ADMIN_LABELS = ADMIN_LABELS; sb.ns('ADMIN_LABELS', ADMIN_LABELS);
window.ADMIN_LOADERS = ADMIN_LOADERS; sb.ns('ADMIN_LOADERS', ADMIN_LOADERS);
})(); })();

View File

@@ -13,6 +13,46 @@
(function() { (function() {
'use strict'; 'use strict';
// ── Delegated Action Dispatcher ────────────────────
// Buttons use data-action="fnName" data-args='[...]' instead of onclick.
// data-pass-event: prepends the click event as first arg (for positioning).
// data-stop-prop: calls event.stopPropagation() (handled by default).
function _uiDispatch(container) {
if (container._uiBound) return;
container._uiBound = true;
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.stopPropagation();
const action = btn.dataset.action;
const fn = sb.resolve(action);
if (typeof fn !== 'function') {
console.warn('[ui] Unknown action:', action);
return;
}
const args = JSON.parse(btn.dataset.args || '[]');
if (btn.hasAttribute('data-pass-event')) {
fn(e, ...args);
} else if (btn.hasAttribute('data-pass-el')) {
fn(btn, ...args);
} else {
fn(...args);
}
});
}
// Wrapper: toggle archived projects visibility + re-render
function _toggleArchivedProjects() {
App.showArchivedProjects = !App.showArchivedProjects;
UI.renderProjectsSection();
}
// Wrapper: read edit input value at click time, then call submitEdit
function _submitEditFromDOM(messageId) {
const val = document.getElementById('editInput_' + messageId)?.value || '';
submitEdit(messageId, val);
}
// ── Avatar Helper ──────────────────────────── // ── Avatar Helper ────────────────────────────
// Returns HTML for a message avatar: <img> if data URI available, emoji fallback. // Returns HTML for a message avatar: <img> if data URI available, emoji fallback.
function avatarHTML(role, avatarDataURI) { function avatarHTML(role, avatarDataURI) {
@@ -445,10 +485,10 @@ const UI = {
ondragover="onProjectDragOver(event)" ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)" ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${proj.id}')"> ondrop="onProjectDrop(event,'${proj.id}')">
<div class="sb-proj-header" onclick="toggleProjectCollapse('${proj.id}')"> <div class="sb-proj-header" data-action="toggleProjectCollapse" data-args='${JSON.stringify([proj.id])}' >
${collapsed ? dot : `<span class="sb-proj-arrow">${arrow}</span>${dot}<span class="sb-proj-name">${esc(proj.name)}</span>${pinIcon}`} ${collapsed ? dot : `<span class="sb-proj-arrow">${arrow}</span>${dot}<span class="sb-proj-name">${esc(proj.name)}</span>${pinIcon}`}
${!collapsed ? `<span class="sb-proj-count">${chats.length}</span> ${!collapsed ? `<span class="sb-proj-count">${chats.length}</span>
<button class="sb-proj-menu" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Options">⋯</button>` : ''} <button class="sb-proj-menu" data-action="showProjectMenu" data-args='${JSON.stringify([proj.id])}' data-pass-event title="Options">⋯</button>` : ''}
</div>`; </div>`;
if (!isCollapsed && !collapsed) { if (!isCollapsed && !collapsed) {
@@ -462,7 +502,7 @@ const UI = {
}); });
if (hasArchived && !collapsed) { if (hasArchived && !collapsed) {
html += `<button class="sb-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderProjectsSection()"> html += `<button class="sb-show-archived" data-action="_toggleArchivedProjects"">
${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`} ${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`}
</button>`; </button>`;
} }
@@ -506,12 +546,11 @@ const UI = {
: ''; : '';
html += `<div class="sb-channel-item${isActive ? ' active' : ''}" html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
onclick="selectChannel('${ch.id}')" data-action="selectChannel" data-args='${JSON.stringify([ch.id])}' oncontextmenu="showChannelContextMenu(event,'${ch.id}')"
oncontextmenu="showChannelContextMenu(event,'${ch.id}')"
title="${esc(ch.name)}"> title="${esc(ch.name)}">
<span class="sb-ch-icon">${icon}</span> <span class="sb-ch-icon">${icon}</span>
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge} ${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}
<button class="sb-ch-menu" onclick="event.stopPropagation();showChannelContextMenu(event,'${ch.id}')" title="Options">⋯</button>` : unreadBadge} <button class="sb-ch-menu" data-action="showChannelContextMenu" data-args='${JSON.stringify([ch.id])}' data-pass-event title="Options">⋯</button>` : unreadBadge}
</div>`; </div>`;
}); });
@@ -576,12 +615,12 @@ const UI = {
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')" ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')" ondragleave="this.classList.remove('drag-over')"
ondrop="onFolderDrop(event,'${f.id}')"> ondrop="onFolderDrop(event,'${f.id}')">
<div class="sb-folder-header" onclick="UI.toggleFolder('${f.id}')"> <div class="sb-folder-header" data-action="UI.toggleFolder" data-args='${JSON.stringify([f.id])}'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/> <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg> </svg>
<span class="sb-folder-name">${esc(f.name)}</span> <span class="sb-folder-name">${esc(f.name)}</span>
<button class="sb-folder-menu" onclick="event.stopPropagation();showFolderMenu(event,'${f.id}')" title="Folder options">⋯</button> <button class="sb-folder-menu" data-action="showFolderMenu" data-args='${JSON.stringify([f.id])}' data-pass-event title="Folder options">⋯</button>
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span> <span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
</div> </div>
${isOpen ${isOpen
@@ -656,15 +695,14 @@ const UI = {
? '<span class="chat-type-icon" title="Group Chat">👥</span> ' ? '<span class="chat-type-icon" title="Group Chat">👥</span> '
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : ''; : c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
return `<div class="chat-item${active}${indent}" return `<div class="chat-item${active}${indent}"
onclick="selectChat('${c.id}')" data-action="selectChat" data-args='${JSON.stringify([c.id])}' oncontextmenu="showChatContextMenu(event,'${c.id}')"
oncontextmenu="showChatContextMenu(event,'${c.id}')"
draggable="true" draggable="true"
ondragstart="onChatDragStart(event,'${c.id}')" ondragstart="onChatDragStart(event,'${c.id}')"
ondragend="onChatDragEnd(event)"> ondragend="onChatDragEnd(event)">
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" <span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();"
title="Double-click to rename">${typeIcon}${esc(c.title)}</span> title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
<span class="chat-item-time">${time}</span> <span class="chat-item-time">${time}</span>
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button> <button class="chat-item-delete" data-action="deleteChat" data-args='${JSON.stringify([c.id])}' title="Delete">✕</button>
</div>`; </div>`;
}, },
@@ -689,7 +727,7 @@ const UI = {
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length; const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
if (hiddenCount > 0) { if (hiddenCount > 0) {
html += `<div class="message-summary-divider" id="summaryDivider"> html += `<div class="message-summary-divider" id="summaryDivider">
<button class="summary-toggle" onclick="toggleSummarizedHistory()"> <button class="summary-toggle" data-action="toggleSummarizedHistory"">
${hiddenCount} earlier messages summarized ${hiddenCount} earlier messages summarized
</button> </button>
</div> </div>
@@ -779,17 +817,15 @@ const UI = {
const sibTotal = msg.siblingCount || 1; const sibTotal = msg.siblingCount || 1;
const branchNav = hasSiblings ? ` const branchNav = hasSiblings ? `
<div class="branch-nav"> <div class="branch-nav">
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)" <button class="branch-arrow" data-action="switchSibling" data-args='${JSON.stringify([msg.id, -1])}' ${sibPos <= 1 ? 'disabled' : ''} title="Previous version">&#8249;</button>
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">&#8249;</button>
<span class="branch-pos">${sibPos}/${sibTotal}</span> <span class="branch-pos">${sibPos}/${sibTotal}</span>
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)" <button class="branch-arrow" data-action="switchSibling" data-args='${JSON.stringify([msg.id, 1])}' ${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">&#8250;</button>
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">&#8250;</button>
</div>` : ''; </div>` : '';
// Action buttons // Action buttons
const msgId = msg.id ? esc(msg.id) : ''; const msgId = msg.id ? esc(msg.id) : '';
const editBtn = isSelf && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : ''; const editBtn = isSelf && msgId ? `<button class="msg-action-btn" data-action="editMessage" data-args='${JSON.stringify([msgId])}' title="Edit">Edit</button>` : '';
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : ''; const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" data-action="regenerateMessage" data-args='${JSON.stringify([msgId])}' title="Regenerate">Regen</button>` : '';
return ` return `
<div class="message ${roleClass}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}"> <div class="message ${roleClass}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
@@ -803,8 +839,8 @@ const UI = {
<div class="msg-actions"> <div class="msg-actions">
${editBtn} ${editBtn}
${regenBtn} ${regenBtn}
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button> <button class="msg-action-btn" data-action="UI.copyMessage" data-args='${JSON.stringify([index])}'" title="Copy">Copy</button>
<button class="msg-action-btn" onclick="saveMessageToNote(${index})" title="Save to note">Note</button> <button class="msg-action-btn" data-action="saveMessageToNote" data-args='${JSON.stringify([index])}'" title="Save to note">Note</button>
</div> </div>
</div> </div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''} ${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
@@ -845,21 +881,21 @@ const UI = {
`<span>with <b>${esc(name)}</b></span>` + `<span>with <b>${esc(name)}</b></span>` +
`<span class="ctx-hint">@mention a persona to bring in AI</span>` + `<span class="ctx-hint">@mention a persona to bring in AI</span>` +
`<span style="flex:1;"></span>` + `<span style="flex:1;"></span>` +
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`; `<button class="ctx-participants-btn" data-action="openParticipantsPanel" data-args='${JSON.stringify([ac.id])}' title="Manage participants">👥 Members</button>`;
} else if (ac.type === 'group') { } else if (ac.type === 'group') {
html = `<span class="ctx-mode">Group</span>` + html = `<span class="ctx-mode">Group</span>` +
`<span class="ctx-hint">No @mention = leader responds · @mention to route · @all for everyone</span>` + `<span class="ctx-hint">No @mention = leader responds · @mention to route · @all for everyone</span>` +
`<span style="flex:1;"></span>` + `<span style="flex:1;"></span>` +
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`; `<button class="ctx-participants-btn" data-action="openParticipantsPanel" data-args='${JSON.stringify([ac.id])}' title="Manage participants">👥 Members</button>`;
} else if (ac.type === 'channel') { } else if (ac.type === 'channel') {
const ch = (App.channels || []).find(c => c.id === ac.id); const ch = (App.channels || []).find(c => c.id === ac.id);
const mode = ch?.aiMode || 'auto'; const mode = ch?.aiMode || 'auto';
const topic = ch?.topic || ''; const topic = ch?.topic || '';
const modeLabel = mode === 'auto' ? 'AI: auto' : mode === 'mention_only' ? 'AI: @mention only' : 'AI: off'; const modeLabel = mode === 'auto' ? 'AI: auto' : mode === 'mention_only' ? 'AI: @mention only' : 'AI: off';
html = `<button class="ctx-mode ctx-mode-btn" onclick="cycleChannelAiMode('${ac.id}')" title="Click to change AI mode">${esc(modeLabel)}</button>`; html = `<button class="ctx-mode ctx-mode-btn" data-action="cycleChannelAiMode" data-args='${JSON.stringify([ac.id])}' title="Click to change AI mode">${esc(modeLabel)}</button>`;
if (topic) html += `<span class="ctx-topic">${esc(topic)}</span>`; if (topic) html += `<span class="ctx-topic">${esc(topic)}</span>`;
html += `<span style="flex:1;"></span>`; html += `<span style="flex:1;"></span>`;
html += `<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`; html += `<button class="ctx-participants-btn" data-action="openParticipantsPanel" data-args='${JSON.stringify([ac.id])}' title="Manage participants">👥 Members</button>`;
} }
el.innerHTML = html; el.innerHTML = html;
el.style.display = ''; el.style.display = '';
@@ -910,8 +946,8 @@ const UI = {
let actions = ''; let actions = '';
if (status === 'active' && API.isAdmin) { if (status === 'active' && API.isAdmin) {
actions = actions =
'<button class="btn-small btn-primary" onclick="advanceWorkflowStage()">Advance ▸</button>' + '<button class="btn-small btn-primary" data-action="advanceWorkflowStage"">Advance ▸</button>' +
(current > 0 ? '<button class="btn-small btn-danger" onclick="rejectWorkflowStage()">◂ Reject</button>' : ''); (current > 0 ? '<button class="btn-small btn-danger" data-action="rejectWorkflowStage"">◂ Reject</button>' : '');
} }
el.innerHTML = el.innerHTML =
@@ -942,8 +978,8 @@ const UI = {
textEl.innerHTML = ` textEl.innerHTML = `
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea> <textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
<div class="msg-edit-actions"> <div class="msg-edit-actions">
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button> <button class="msg-edit-cancel" data-action="cancelEdit">Cancel</button>
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button> <button class="msg-edit-submit" data-action="_submitEditFromDOM" data-args='${JSON.stringify([messageId])}'">Submit & Regenerate</button>
</div>`; </div>`;
// Focus and select all // Focus and select all
@@ -1604,9 +1640,15 @@ const UI = {
} }
}; };
// ── Body-level delegation for data-action buttons ──
// Wired once, handles all data-action clicks across sidebar, messages, etc.
_uiDispatch(document.body);
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.UI = UI; sb.ns('UI', UI);
window.renderPersonaForm = renderPersonaForm; sb.register('renderPersonaForm', renderPersonaForm);
// onclick handler (called from innerHTML) sb.register('_uiDispatch', _uiDispatch);
window.toggleSummarizedHistory = toggleSummarizedHistory; sb.register('_toggleArchivedProjects', _toggleArchivedProjects);
sb.register('_submitEditFromDOM', _submitEditFromDOM);
sb.register('toggleSummarizedHistory', toggleSummarizedHistory);
})(); })();

View File

@@ -179,7 +179,7 @@ function _formatMarked(text) {
const decoded = _decodeHTML(code); const decoded = _decodeHTML(code);
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer); const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
if (handled && fakeContainer.innerHTML) { if (handled && fakeContainer.innerHTML) {
const popBtn = `<button class="ext-popout-btn" onclick="popOutExtBlock('${codeId}')" title="Open in side panel">⧉</button>`; const popBtn = `<button class="ext-popout-btn" data-action="popOutExtBlock" data-args='${JSON.stringify([codeId])}'" title="Open in side panel">⧉</button>`;
return `<div class="ext-rendered" data-ext-block="${codeId}" data-ext-lang="${lang}">${popBtn}${fakeContainer.innerHTML}</div>`; return `<div class="ext-rendered" data-ext-block="${codeId}" data-ext-lang="${lang}">${popBtn}${fakeContainer.innerHTML}</div>`;
} }
if (handled && !fakeContainer.innerHTML) { if (handled && !fakeContainer.innerHTML) {
@@ -197,16 +197,16 @@ function _formatMarked(text) {
// Build toolbar buttons // Build toolbar buttons
const collapseBtn = lineCount > 5 const collapseBtn = lineCount > 5
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${codeId}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>` ? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([codeId])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
: ''; : '';
let toolbar = collapseBtn; let toolbar = collapseBtn;
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`; toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([codeId])}'">Copy</button>`;
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${codeId}','${lang}')">Download</button>`; toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([codeId, lang])}'">Download</button>`;
// HTML preview button (only for HTML-like content) // HTML preview button (only for HTML-like content)
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) { if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${codeId}')">Preview</button>`; toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([codeId])}'">Preview</button>`;
} }
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : ''; const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
@@ -226,15 +226,15 @@ function _formatBasic(text) {
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD; const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
const collapseBtn = lineCount > 5 const collapseBtn = lineCount > 5
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${id}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>` ? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([id])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
: ''; : '';
let toolbar = collapseBtn; let toolbar = collapseBtn;
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`; toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([id])}'">Copy</button>`;
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${id}','${lang}')">Download</button>`; toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([id, lang])}'">Download</button>`;
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) { if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`; toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([id])}'">Preview</button>`;
} }
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : ''; const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
@@ -531,7 +531,7 @@ function _renderToolCallsHTML(toolCalls) {
try { try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result; const parsed = typeof result === 'string' ? JSON.parse(result) : result;
if (parsed.id) { if (parsed.id) {
noteLink = `<button class="tool-note-link" onclick="openNotes(); setTimeout(() => openNoteEditor('${esc(parsed.id)}'), 300)">📝 View</button>`; noteLink = `<button class="tool-note-link" data-action="_openNoteFromTool" data-args='${JSON.stringify([esc(parsed.id)])}'">📝 View</button>`;
} }
} catch (e) { /* not JSON */ } } catch (e) { /* not JSON */ }
} }
@@ -584,18 +584,30 @@ function runExtensionPostRender(container) {
} }
} }
// ── Wrappers for compound onclick expressions ──
function _copyCodeBlock(codeId) {
const el = document.getElementById(codeId);
if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
}
function _openNoteFromTool(noteId) {
openNotes();
setTimeout(() => openNoteEditor(noteId), 300);
}
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
// Cross-file function calls // Cross-file function calls
window.formatMessage = formatMessage; sb.register('formatMessage', formatMessage);
window.clearPreview = clearPreview; sb.register('clearPreview', clearPreview);
window.runExtensionPostRender = runExtensionPostRender; sb.register('runExtensionPostRender', runExtensionPostRender);
window._livePreviewUpdate = _livePreviewUpdate; sb.register('_livePreviewUpdate', _livePreviewUpdate);
window._relativeTime = _relativeTime; sb.register('_relativeTime', _relativeTime);
window._renderToolCallsHTML = _renderToolCallsHTML; sb.register('_renderToolCallsHTML', _renderToolCallsHTML);
window._registerPreviewPanel = _registerPreviewPanel; sb.register('_registerPreviewPanel', _registerPreviewPanel);
// onclick handlers (called from innerHTML) // data-action handlers
window.toggleCodeCollapse = toggleCodeCollapse; sb.register('toggleCodeCollapse', toggleCodeCollapse);
window.toggleHTMLPreview = toggleHTMLPreview; sb.register('toggleHTMLPreview', toggleHTMLPreview);
window.downloadCode = downloadCode; sb.register('downloadCode', downloadCode);
window.popOutExtBlock = popOutExtBlock; sb.register('popOutExtBlock', popOutExtBlock);
sb.register('_copyCodeBlock', _copyCodeBlock);
sb.register('_openNoteFromTool', _openNoteFromTool);
})(); })();

View File

@@ -1116,21 +1116,21 @@ function mountAvatarUpload(containerEl, opts = {}) {
} }
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.esc = esc; sb.register('esc', esc);
window.createComponentRegistry = createComponentRegistry; sb.register('createComponentRegistry', createComponentRegistry);
window.componentMixin = componentMixin; sb.register('componentMixin', componentMixin);
window.Providers = Providers; sb.ns('Providers', Providers);
window.Roles = Roles; sb.ns('Roles', Roles);
window.renderCapBadges = renderCapBadges; sb.register('renderCapBadges', renderCapBadges);
window.renderProviderForm = renderProviderForm; sb.register('renderProviderForm', renderProviderForm);
window.renderProviderList = renderProviderList; sb.register('renderProviderList', renderProviderList);
window.renderRoleConfig = renderRoleConfig; sb.register('renderRoleConfig', renderRoleConfig);
window.renderUsageDashboard = renderUsageDashboard; sb.register('renderUsageDashboard', renderUsageDashboard);
window.showConfirm = showConfirm; sb.register('showConfirm', showConfirm);
window.showPrompt = showPrompt; sb.register('showPrompt', showPrompt);
window.openModal = openModal; sb.register('openModal', openModal);
window.closeModal = closeModal; sb.register('closeModal', closeModal);
window.checkTabsOverflow = checkTabsOverflow; sb.register('checkTabsOverflow', checkTabsOverflow);
window.Theme = Theme; sb.ns('Theme', Theme);
window.mountAvatarUpload = mountAvatarUpload; sb.register('mountAvatarUpload', mountAvatarUpload);
})(); })();

View File

@@ -329,7 +329,7 @@ Object.assign(UI, {
} }
section.style.display = ''; section.style.display = '';
el.innerHTML = teams.map(t => ` el.innerHTML = teams.map(t => `
<div class="team-card"${t.my_role === 'admin' ? ` style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')"` : ''}> <div class="team-card"${t.my_role === 'admin' ? ` style="cursor:pointer" data-action="UI.openTeamManage" data-args='${JSON.stringify([t.id, esc(t.name)])}'"` : ''}>
<div class="team-card-info"> <div class="team-card-info">
<strong>${esc(t.name)}</strong> <strong>${esc(t.name)}</strong>
<span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span> <span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span>
@@ -362,7 +362,7 @@ Object.assign(UI, {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin'); const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
const titleEl = document.getElementById('teamAdminTitle'); const titleEl = document.getElementById('teamAdminTitle');
if (adminTeams.length > 1) { if (adminTeams.length > 1) {
titleEl.innerHTML = `<span class="team-admin-back" onclick="UI.showTeamPicker()" title="Back to teams">←</span> ${esc(teamName)}`; titleEl.innerHTML = `<span class="team-admin-back" data-action="UI.showTeamPicker" " title="Back to teams">←</span> ${esc(teamName)}`;
} else { } else {
titleEl.textContent = teamName; titleEl.textContent = teamName;
} }
@@ -409,7 +409,7 @@ Object.assign(UI, {
</select> </select>
</td> </td>
<td class="admin-actions-cell"> <td class="admin-actions-cell">
<button class="icon-btn icon-btn-danger" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button> <button class="icon-btn icon-btn-danger" data-action="settingsRemoveTeamMember" data-args='${JSON.stringify([teamId, m.id, esc(m.email)])}'" title="Remove">🗑</button>
</td> </td>
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table> </table>
@@ -521,7 +521,7 @@ Object.assign(UI, {
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong> <strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
<div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</div> <div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
</div> </div>
<button class="btn-delete" onclick="settingsDeleteTeamPersona('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button> <button class="btn-delete" data-action="settingsDeleteTeamPersona" data-args='${JSON.stringify([teamId, p.id, esc(p.name)])}'" title="Delete">✕</button>
</div>`; </div>`;
}).join('') || '<div class="empty-hint">No team personas — create one to give your team preconfigured models</div>'; }).join('') || '<div class="empty-hint">No team personas — create one to give your team preconfigured models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
@@ -812,8 +812,8 @@ Object.assign(UI, {
let html = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;"> let html = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px">${visibleCount} visible, ${hiddenCount} hidden of ${models.length} total</span> <span class="text-muted" style="font-size:12px">${visibleCount} visible, ${hiddenCount} hidden of ${models.length} total</span>
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn-small" onclick="bulkSetUserModelVisibility(true)">Show All</button> <button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[true]'">Show All</button>
<button class="btn-small" onclick="bulkSetUserModelVisibility(false)">Hide All</button> <button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[false]'">Hide All</button>
</div>`; </div>`;
html += models.map(m => { html += models.map(m => {
@@ -832,7 +832,7 @@ Object.assign(UI, {
<span class="model-caps-inline">${badges}</span> <span class="model-caps-inline">${badges}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span> <span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src} ${src}
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', '${esc(cfgId)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button> <button class="admin-model-toggle ${toggleCls}" data-action="toggleUserModelVisibility" data-args='${JSON.stringify([esc(mid), esc(cfgId), hidden])}'" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
</div>`; </div>`;
}).join(''); }).join('');
el.innerHTML = html; el.innerHTML = html;
@@ -858,7 +858,7 @@ Object.assign(UI, {
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div> <div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
</div> </div>
<div class="persona-actions"> <div class="persona-actions">
<button class="btn-delete" onclick="deleteUserPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button> <button class="btn-delete" data-action="deleteUserPersona" data-args='${JSON.stringify([p.id, esc(p.name)])}'" title="Delete">✕</button>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');

View File

@@ -138,5 +138,5 @@ const UserMenu = {
}; };
// ── Exports ───────────────────────────────── // ── Exports ─────────────────────────────────
window.UserMenu = UserMenu; sb.ns('UserMenu', UserMenu);
})(); })();

View File

@@ -126,7 +126,7 @@
// ── Loader (called from ADMIN_LOADERS.workflows) ── // ── Loader (called from ADMIN_LOADERS.workflows) ──
window._loadAdminWorkflows = async function() { sb.register('_loadAdminWorkflows', async function() {
// Self-scaffold: inject our HTML into adminDynamic // Self-scaffold: inject our HTML into adminDynamic
var target = document.getElementById('adminDynamic'); var target = document.getElementById('adminDynamic');
if (target && !document.getElementById('wfAdminList')) { if (target && !document.getElementById('wfAdminList')) {
@@ -148,18 +148,18 @@
list.innerHTML = list.innerHTML =
'<div class="empty-hint">No workflows yet</div>' + '<div class="empty-hint">No workflows yet</div>' +
'<button class="btn-small btn-primary" style="margin-top:8px" ' + '<button class="btn-small btn-primary" style="margin-top:8px" ' +
'onclick="_wfCreate()">+ Create Workflow</button>'; 'data-action="_wfCreate">+ Create Workflow</button>';
return; return;
} }
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' + let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' + '<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" onclick="_wfCreate()">+ New</button></div>'; '<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button></div>';
html += '<table class="admin-table"><thead><tr>' + html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' + '<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>'; '</tr></thead><tbody>';
for (const wf of wfs) { for (const wf of wfs) {
html += '<tr style="cursor:pointer" onclick="_wfOpen(\'' + wf.id + '\')">' + html += '<tr style="cursor:pointer" data-action="_wfOpen" data-args=\'' + JSON.stringify([wf.id]) + '\'>' +
'<td>' + esc(wf.name) + '</td>' + '<td>' + esc(wf.name) + '</td>' +
'<td><code>' + esc(wf.slug) + '</code></td>' + '<td><code>' + esc(wf.slug) + '</code></td>' +
'<td>' + esc(wf.entry_mode || 'authenticated') + '</td>' + '<td>' + esc(wf.entry_mode || 'authenticated') + '</td>' +
@@ -172,11 +172,11 @@
} catch (e) { } catch (e) {
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>'; list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
} }
}; });
// ── Create ────────────────────────────── // ── Create ──────────────────────────────
window._wfCreate = async function() { sb.register('_wfCreate', async function() {
const name = prompt('Workflow name:'); const name = prompt('Workflow name:');
if (!name) return; if (!name) return;
try { try {
@@ -186,11 +186,11 @@
} catch (e) { } catch (e) {
UI.toast('Failed: ' + e.message, 'error'); UI.toast('Failed: ' + e.message, 'error');
} }
}; });
// ── Open Detail ───────────────────────── // ── Open Detail ─────────────────────────
window._wfOpen = async function(id) { sb.register('_wfOpen', async function(id) {
const list = document.getElementById('wfAdminList'); const list = document.getElementById('wfAdminList');
const detail = document.getElementById('wfAdminDetail'); const detail = document.getElementById('wfAdminDetail');
try { try {
@@ -227,7 +227,7 @@
} catch (e) { } catch (e) {
UI.toast('Failed to load workflow: ' + e.message, 'error'); UI.toast('Failed to load workflow: ' + e.message, 'error');
} }
}; });
// ── Stage List ────────────────────────── // ── Stage List ──────────────────────────
@@ -255,8 +255,8 @@
'<span class="wf-stage-name">' + esc(s.name) + '</span>' + '<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel + personaLabel +
'<span class="badge">' + (s.history_mode || 'full') + '</span>' + '<span class="badge">' + (s.history_mode || 'full') + '</span>' +
'<button class="btn-small" onclick="_wfEditStage(\'' + s.id + '\')">Edit</button>' + '<button class="btn-small" data-action="_wfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
'<button class="btn-small btn-danger" onclick="_wfDeleteStage(\'' + s.id + '\')">×</button>' + '<button class="btn-small btn-danger" data-action="_wfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
'</div>'; '</div>';
}); });
el.innerHTML = html; el.innerHTML = html;
@@ -437,7 +437,7 @@
// ── Edit / Delete Stage ───────────────── // ── Edit / Delete Stage ─────────────────
window._wfEditStage = async function(stageId) { sb.register('_wfEditStage', async function(stageId) {
if (!_currentWf) return; if (!_currentWf) return;
try { try {
const resp = await API.listStages(_currentWf.id); const resp = await API.listStages(_currentWf.id);
@@ -456,9 +456,9 @@
} catch (e) { } catch (e) {
UI.toast('Failed: ' + e.message, 'error'); UI.toast('Failed: ' + e.message, 'error');
} }
}; });
window._wfDeleteStage = async function(stageId) { sb.register('_wfDeleteStage', async function(stageId) {
if (!_currentWf) return; if (!_currentWf) return;
if (!confirm('Delete this stage?')) return; if (!confirm('Delete this stage?')) return;
try { try {
@@ -468,6 +468,6 @@
} catch (e) { } catch (e) {
UI.toast('Failed: ' + e.message, 'error'); UI.toast('Failed: ' + e.message, 'error');
} }
}; });
})(); })();

View File

@@ -9,7 +9,7 @@
(function() { (function() {
'use strict'; 'use strict';
window.WorkflowQueue = { const WorkflowQueue = {
_loaded: false, _loaded: false,
_myAssignments: [], _myAssignments: [],
@@ -28,7 +28,7 @@
section.className = 'sb-section'; section.className = 'sb-section';
section.id = 'sbSectionQueue'; section.id = 'sbSectionQueue';
section.innerHTML = section.innerHTML =
'<div class="sb-section-header" onclick="UI.toggleSidebarSection(\'queue\')">' + '<div class="sb-section-header" data-action="UI.toggleSidebarSection" data-args=\'["queue"]\'>' +
'<span class="sb-section-arrow" id="sbArrowQueue">▾</span>' + '<span class="sb-section-arrow" id="sbArrowQueue">▾</span>' +
'<span class="sb-section-label">Workflows</span>' + '<span class="sb-section-label">Workflows</span>' +
'<span class="sb-queue-badge" id="sbQueueBadge" style="display:none">0</span>' + '<span class="sb-queue-badge" id="sbQueueBadge" style="display:none">0</span>' +
@@ -79,7 +79,7 @@
workflows.forEach(function(wf) { workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id; var isActive = App.activeId === wf.id;
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' + html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
'onclick="selectChannel(\'' + wf.id + '\')" ' + 'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' +
'title="' + esc(wf.title) + '">' + 'title="' + esc(wf.title) + '">' +
'<span class="sb-queue-icon">⚡</span>' + '<span class="sb-queue-icon">⚡</span>' +
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' + '<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
@@ -93,10 +93,10 @@
if (alreadyShown) return; if (alreadyShown) return;
html += '<div class="sb-queue-item" ' + html += '<div class="sb-queue-item" ' +
'onclick="WorkflowQueue.openAssignment(\'' + a.id + '\',\'' + a.channel_id + '\')">' + 'data-action="WorkflowQueue.openAssignment" data-args=\'' + JSON.stringify([a.id, a.channel_id]) + '\'>' +
'<span class="sb-queue-icon">📋</span>' + '<span class="sb-queue-icon">📋</span>' +
'<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' + '<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' +
'<button class="btn-small" onclick="event.stopPropagation();WorkflowQueue.complete(\'' + a.id + '\')">✓</button>' + '<button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>✓</button>' +
'</div>'; '</div>';
}); });
} }
@@ -158,7 +158,7 @@
uItems.forEach(function(a) { uItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</td>' + html += '<tr><td>Stage ' + a.stage + '</td>' +
'<td>' + new Date(a.created_at).toLocaleDateString() + '</td>' + '<td>' + new Date(a.created_at).toLocaleDateString() + '</td>' +
'<td><button class="btn-small btn-primary" onclick="WorkflowQueue.claim(\'' + a.id + '\')">Claim</button></td></tr>'; '<td><button class="btn-small btn-primary" data-action="WorkflowQueue.claim" data-args=\'' + JSON.stringify([a.id]) + '\'>Claim</button></td></tr>';
}); });
html += '</tbody></table>'; html += '</tbody></table>';
} }
@@ -169,7 +169,7 @@
cItems.forEach(function(a) { cItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</td>' + html += '<tr><td>Stage ' + a.stage + '</td>' +
'<td>' + (a.assigned_to || '—') + '</td>' + '<td>' + (a.assigned_to || '—') + '</td>' +
'<td><button class="btn-small" onclick="WorkflowQueue.complete(\'' + a.id + '\')">Complete</button></td></tr>'; '<td><button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>Complete</button></td></tr>';
}); });
html += '</tbody></table>'; html += '</tbody></table>';
} }
@@ -187,6 +187,7 @@
} }
}, },
}; };
sb.ns('WorkflowQueue', WorkflowQueue);
// Auto-init when chat surface loads // Auto-init when chat surface loads
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {