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
## [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
### 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
- [ ] ICD runner gains test tiers: `crud`, `envelope`, `security`, `sdk`
- [ ] Runner coverage target: 100% of ICD-documented endpoints have at least one test
- [ ] Phase 2: onclick → addEventListener migration (143 dynamic + ~50 template handlers)
- [ ] Phase 3: ES module conversion
- [ ] Phase 4: Template handler shim
- [x] Phase 2: onclick → addEventListener migration (143 dynamic → 5 unconvertible,
163 data-action delegated via `_uiDispatch` on document.body)
- [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)
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}}
</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/api.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}">

View File

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

View File

@@ -198,5 +198,5 @@ async function _loadSurfaceList() {
// ── 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); }
}
window.App = App;
window.fetchModels = fetchModels;
sb.ns('App', App);
sb.register('fetchModels', fetchModels);
})();

View File

@@ -30,17 +30,17 @@ async function init() {
`<strong>Network proxy blocked this request</strong><br>` +
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
`<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') {
splashErr.innerHTML =
`<strong>Connection timed out</strong><br>` +
`<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 {
splashErr.innerHTML =
`<strong>Cannot reach server</strong><br>` +
`<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);
return;
@@ -582,10 +582,10 @@ async function initBanners() {
document.addEventListener('DOMContentLoaded', init);
// ── Exports ─────────────────────────────────
window.handleLogin = handleLogin;
window.handleRegister = handleRegister;
window.handleLogout = handleLogout;
window.switchAuthTab = switchAuthTab;
window.startApp = startApp;
window.initBanners = initBanners;
sb.register('handleLogin', handleLogin);
sb.register('handleRegister', handleRegister);
sb.register('handleLogout', handleLogout);
sb.register('switchAuthTab', switchAuthTab);
sb.register('startApp', startApp);
sb.register('initBanners', initBanners);
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,5 +99,5 @@ function _clientPos(e, isHoriz) {
}
// ── 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 ─────────────────────────────────
window.Extensions = Extensions;
sb.ns('Extensions', Extensions);
})();

View File

@@ -268,5 +268,5 @@ function _ftFileIcon(name) {
}
// ── 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-meta">${esc(size)}${label ? ' · ' + label : ''}</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>`;
}).join('');
}
@@ -423,7 +423,7 @@ function renderMessageFiles(msgId) {
const visionHint = !hasVision
? '<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)}"
style="max-width:${w}px" loading="lazy">
${visionHint}
@@ -433,7 +433,7 @@ function renderMessageFiles(msgId) {
// Document chip
const icon = _docIcon(att.content_type);
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-info">
<span class="att-doc-name">${esc(att.filename)}</span>
@@ -839,22 +839,22 @@ function _hasDragFiles(e) {
}
// ── Exports ─────────────────────────────────
window._initFileListeners = _initFileListeners;
window._pollTimers = _pollTimers;
window._startPolling = _startPolling;
window._stopPolling = _stopPolling;
window.clearStaged = clearStaged;
window.closeLightbox = closeLightbox;
window.consumeStaged = consumeStaged;
window.getStagedFileIds = getStagedFileIds;
window.hasStagedFiles = hasStagedFiles;
window.initFiles = initFiles;
window.isSendBlocked = isSendBlocked;
window.loadAdminStorage = loadAdminStorage;
window.loadAuthImages = loadAuthImages;
window.loadChannelFiles = loadChannelFiles;
window.renderMessageFiles = renderMessageFiles;
window.unstageFile = unstageFile;
window.openLightbox = openLightbox;
window.downloadFile = downloadFile;
sb.register('_initFileListeners', _initFileListeners);
sb.register('_pollTimers', _pollTimers);
sb.register('_startPolling', _startPolling);
sb.register('_stopPolling', _stopPolling);
sb.register('clearStaged', clearStaged);
sb.register('closeLightbox', closeLightbox);
sb.register('consumeStaged', consumeStaged);
sb.register('getStagedFileIds', getStagedFileIds);
sb.register('hasStagedFiles', hasStagedFiles);
sb.register('initFiles', initFiles);
sb.register('isSendBlocked', isSendBlocked);
sb.register('loadAdminStorage', loadAdminStorage);
sb.register('loadAuthImages', loadAuthImages);
sb.register('loadChannelFiles', loadChannelFiles);
sb.register('renderMessageFiles', renderMessageFiles);
sb.register('unstageFile', unstageFile);
sb.register('openLightbox', openLightbox);
sb.register('downloadFile', downloadFile);
})();

View File

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

View File

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

View File

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

View File

@@ -495,10 +495,10 @@ function invalidateNoteGraph() {
}
// ── Exports ─────────────────────────────────
window.openNoteGraph = openNoteGraph;
window.closeNoteGraph = closeNoteGraph;
window._render = _render;
window._graphResetZoom = _graphResetZoom;
window._graphToggleOrphans = _graphToggleOrphans;
window.invalidateNoteGraph = invalidateNoteGraph;
sb.register('openNoteGraph', openNoteGraph);
sb.register('closeNoteGraph', closeNoteGraph);
sb.register('_render', _render);
sb.register('_graphResetZoom', _graphResetZoom);
sb.register('_graphToggleOrphans', _graphToggleOrphans);
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-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
<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 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">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
@@ -422,7 +422,7 @@ async function _loadBacklinks(noteId) {
countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none';
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-folder text-muted">${esc(n.folder_path)}</span>
</div>
@@ -454,12 +454,12 @@ function _renderWikilinksInReadMode(containerEl) {
const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<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 class="transclusion-content"><span class="text-muted">Loading…</span></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;
@@ -818,7 +818,7 @@ function _registerNotesPanel() {
</div>
<!-- Backlinks -->
<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>
</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>
<div style="flex:1"></div>
<button class="btn-small" onclick="_graphToggleOrphans()" title="Toggle orphan nodes">Orphans</button>
<button class="btn-small" onclick="_graphResetZoom()" title="Reset zoom">Reset</button>
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>
</div>
<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>
</div>`;
@@ -944,17 +944,33 @@ function _initNotesListeners() {
// 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 ─────────────────────────────────
window._currentNote = _currentNote;
window._editingNoteId = _editingNoteId;
window._initNotesListeners = _initNotesListeners;
window._registerNotesPanel = _registerNotesPanel;
window.deleteNote = deleteNote;
window.openNoteEditor = openNoteEditor;
window.openNotes = openNotes;
window.saveMessageToNote = saveMessageToNote;
window.saveNote = saveNote;
window._toggleNoteSelect = _toggleNoteSelect;
window._navigateToLinkedNote = _navigateToLinkedNote;
window.toggleBacklinks = toggleBacklinks;
sb.register('_currentNote', _currentNote);
sb.register('_editingNoteId', _editingNoteId);
sb.register('_initNotesListeners', _initNotesListeners);
sb.register('_registerNotesPanel', _registerNotesPanel);
sb.register('deleteNote', deleteNote);
sb.register('openNoteEditor', openNoteEditor);
sb.register('openNotes', openNotes);
sb.register('saveMessageToNote', saveMessageToNote);
sb.register('saveNote', saveNote);
sb.register('_toggleNoteSelect', _toggleNoteSelect);
sb.register('_navigateToLinkedNote', _navigateToLinkedNote);
sb.register('toggleBacklinks', toggleBacklinks);
sb.register('_toggleNoteSelectCb', _toggleNoteSelectCb);
sb.register('_noteItemClick', _noteItemClick);
})();

View File

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

View File

@@ -120,7 +120,7 @@ const Notifications = {
let html = `<div class="notif-dd-header">
<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">`;
for (const n of items.slice(0, 10)) {
@@ -128,7 +128,7 @@ const Notifications = {
const dot = unread ? '●' : '○';
const cls = unread ? 'notif-item notif-unread' : 'notif-item';
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>
<div class="notif-body">
<div class="notif-title">${esc(n.title)}</div>
@@ -140,7 +140,7 @@ const Notifications = {
html += `</div>`;
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>`;
return html;
@@ -227,12 +227,12 @@ const Notifications = {
el.style.display = 'none';
el.innerHTML = `<div class="notif-panel" id="notifPanelInner">
<div class="notif-panel-toolbar">
<button class="btn-small" onclick="Notifications.markAllRead()">Mark all read</button>
<button class="btn-small btn-danger" onclick="Notifications.clearAll()">Clear all</button>
<button class="btn-small" data-action="Notifications.markAllRead">Mark all read</button>
<button class="btn-small btn-danger" data-action="Notifications.clearAll">Clear all</button>
</div>
<div class="notif-panel-list" id="notifPanelList"></div>
<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>`;
body.appendChild(el);
@@ -278,7 +278,7 @@ const Notifications = {
const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item';
const dot = unread ? '●' : '○';
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">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
@@ -286,7 +286,7 @@ const Notifications = {
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</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>`;
}
@@ -430,5 +430,5 @@ function _timeAgo(isoStr) {
}
// ── Exports ─────────────────────────────────
window.Notifications = Notifications;
sb.ns('Notifications', Notifications);
})();

View File

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

View File

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

View File

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

View File

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

View File

@@ -267,18 +267,18 @@ function showChatContextMenu(e, chatId) {
const order = getProjectChannelOrder(chat.projectId);
const idx = order.indexOf(chatId);
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
</button>`;
}
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
</button>`;
}
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
</button>`;
items += '<div class="ctx-divider"></div>';
@@ -287,7 +287,7 @@ function showChatContextMenu(e, chatId) {
App.projects.forEach(p => {
if (p.id === chat.projectId) return; // skip current
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>
</button>`;
});
@@ -297,13 +297,13 @@ function showChatContextMenu(e, chatId) {
}
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…
</button>`;
// Workspace override (v0.21.5)
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…
</button>`;
@@ -312,13 +312,13 @@ function showChatContextMenu(e, chatId) {
if (folders.length > 0 || chat.folderId) {
items += '<div class="ctx-divider"></div>';
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
</button>`;
}
folders.forEach(f => {
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)}
</button>`;
});
@@ -331,6 +331,7 @@ function showChatContextMenu(e, chatId) {
menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';
document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu;
// Dismiss on click outside
@@ -341,6 +342,31 @@ function dismissChatContextMenu() {
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) {
const name = prompt('New project name:');
if (!name || !name.trim()) return;
@@ -467,21 +493,21 @@ function showProjectMenu(e, projectId) {
const menu = document.createElement('div');
menu.className = 'project-ctx-menu';
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}
</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
</button>
<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
</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
</button>
<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
</button>`;
@@ -489,6 +515,7 @@ function showProjectMenu(e, projectId) {
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
}
@@ -684,8 +711,9 @@ async function _renderProjectKBs(projectId) {
list.innerHTML = kbs.map(kb => `
<div class="project-panel-item">
<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('');
_ctxDispatch(list);
} catch (_) {
list.innerHTML = '<div class="project-panel-empty">Failed to load KBs</div>';
}
@@ -733,11 +761,12 @@ async function _showKBPicker() {
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
menu.style.top = (rect.bottom + 4) + 'px';
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)}
</button>`).join('');
document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
} catch (e) {
@@ -768,8 +797,9 @@ async function _renderProjectNotes(projectId) {
list.innerHTML = notes.map(n => `
<div class="project-panel-item">
<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('');
_ctxDispatch(list);
} catch (_) {
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';
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
</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
</button>`;
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
</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')}
</button>`;
}
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
</button>`;
// Delete gated by retention mode (default=flexible allows delete)
const retentionMode = App.policies?.channel_retention_mode || '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
</button>`;
}
@@ -1606,6 +1636,7 @@ function showChannelContextMenu(e, channelId) {
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
}
@@ -1725,17 +1756,18 @@ function showFolderMenu(e, folderId) {
const menu = document.createElement('div');
menu.className = 'project-ctx-menu';
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
</button>
<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
</button>`;
menu.style.left = Math.min(e.clientX, window.innerWidth - 160) + 'px';
menu.style.top = Math.min(e.clientY, window.innerHeight - 100) + 'px';
document.body.appendChild(menu);
_ctxDispatch(menu);
_ctxMenu = menu;
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="font-size:11px;color:var(--text-3);">${esc(p.participant_type)} · ${esc(role)}</span>
${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>`;
}).join('');
_ctxDispatch(el);
} catch (e) {
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 results = overlay.querySelector('#_addPartResults');
_ctxDispatch(results); // delegate data-action clicks on search results
input.focus();
let debounce = null;
@@ -1972,7 +2006,7 @@ async function _showAddParticipantSearch(channelId, pType) {
results.innerHTML = items.slice(0, 10).map(item =>
`<div style="padding:6px 8px;cursor:pointer;border-radius:4px;font-size:13px;"
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)}
</div>`
).join('');
@@ -2008,52 +2042,52 @@ async function _removeParticipant(channelId, recordId) {
}
// ── Exports ─────────────────────────────────
window._registerProjectPanel = _registerProjectPanel;
window.createProject = createProject;
window.cycleChannelAiMode = cycleChannelAiMode;
window.deleteChannel = deleteChannel;
window.deleteFolder = deleteFolder;
window.deleteProject = deleteProject;
window.loadChannels = loadChannels;
window.loadFolders = loadFolders;
window.loadProjectChannelPositions = loadProjectChannelPositions;
window.loadProjects = loadProjects;
window.loadUsers = loadUsers;
window.newChannelOrDM = newChannelOrDM;
window.newFolder = newFolder;
window.onChatDragEnd = onChatDragEnd;
window.onChatDragStart = onChatDragStart;
window.onChatSectionDrop = onChatSectionDrop;
window.onFolderDrop = onFolderDrop;
window.onProjectDragLeave = onProjectDragLeave;
window.onProjectDragOver = onProjectDragOver;
window.onProjectDrop = onProjectDrop;
window.onUnfiledDrop = onUnfiledDrop;
window.openParticipantsPanel = openParticipantsPanel;
window.openProjectPanel = openProjectPanel;
window.selectChannel = selectChannel;
window.showChannelContextMenu = showChannelContextMenu;
window.showChatContextMenu = showChatContextMenu;
window.showFolderMenu = showFolderMenu;
window.showProjectMenu = showProjectMenu;
window.toggleProjectCollapse = toggleProjectCollapse;
window.renameProject = renameProject;
window.setProjectColor = setProjectColor;
window.toggleActiveProject = toggleActiveProject;
window.moveChatToProject = moveChatToProject;
window.dismissChatContextMenu = dismissChatContextMenu;
window.createProjectAndMove = createProjectAndMove;
window.moveChatToFolder = moveChatToFolder;
window._removeProjectKB = _removeProjectKB;
window._addProjectKB = _addProjectKB;
window._removeProjectNote = _removeProjectNote;
window.showWorkspacePicker = showWorkspacePicker;
window._moveChatInProject = _moveChatInProject;
window.renameChannel = renameChannel;
window.editChannelTopic = editChannelTopic;
window.archiveChannel = archiveChannel;
window.renameFolder = renameFolder;
window._showAddParticipantSearch = _showAddParticipantSearch;
window._addParticipant = _addParticipant;
window._removeParticipant = _removeParticipant;
sb.register('_registerProjectPanel', _registerProjectPanel);
sb.register('createProject', createProject);
sb.register('cycleChannelAiMode', cycleChannelAiMode);
sb.register('deleteChannel', deleteChannel);
sb.register('deleteFolder', deleteFolder);
sb.register('deleteProject', deleteProject);
sb.register('loadChannels', loadChannels);
sb.register('loadFolders', loadFolders);
sb.register('loadProjectChannelPositions', loadProjectChannelPositions);
sb.register('loadProjects', loadProjects);
sb.register('loadUsers', loadUsers);
sb.register('newChannelOrDM', newChannelOrDM);
sb.register('newFolder', newFolder);
sb.register('onChatDragEnd', onChatDragEnd);
sb.register('onChatDragStart', onChatDragStart);
sb.register('onChatSectionDrop', onChatSectionDrop);
sb.register('onFolderDrop', onFolderDrop);
sb.register('onProjectDragLeave', onProjectDragLeave);
sb.register('onProjectDragOver', onProjectDragOver);
sb.register('onProjectDrop', onProjectDrop);
sb.register('onUnfiledDrop', onUnfiledDrop);
sb.register('openParticipantsPanel', openParticipantsPanel);
sb.register('openProjectPanel', openProjectPanel);
sb.register('selectChannel', selectChannel);
sb.register('showChannelContextMenu', showChannelContextMenu);
sb.register('showChatContextMenu', showChatContextMenu);
sb.register('showFolderMenu', showFolderMenu);
sb.register('showProjectMenu', showProjectMenu);
sb.register('toggleProjectCollapse', toggleProjectCollapse);
sb.register('renameProject', renameProject);
sb.register('setProjectColor', setProjectColor);
sb.register('toggleActiveProject', toggleActiveProject);
sb.register('moveChatToProject', moveChatToProject);
sb.register('dismissChatContextMenu', dismissChatContextMenu);
sb.register('createProjectAndMove', createProjectAndMove);
sb.register('moveChatToFolder', moveChatToFolder);
sb.register('_removeProjectKB', _removeProjectKB);
sb.register('_addProjectKB', _addProjectKB);
sb.register('_removeProjectNote', _removeProjectNote);
sb.register('showWorkspacePicker', showWorkspacePicker);
sb.register('_moveChatInProject', _moveChatInProject);
sb.register('renameChannel', renameChannel);
sb.register('editChannelTopic', editChannelTopic);
sb.register('archiveChannel', archiveChannel);
sb.register('renameFolder', renameFolder);
sb.register('_showAddParticipantSearch', _showAddParticipantSearch);
sb.register('_addParticipant', _addParticipant);
sb.register('_removeParticipant', _removeParticipant);
})();

View File

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

View File

@@ -239,7 +239,7 @@
}
}
window._loadAdminTasks = async function() {
sb.register('_loadAdminTasks', async function() {
var container = document.getElementById('adminDynamic');
if (!container) return;
container.innerHTML = SCAFFOLD_HTML;
@@ -251,5 +251,5 @@
document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig);
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>' +
'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">';
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-size:11px;color:var(--text-2);margin-top:4px">' + esc(tmpl.schedule) + '</div>' +
'</div>';
@@ -269,13 +269,13 @@
}
// Template launcher
window._createFromTemplate = function(name) {
sb.register('_createFromTemplate', function(name) {
var tmpl = TEMPLATES.find(function(t) { return t.name === name; });
if (tmpl) showCreateForm({ name: tmpl.name, prompt: tmpl.prompt, schedule: tmpl.schedule });
};
});
// ── Entry point (called from settings loader) ──
window._loadSettingsTasks = function() {
sb.register('_loadSettingsTasks', function() {
loadTaskList();
};
});
})();

View File

@@ -7,7 +7,7 @@
(function() {
'use strict';
window.TaskSidebar = {
const TaskSidebar = {
_loaded: false,
@@ -26,7 +26,7 @@
section.className = 'sb-section';
section.id = 'sbSectionTasks';
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-label">Tasks</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-label">' + label + '</span>' +
'<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>';
});
@@ -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;') : ''; }

View File

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

View File

@@ -146,7 +146,7 @@ Object.assign(UI, {
document.getElementById('teamAdminPicker').style.display = '';
document.getElementById('teamAdminContent').style.display = 'none';
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">
<strong>${esc(t.name)}</strong>
<span class="badge-admin">admin</span>
@@ -220,12 +220,12 @@ Object.assign(UI, {
<td class="admin-user-time">${lastSeen}</td>
<td class="admin-actions-cell">
${isPending
? `<button class="icon-btn" onclick="showApproveForm('${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="showApproveForm" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Approve">✓</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" onclick="adminResetUserPassword('${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" 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" data-action="adminResetUserPassword" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Reset PW">🔑</button>
<button class="icon-btn icon-btn-danger" data-action="deleteUser" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Delete">🗑</button>
</td>
</tr>`;
}).join('');
@@ -426,9 +426,9 @@ Object.assign(UI, {
<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 class="admin-actions-cell">
<button class="icon-btn" onclick="editAdminExtension('${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 icon-btn-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
<button class="icon-btn" data-action="editAdminExtension" data-args='${JSON.stringify([ext.id])}' title="Edit">✎</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" data-action="deleteAdminExtension" data-args='${JSON.stringify([ext.id, esc(ext.name)])}' title="Uninstall">🗑</button>
</td>
</tr>`;
}).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-size:12px;color:var(--text-3)">${esc(m.provider_name || '')}</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>`;
}).join('')}</tbody>
</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>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<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" onclick="editAdminPersona('${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 icon-btn-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</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" data-action="editAdminPersona" data-args='${JSON.stringify([p.id])}' title="Edit">✎</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" data-action="deleteAdminPersona" data-args='${JSON.stringify([p.id, esc(p.name)])}' title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -775,9 +775,9 @@ Object.assign(UI, {
</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">
<button class="icon-btn" onclick="UI.openTeamDetail('${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 icon-btn-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</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" 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" data-action="deleteTeam" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</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>' : ''}
</td>
<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>
</tr>`).join('')}</tbody>
</table>
@@ -885,8 +885,8 @@ Object.assign(UI, {
<td>${scopeBadge}</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">
<button class="icon-btn" onclick="UI.openGroupDetail('${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" data-action="UI.openGroupDetail" data-args='${JSON.stringify([g.id, esc(g.name)])}' title="Members">👥</button>
<button class="icon-btn icon-btn-danger" data-action="deleteGroup" data-args='${JSON.stringify([g.id, esc(g.name)])}' title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -1011,7 +1011,7 @@ Object.assign(UI, {
</td>
<td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
<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>
</tr>`).join('')}</tbody>
</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>'}
</div>
<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>
</div>
</div>`;
@@ -1185,9 +1185,9 @@ Object.assign(UI, {
<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 class="admin-actions-cell">
<button class="icon-btn" onclick="UI._showRoutingForm('${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 icon-btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
<button class="icon-btn" data-action="UI._showRoutingForm" data-args='${JSON.stringify([esc(p.id)])}' title="Edit">✎</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" data-action="UI._deleteRoutingPolicy" data-args='${JSON.stringify([esc(p.id), esc(p.name)])}' title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
@@ -1341,7 +1341,7 @@ Object.assign(UI, {
<td>${esc(o.provider_config_id?.slice(0, 8) || 'any')}</td>
<td>${esc(o.field)}</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>
</table>`;
} catch (e) {
@@ -1546,7 +1546,7 @@ Object.assign(UI, {
<td>${esc(ch.owner_name)}</td>
<td>${ch.message_count}</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>`;
}
html += '</tbody></table>';
@@ -1570,7 +1570,7 @@ Object.assign(UI, {
});
// ── Exports ─────────────────────────────────
window.ADMIN_SECTIONS = ADMIN_SECTIONS;
window.ADMIN_LABELS = ADMIN_LABELS;
window.ADMIN_LOADERS = ADMIN_LOADERS;
sb.ns('ADMIN_SECTIONS', ADMIN_SECTIONS);
sb.ns('ADMIN_LABELS', ADMIN_LABELS);
sb.ns('ADMIN_LOADERS', ADMIN_LOADERS);
})();

View File

@@ -13,6 +13,46 @@
(function() {
'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 ────────────────────────────
// Returns HTML for a message avatar: <img> if data URI available, emoji fallback.
function avatarHTML(role, avatarDataURI) {
@@ -445,10 +485,10 @@ const UI = {
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
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 ? `<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>`;
if (!isCollapsed && !collapsed) {
@@ -462,7 +502,7 @@ const UI = {
});
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})`}
</button>`;
}
@@ -506,12 +546,11 @@ const UI = {
: '';
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
onclick="selectChannel('${ch.id}')"
oncontextmenu="showChannelContextMenu(event,'${ch.id}')"
data-action="selectChannel" data-args='${JSON.stringify([ch.id])}' oncontextmenu="showChannelContextMenu(event,'${ch.id}')"
title="${esc(ch.name)}">
<span class="sb-ch-icon">${icon}</span>
${!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>`;
});
@@ -576,12 +615,12 @@ const UI = {
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
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">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
<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>
</div>
${isOpen
@@ -656,15 +695,14 @@ const UI = {
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
return `<div class="chat-item${active}${indent}"
onclick="selectChat('${c.id}')"
oncontextmenu="showChatContextMenu(event,'${c.id}')"
data-action="selectChat" data-args='${JSON.stringify([c.id])}' oncontextmenu="showChatContextMenu(event,'${c.id}')"
draggable="true"
ondragstart="onChatDragStart(event,'${c.id}')"
ondragend="onChatDragEnd(event)">
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();"
title="Double-click to rename">${typeIcon}${esc(c.title)}</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>`;
},
@@ -689,7 +727,7 @@ const UI = {
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
if (hiddenCount > 0) {
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
</button>
</div>
@@ -779,17 +817,15 @@ const UI = {
const sibTotal = msg.siblingCount || 1;
const branchNav = hasSiblings ? `
<div class="branch-nav">
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">&#8249;</button>
<button class="branch-arrow" data-action="switchSibling" data-args='${JSON.stringify([msg.id, -1])}' ${sibPos <= 1 ? 'disabled' : ''} title="Previous version">&#8249;</button>
<span class="branch-pos">${sibPos}/${sibTotal}</span>
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">&#8250;</button>
<button class="branch-arrow" data-action="switchSibling" data-args='${JSON.stringify([msg.id, 1])}' ${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">&#8250;</button>
</div>` : '';
// Action buttons
const msgId = msg.id ? esc(msg.id) : '';
const editBtn = isSelf && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</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" data-action="regenerateMessage" data-args='${JSON.stringify([msgId])}' title="Regenerate">Regen</button>` : '';
return `
<div class="message ${roleClass}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
@@ -803,8 +839,8 @@ const UI = {
<div class="msg-actions">
${editBtn}
${regenBtn}
<button class="msg-action-btn" onclick="UI.copyMessage(${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="UI.copyMessage" data-args='${JSON.stringify([index])}'" title="Copy">Copy</button>
<button class="msg-action-btn" data-action="saveMessageToNote" data-args='${JSON.stringify([index])}'" title="Save to note">Note</button>
</div>
</div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
@@ -845,21 +881,21 @@ const UI = {
`<span>with <b>${esc(name)}</b></span>` +
`<span class="ctx-hint">@mention a persona to bring in AI</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') {
html = `<span class="ctx-mode">Group</span>` +
`<span class="ctx-hint">No @mention = leader responds · @mention to route · @all for everyone</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') {
const ch = (App.channels || []).find(c => c.id === ac.id);
const mode = ch?.aiMode || 'auto';
const topic = ch?.topic || '';
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>`;
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.style.display = '';
@@ -910,8 +946,8 @@ const UI = {
let actions = '';
if (status === 'active' && API.isAdmin) {
actions =
'<button class="btn-small btn-primary" onclick="advanceWorkflowStage()">Advance ▸</button>' +
(current > 0 ? '<button class="btn-small btn-danger" onclick="rejectWorkflowStage()">◂ Reject</button>' : '');
'<button class="btn-small btn-primary" data-action="advanceWorkflowStage"">Advance ▸</button>' +
(current > 0 ? '<button class="btn-small btn-danger" data-action="rejectWorkflowStage"">◂ Reject</button>' : '');
}
el.innerHTML =
@@ -942,8 +978,8 @@ const UI = {
textEl.innerHTML = `
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
<div class="msg-edit-actions">
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button>
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button>
<button class="msg-edit-cancel" data-action="cancelEdit">Cancel</button>
<button class="msg-edit-submit" data-action="_submitEditFromDOM" data-args='${JSON.stringify([messageId])}'">Submit & Regenerate</button>
</div>`;
// 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 ─────────────────────────────────
window.UI = UI;
window.renderPersonaForm = renderPersonaForm;
// onclick handler (called from innerHTML)
window.toggleSummarizedHistory = toggleSummarizedHistory;
sb.ns('UI', UI);
sb.register('renderPersonaForm', renderPersonaForm);
sb.register('_uiDispatch', _uiDispatch);
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 handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
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>`;
}
if (handled && !fakeContainer.innerHTML) {
@@ -197,16 +197,16 @@ function _formatMarked(text) {
// Build toolbar buttons
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;
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" onclick="downloadCode('${codeId}','${lang}')">Download</button>`;
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([codeId])}'">Copy</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)
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>` : '';
@@ -226,15 +226,15 @@ function _formatBasic(text) {
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
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;
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" onclick="downloadCode('${id}','${lang}')">Download</button>`;
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([id])}'">Copy</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))) {
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>` : '';
@@ -531,7 +531,7 @@ function _renderToolCallsHTML(toolCalls) {
try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result;
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 */ }
}
@@ -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 ─────────────────────────────────
// Cross-file function calls
window.formatMessage = formatMessage;
window.clearPreview = clearPreview;
window.runExtensionPostRender = runExtensionPostRender;
window._livePreviewUpdate = _livePreviewUpdate;
window._relativeTime = _relativeTime;
window._renderToolCallsHTML = _renderToolCallsHTML;
window._registerPreviewPanel = _registerPreviewPanel;
// onclick handlers (called from innerHTML)
window.toggleCodeCollapse = toggleCodeCollapse;
window.toggleHTMLPreview = toggleHTMLPreview;
window.downloadCode = downloadCode;
window.popOutExtBlock = popOutExtBlock;
sb.register('formatMessage', formatMessage);
sb.register('clearPreview', clearPreview);
sb.register('runExtensionPostRender', runExtensionPostRender);
sb.register('_livePreviewUpdate', _livePreviewUpdate);
sb.register('_relativeTime', _relativeTime);
sb.register('_renderToolCallsHTML', _renderToolCallsHTML);
sb.register('_registerPreviewPanel', _registerPreviewPanel);
// data-action handlers
sb.register('toggleCodeCollapse', toggleCodeCollapse);
sb.register('toggleHTMLPreview', toggleHTMLPreview);
sb.register('downloadCode', downloadCode);
sb.register('popOutExtBlock', popOutExtBlock);
sb.register('_copyCodeBlock', _copyCodeBlock);
sb.register('_openNoteFromTool', _openNoteFromTool);
})();

View File

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

View File

@@ -329,7 +329,7 @@ Object.assign(UI, {
}
section.style.display = '';
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">
<strong>${esc(t.name)}</strong>
<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 titleEl = document.getElementById('teamAdminTitle');
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 {
titleEl.textContent = teamName;
}
@@ -409,7 +409,7 @@ Object.assign(UI, {
</select>
</td>
<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>
</tr>`).join('')}</tbody>
</table>
@@ -521,7 +521,7 @@ Object.assign(UI, {
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
<div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</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>`;
}).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>`; }
@@ -812,8 +812,8 @@ Object.assign(UI, {
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>
<div style="flex:1"></div>
<button class="btn-small" onclick="bulkSetUserModelVisibility(true)">Show All</button>
<button class="btn-small" onclick="bulkSetUserModelVisibility(false)">Hide All</button>
<button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[true]'">Show All</button>
<button class="btn-small" data-action="bulkSetUserModelVisibility" data-args='[false]'">Hide All</button>
</div>`;
html += models.map(m => {
@@ -832,7 +832,7 @@ Object.assign(UI, {
<span class="model-caps-inline">${badges}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${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>`;
}).join('');
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>
<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>`;
}).join('');

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
(function() {
'use strict';
window.WorkflowQueue = {
const WorkflowQueue = {
_loaded: false,
_myAssignments: [],
@@ -28,7 +28,7 @@
section.className = 'sb-section';
section.id = 'sbSectionQueue';
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-label">Workflows</span>' +
'<span class="sb-queue-badge" id="sbQueueBadge" style="display:none">0</span>' +
@@ -79,7 +79,7 @@
workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id;
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) + '">' +
'<span class="sb-queue-icon">⚡</span>' +
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
@@ -93,10 +93,10 @@
if (alreadyShown) return;
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-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>';
});
}
@@ -158,7 +158,7 @@
uItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</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>';
}
@@ -169,7 +169,7 @@
cItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</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>';
}
@@ -187,6 +187,7 @@
}
},
};
sb.ns('WorkflowQueue', WorkflowQueue);
// Auto-init when chat surface loads
document.addEventListener('DOMContentLoaded', function() {