Changeset 0.37.12 (#224)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-22 11:59:17 +00:00
committed by xcaliber
parent c687002015
commit aeda4fdd0c
31 changed files with 118 additions and 3642 deletions

View File

@@ -13,6 +13,9 @@
// v0.37.10: Legacy SPA source audit removed (ui-core.js, app.js,
// pages.js, settings-handlers.js, ui-admin.js all deleted).
// Policy gating now verified via Preact surfaces + admin templates.
// v0.37.12: Admin Go templates deleted (Preact since v0.37.6). Template
// element ID assertions removed — Preact component source audits
// at the bottom of this file cover the same policy keys.
//
// Run: node --test src/js/__tests__/policy-gating.test.js
// ==========================================
@@ -133,49 +136,6 @@ describe('Team member dropdown population', () => {
});
});
// ── Admin settings field mapping ─────────────
// v0.22.5: Server-rendered admin settings template uses new element IDs.
// v0.37.10: pages.js deleted — admin settings save now handled by
// Preact admin surface. Template elements still required.
describe('Admin settings field mapping (server templates)', () => {
const templateSrc = readAllTemplates();
const requiredElements = [
'settRegEnabled',
'settRegDefaultState',
'settUserBYOK',
'settUserPersonas',
'settBannerEnabled',
];
for (const id of requiredElements) {
it(`template has element #${id}`, () => {
assert.ok(templateSrc.includes(`id="${id}"`),
`MISSING: #${id} in server templates — admin settings incomplete`);
});
}
});
// ── Critical HTML elements in templates ───────
describe('Critical HTML elements exist in server templates', () => {
const templateSrc = readAllTemplates();
const requiredElements = [
// Admin settings — policy toggles (still in Go templates)
'settUserBYOK', // Admin toggle for BYOK
'settUserPersonas', // Admin toggle for personas
];
for (const id of requiredElements) {
it(`#${id} exists in server templates`, () => {
assert.ok(templateSrc.includes(`id="${id}"`),
`MISSING element: #${id} — UI feature will break`);
});
}
});
// ── Chat surface template ────────────────────
// v0.37.10: Chat surface is now Preact-rendered. Verify the mount
// point exists and the old SPA scaffold is gone.

View File

@@ -1,100 +0,0 @@
// ==========================================
// Chat Switchboard — Drag Resize Utility
// ==========================================
// v0.25.2: Single drag-resize primitive used by both the workspace
// handle (panels.js) and PaneContainer split handles (pane-container.js).
//
// Fixes vs the two prior implementations:
// - Full-viewport overlay during drag prevents iframes / CM6 from
// swallowing mousemove events (the "jump to middle" bug).
// - Touch support (touchstart / touchmove / touchend) everywhere.
// - Transitions on dragged elements are killed on grab and restored
// only after release — no mid-animation capture of wrong startW.
//
// Usage:
// initDragResize(handleEl, {
// direction: 'horizontal', // or 'vertical'
// onStart(clientPos) → ctx | false, // return drag context or false to cancel
// onMove(delta, ctx), // delta = px from start (signed)
// onEnd(ctx), // cleanup / persist
// });
//
// Exports: window.initDragResize
/**
* Attach drag-resize behavior to a handle element.
*
* @param {HTMLElement} handleEl — the drag grip element
* @param {object} opts
* @param {'horizontal'|'vertical'} opts.direction
* @param {function} opts.onStart — receives initial clientX/Y, returns context or false
* @param {function} opts.onMove — receives (delta, ctx)
* @param {function} opts.onEnd — receives (ctx)
* @param {function} [opts.canDrag] — optional guard; return false to ignore mousedown
*/
function initDragResize(handleEl, opts) {
if (!handleEl) return;
const isHoriz = (opts.direction || 'horizontal') === 'horizontal';
const onPointerDown = (e) => {
// Optional guard (e.g. "only when .active class is present")
if (opts.canDrag && !opts.canDrag()) return;
e.preventDefault();
const clientPos = _clientPos(e, isHoriz);
if (clientPos == null) return;
const ctx = opts.onStart(clientPos);
if (ctx === false) return;
// ── Overlay: blocks iframes / CodeMirror from eating events ──
const overlay = document.createElement('div');
overlay.style.cssText =
'position:fixed;inset:0;z-index:99999;' +
'cursor:' + (isHoriz ? 'col-resize' : 'row-resize') + ';';
document.body.appendChild(overlay);
document.body.style.cursor = isHoriz ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const pos = _clientPos(e, isHoriz);
if (pos == null) return;
opts.onMove(pos - clientPos, ctx);
};
const onUp = () => {
overlay.remove();
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
if (opts.onEnd) opts.onEnd(ctx);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
};
handleEl.addEventListener('mousedown', onPointerDown);
handleEl.addEventListener('touchstart', onPointerDown, { passive: false });
}
/** Extract the relevant coordinate from a mouse or touch event. */
function _clientPos(e, isHoriz) {
if (e.touches?.length > 0) {
return isHoriz ? e.touches[0].clientX : e.touches[0].clientY;
}
if (e.changedTouches?.length > 0) {
return isHoriz ? e.changedTouches[0].clientX : e.changedTouches[0].clientY;
}
return isHoriz ? e.clientX : e.clientY;
}
// ── Exports ─────────────────────────────────
sb.register('initDragResize', initDragResize);

View File

@@ -1,175 +0,0 @@
// ==========================================
// Chat Switchboard — ModelSelector Component
// ==========================================
// v0.25.0: Extracted from ui-core.js.
// Grouped dropdown: personas (global, team, personal), models, BYOK.
// Pattern: Go template partial (model-selector.html) + JS factory (this file).
//
// Usage:
// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } });
// sel.setModels(App.models);
// sel.select(modelId);
// sel.getSelected(); // → current model ID
// sel.destroy();
//
// Exports: window.ModelSelector
const ModelSelector = {
...createComponentRegistry('ModelSelector'),
create(opts) {
const pfx = opts.id || '';
const instance = componentMixin({
id: pfx,
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
labelEl: document.getElementById(pfx + 'modelDropdownLabel'),
menuEl: document.getElementById(pfx + 'modelDropdownMenu'),
capsEl: document.getElementById(pfx + 'modelCaps'),
onChange: opts.onChange || null,
_value: '',
_models: [],
// ── Selection ───────────────────────
getSelected() { return this._value; },
select(id, label) {
this._value = id;
if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model';
// Highlight
if (this.menuEl) {
this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === id);
});
}
this._updateCaps();
if (this.onChange) this.onChange(id, label);
},
// ── Model list ──────────────────────
setModels(models) {
this._models = models || [];
this._rebuild();
},
_rebuild() {
if (!this.menuEl) return;
this.menuEl.innerHTML = '';
if (this._models.length === 0) {
this.menuEl.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
this.select('', 'No models loaded');
return;
}
const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global');
const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team');
const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal');
const models = this._models.filter(m => !m.isPersona && !m.hidden);
const addGroup = (label, items) => {
if (items.length === 0) return;
const hdr = document.createElement('div');
hdr.className = 'model-dropdown-group';
hdr.textContent = label;
this.menuEl.appendChild(hdr);
items.forEach(m => this.menuEl.appendChild(this._createItem(m)));
};
addGroup('\u26A1 Personas', globalPersonas);
// Group team personas by team name
const teamGroups = {};
teamPersonas.forEach(m => {
const tn = m.personaTeamName || 'Team';
(teamGroups[tn] = teamGroups[tn] || []).push(m);
});
Object.keys(teamGroups).sort().forEach(tn => {
addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]);
});
addGroup('\uD83D\uDD27 My Personas', personalPersonas);
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal'));
},
_createItem(m) {
const self = this;
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.personaAvatar ? '<img src="' + esc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + esc(m.personaHandle) + '</span>' : '';
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + esc(m.teamName) + '</span>' : '';
const provider = m.provider ? '<span class="item-provider">' + esc(m.provider) + '</span>' : '';
div.innerHTML = avatar + '<span class="item-label">' + esc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
div.addEventListener('click', () => {
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
self.close();
});
return div;
},
// ── Restore selection ───────────────
/**
* Restore selection from a preferred ID, falling back through
* admin default → first visible.
*/
restore(preferredId, adminDefault) {
const visible = this._models.filter(m => !m.hidden);
if (visible.length === 0) { this.select('', 'No visible models'); return; }
const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id);
const match = byId(preferredId) || byId(adminDefault) || visible[0];
this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : ''));
},
// ── Dropdown toggle ─────────────────
open() { if (this.menuEl) this.menuEl.classList.add('open'); },
close() { if (this.menuEl) this.menuEl.classList.remove('open'); },
toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); },
// ── Capability badges ───────────────
_updateCaps() {
if (!this.capsEl) return;
if (!this._value) { this.capsEl.innerHTML = ''; return; }
const m = this._models.find(m => m.id === this._value);
const caps = m?.capabilities;
if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; }
// Use global renderCapBadges if available (from ui-core.js)
if (typeof renderCapBadges === 'function') {
this.capsEl.innerHTML = renderCapBadges(caps);
}
},
getSelectedCaps() {
const m = this._models.find(m => m.id === this._value);
return m?.capabilities || {};
},
// ── Event wiring ────────────────────
bind() {
this._on(this.btnEl, 'click', (e) => {
e.stopPropagation();
this.toggle();
});
this._on(document, 'click', (e) => {
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
});
},
}, ModelSelector);
ModelSelector._register(pfx, instance);
return instance;
},
};
// ── Exports ─────────────────────────────────
sb.ns('ModelSelector', ModelSelector);

View File

@@ -1,186 +0,0 @@
// ==========================================
// Chat Switchboard - Splash Surface (pages-splash.js)
// ==========================================
// v0.22.7: Splash init, login, register, animated grid.
var base = window.__BASE__ || '';
Pages.initSplash = function() {
_initAuthTabs();
_initLoginForm();
_initRegisterForm();
_initGridCanvas();
if (typeof Theme !== 'undefined') Theme.init();
};
// -- Save tokens via API singleton (sets sb_token cookie) --
function _saveAuth(data) {
if (typeof API !== 'undefined') {
API.accessToken = data.access_token || null;
API.refreshToken = data.refresh_token || null;
API.user = data.user || null;
API.saveTokens();
} else {
// Fallback: at minimum set the cookie so page auth works
if (data.access_token) {
localStorage.setItem('token', data.access_token);
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
}
}
}
// -- Redirect after auth --
function _redirectAfterAuth() {
// Check for saved redirect (e.g. deep link that bounced to /login)
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var c = cookies[i].trim();
if (c.startsWith('redirect_after_login=')) {
var dest = decodeURIComponent(c.substring('redirect_after_login='.length));
document.cookie = 'redirect_after_login=; path=/; max-age=0';
if (dest && dest !== '/login' && dest !== base + '/login') {
window.location.href = dest;
return;
}
}
}
window.location.href = base + '/';
}
function _initAuthTabs() {
var tabs = document.querySelectorAll('.splash-tab');
var loginForm = document.getElementById('authLogin');
var regForm = document.getElementById('authRegister');
if (!tabs.length) return;
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
var target = tab.dataset.tab;
if (loginForm) loginForm.style.display = target === 'login' ? '' : 'none';
if (regForm) regForm.style.display = target === 'register' ? '' : 'none';
});
});
}
function _initLoginForm() {
var btn = document.getElementById('loginBtn');
if (!btn) return;
var form = document.getElementById('authLogin');
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
btn.addEventListener('click', async function() {
var username = _val('loginUsername');
var password = _val('loginPassword');
var errEl = document.getElementById('loginError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
btn.disabled = true;
btn.textContent = 'Logging in\u2026';
try {
var resp = await fetch(base + '/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: username, password: password }),
});
if (!resp.ok) {
var errData = await resp.json().catch(function() { return {}; });
throw new Error(errData.error || 'Invalid credentials');
}
var data = await resp.json();
_saveAuth(data);
_redirectAfterAuth();
} catch (e) {
_showErr(errEl, e.message || 'Login failed');
btn.disabled = false;
btn.textContent = 'Log In';
}
});
}
function _initRegisterForm() {
var btn = document.getElementById('regBtn');
if (!btn) return;
var avatarEl = document.getElementById('avatarUpload');
var avatarFile = null;
if (avatarEl && typeof mountAvatarUpload === 'function') {
mountAvatarUpload(avatarEl, { onUpload: function(file) { avatarFile = file; } });
}
var form = document.getElementById('authRegister');
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
btn.addEventListener('click', async function() {
var username = _val('regUsername');
var displayName = _val('regDisplayName');
var email = _val('regEmail');
var password = _val('regPassword');
var confirm = _val('regConfirm');
var errEl = document.getElementById('regError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
if (password !== confirm) { _showErr(errEl, 'Passwords do not match.'); return; }
btn.disabled = true;
btn.textContent = 'Creating account\u2026';
try {
var body = { username: username, password: password, display_name: displayName, email: email };
var resp = await fetch(base + '/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
var errData = await resp.json().catch(function() { return {}; });
throw new Error(errData.error || 'Registration failed');
}
var data = await resp.json();
// Upload avatar before redirect (token needed for auth)
if (avatarFile && data.access_token) {
try {
var fd = new FormData();
fd.append('avatar', avatarFile);
await fetch(base + '/api/v1/users/me/avatar', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + data.access_token },
body: fd,
});
} catch (e) { console.warn('Avatar upload failed:', e); }
}
_saveAuth(data);
_redirectAfterAuth();
} catch (e) {
_showErr(errEl, e.message || 'Registration failed');
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
}
function _initGridCanvas() {
var canvas = document.getElementById('gridCanvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var w, h, offset = 0;
function resize() {
var rect = canvas.parentElement.getBoundingClientRect();
w = canvas.width = rect.width;
h = canvas.height = rect.height;
}
function draw() {
ctx.clearRect(0, 0, w, h);
var gap = 40;
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#2e2e35';
ctx.lineWidth = 0.5;
for (var x = (offset % gap); x < w; x += gap) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
for (var y = (offset % gap); y < h; y += gap) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
offset += 0.15;
requestAnimationFrame(draw);
}
window.addEventListener('resize', resize);
resize();
draw();
}
function _val(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
function _showErr(el, msg) { if (!el) return; el.textContent = msg; el.style.display = ''; }

View File

@@ -1,141 +0,0 @@
// ==========================================
// Chat Switchboard — Task Sidebar Section
// ==========================================
// Sidebar section showing user's scheduled tasks with status indicators.
// Click opens the task's output channel. Same pattern as WorkflowQueue.
const TaskSidebar = {
_loaded: false,
// ── Init ────────────────────────────
async init() {
if (this._loaded) return;
this._loaded = true;
// Insert after Queue section (or after Channels if no queue)
var anchor = document.getElementById('sbSectionQueue') ||
document.getElementById('sbSectionChannels');
if (!anchor) return;
var section = document.createElement('div');
section.className = 'sb-section';
section.id = 'sbSectionTasks';
section.innerHTML =
'<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>' +
'</div>' +
'<div class="sb-section-body" id="sbBodyTasks"></div>';
anchor.parentNode.insertBefore(section, anchor.nextSibling);
this.refresh();
},
// ── Refresh ─────────────────────────
async refresh() {
var body = document.getElementById('sbBodyTasks');
var badge = document.getElementById('sbTaskBadge');
if (!body) return;
try {
// Personal tasks
var resp = await API._get('/api/v1/tasks');
var tasks = (resp.data || []).filter(function(t) { return t.is_active; });
// Team tasks (v0.27.5)
try {
var teamsResp = await API._get('/api/v1/teams/mine');
var teams = teamsResp.data || teamsResp || [];
var seen = {};
tasks.forEach(function(t) { seen[t.id] = true; });
for (var i = 0; i < teams.length; i++) {
try {
var tr = await API._get('/api/v1/teams/' + teams[i].id + '/tasks');
(tr.data || []).forEach(function(t) {
if (!seen[t.id] && t.is_active) {
t._teamName = teams[i].name;
tasks.push(t);
seen[t.id] = true;
}
});
} catch (_) {}
}
} catch (_) {}
if (tasks.length === 0) {
body.innerHTML = '';
if (badge) badge.style.display = 'none';
return;
}
if (badge) {
badge.textContent = tasks.length;
badge.style.display = '';
}
var html = '';
tasks.forEach(function(t) {
// Status indicator
var icon = '\u25f7'; // default: clock
var statusTip = 'Scheduled';
if (t.last_run_at) {
// Check if most recent run was recent (within 2x schedule interval)
icon = '\u2713'; // checkmark
statusTip = 'Last: ' + new Date(t.last_run_at).toLocaleString();
}
var nextTip = t.next_run_at
? 'Next: ' + new Date(t.next_run_at).toLocaleString()
: '';
var channelId = t.output_channel_id || '';
var onclick = channelId
? 'selectChannel(\'' + channelId + '\')'
: 'UI.toast(\'No output channel yet — task has not run\',\'info\')';
var label = _esc(t.name);
if (t._teamName) label = '<span style="opacity:0.6;font-size:10px">[' + _esc(t._teamName) + ']</span> ' + label;
html += '<div class="sb-queue-item" onclick="' + onclick + '" title="' +
_esc(statusTip + (nextTip ? ' \u00b7 ' + nextTip : '')) + '">' +
'<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" ' +
'data-action="TaskSidebar.runNow" data-args=\'' + JSON.stringify([t.id]) + '\' title="Run Now">\u25b6</button>' +
'</div>';
});
body.innerHTML = html;
} catch (err) {
body.innerHTML = '';
if (badge) badge.style.display = 'none';
}
},
// ── Run Now ─────────────────────────
async runNow(taskId) {
try {
await API._post('/api/v1/tasks/' + taskId + '/run', {});
UI.toast('Task triggered', 'success');
} catch (err) {
UI.toast(err.message || 'Failed to run task', 'error');
}
}
};
sb.ns('TaskSidebar', TaskSidebar);
function _esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// Auto-init when chat surface loads
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
if (typeof API !== 'undefined' && API.accessToken) {
TaskSidebar.init();
}
}, 2200); // After WorkflowQueue (2000ms)
});

View File

@@ -1,628 +0,0 @@
// ==========================================
// Chat Switchboard UI Formatting & Helpers
// ==========================================
// Markdown rendering, code blocks, preview panel, time formatting.
// esc() is in ui-primitives.js (loaded globally in base.html).
//
// Exports: formatMessage, clearPreview, runExtensionPostRender,
// _livePreviewUpdate, _relativeTime, _renderToolCallsHTML,
// _registerPreviewPanel,
// toggleCodeCollapse, toggleHTMLPreview, downloadCode,
// popOutExtBlock (onclick handlers)
// ==========================================
// ── Message Formatting ──────────────────────
function formatMessage(content) {
if (!content) return '';
const thinkingBlocks = [];
let text = content;
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
const id = 'think-' + Math.random().toString(36).slice(2, 9);
thinkingBlocks.push({ id, content: inner.trim() });
// Newlines ensure the placeholder is its own markdown block so adjacent
// code fences (e.g. "</think>```html") start on a fresh line for marked.
return `\n\nTHINK_PLACEHOLDER_${id}\n\n`;
});
let html;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
html = _formatMarked(text);
} else {
html = _formatBasic(text);
}
for (const b of thinkingBlocks) {
const inner = esc(b.content).replace(/\n/g, '<br>');
const openAttr = App.settings.showThinking ? ' open' : '';
const thinkHTML = `<details class="thinking-block"${openAttr}><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`;
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
}
// @mention highlighting (v0.23.0): style @Name references as pills
html = _highlightMentions(html);
return html;
}
/**
* Highlight @mentions in rendered HTML as styled pills.
* Matches @Name patterns against the channel model roster.
* Only highlights inside text nodes (not inside code/pre/a tags).
*/
function _highlightMentions(html) {
if (typeof ChannelModels === 'undefined') return html;
const roster = ChannelModels.getRoster();
// Build patterns from roster handles and display names (AI mentions)
const aiPatterns = [];
const seen = new Set();
if (roster && roster.length >= 2) {
for (const m of roster) {
if (m.handle) {
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped)) {
aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped);
}
}
if (m.display_name) {
const hyphenated = m.display_name.replace(/\s+/g, '-');
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped.toLowerCase())) {
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped.toLowerCase());
}
}
}
}
// @all is special
aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
// v0.23.2: Build user mention patterns
const userPatterns = [];
for (const u of (App.users || [])) {
const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped.toLowerCase())) {
userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped.toLowerCase());
}
}
// Don't highlight inside <code>, <pre>, <a> tags
const parts = html.split(/(<[^>]+>)/);
let inCode = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith('<')) {
const tag = part.toLowerCase();
if (tag.startsWith('<code') || tag.startsWith('<pre') || tag.startsWith('<a ')) inCode = true;
if (tag.startsWith('</code') || tag.startsWith('</pre') || tag.startsWith('</a')) inCode = false;
continue;
}
if (inCode) continue;
let text = part;
// AI mentions first (persona/model)
for (const re of aiPatterns) {
text = text.replace(re, '<span class="mention-pill">$1</span>');
}
// User mentions (different color)
for (const re of userPatterns) {
text = text.replace(re, '<span class="mention-pill mention-user">$1</span>');
}
parts[i] = text;
}
return parts.join('');
}
function _formatMarked(text) {
// ── Unwrap outer ```markdown wrappers ────────────────────────
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
// semantically "this content IS markdown". Standard markdown doesn't support
// nested fences with the same delimiter, so an inner ```mermaid ... ```
// prematurely closes the outer block, splitting the content into broken
// fragments. Fix: if the outer fence is ```markdown/```md and contains
// nested fences, strip the wrapper and render contents as markdown directly.
text = _unwrapMarkdownFence(text);
// Close any unclosed code fences to prevent raw HTML injection during
// streaming (closing ``` hasn't arrived yet) or when the model forgets
// to close a fence. An odd number of ``` markers means one is unclosed.
const fences = text.match(/^```/gm);
if (fences && fences.length % 2 !== 0) {
text += '\n```';
}
const rendered = marked.parse(text, { breaks: true, gfm: true });
let html = DOMPurify.sanitize(rendered, {
// Strict allowlist: only elements that marked.js actually produces.
// This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.)
// from rendering live even if a code fence fails to parse.
ALLOWED_TAGS: [
// Block elements
'p', 'br', 'hr', 'pre', 'code', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Lists
'ul', 'ol', 'li',
// Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
// Inline formatting
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup',
'a', 'img', 'span', 'div',
// Think blocks (injected post-sanitize, but placeholders may be in <p>)
'details', 'summary',
],
ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title',
'colspan', 'rowspan', 'align'],
});
// Process code blocks: add copy button, collapse toggle, HTML preview
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
const langMatch = attrs.match(/class="language-(\w+)"/);
const lang = langMatch ? langMatch[1].toLowerCase() : '';
// Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
// Extensions mark blocks with data-ext-render for post-render DOM processing.
if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
try {
const fakeContainer = document.createElement('div');
const decoded = _decodeHTML(code);
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
if (handled && fakeContainer.innerHTML) {
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) {
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
}
} catch (e) {
console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e);
}
}
// Count lines for collapse decision
const lineCount = (code.match(/\n/g) || []).length + 1;
const COLLAPSE_THRESHOLD = 15;
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
// Build toolbar buttons
const collapseBtn = lineCount > 5
? `<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" 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" data-action="toggleHTMLPreview" data-args='${JSON.stringify([codeId])}'">Preview</button>`;
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
return `<pre class="code-block${collapsedClass}" id="block-${codeId}">${langLabel}<code id="${codeId}"${attrs}>${code}</code><div class="code-toolbar">${toolbar}</div></pre>`;
});
return html;
}
function _formatBasic(text) {
let html = esc(text);
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const id = 'code-' + Math.random().toString(36).slice(2, 9);
const lineCount = (code.match(/\n/g) || []).length + 1;
const COLLAPSE_THRESHOLD = 15;
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
const collapseBtn = lineCount > 5
? `<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" 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" data-action="toggleHTMLPreview" data-args='${JSON.stringify([id])}'">Preview</button>`;
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
return `<pre class="code-block${collapsedClass}" id="block-${id}">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code><div class="code-toolbar">${toolbar}</div></pre>`;
});
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\n/g, '<br>');
return html;
}
// ── Markdown Fence Unwrapper ──────────────────
// LLMs often wrap entire responses in ```markdown ... ```. This is semantically
// a no-op ("this markdown IS markdown") but breaks parsing when the content
// contains nested code fences — the inner closing ``` prematurely terminates
// the outer block, splitting the response into fragments.
//
// Strategy: if the text is wrapped in ```markdown/```md and the body contains
// at least one nested ``` fence, strip the outer wrapper. If there are no
// nested fences, leave it alone (the user/LLM may genuinely want to show
// markdown source in a code block).
function _unwrapMarkdownFence(text) {
// The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier).
// We need to find the ```markdown fence after any placeholders/whitespace.
const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i);
if (!openMatch) return text;
const prefix = openMatch[1]; // placeholders + whitespace before the fence
const fence = openMatch[2]; // the ```markdown\n itself
const afterOpen = prefix.length + fence.length;
// Find the LAST bare ``` on its own line — this is the outer closing fence.
// There may be trailing text (LLM explanations) after it.
let closeIdx = -1;
const searchRegex = /\n```[ \t]*(?:\n|$)/g;
let m;
while ((m = searchRegex.exec(text)) !== null) {
closeIdx = m.index;
}
if (closeIdx < afterOpen) return text;
// Extract inner content and any trailing text after the close
const inner = text.slice(afterOpen, closeIdx);
const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n');
// Only unwrap if the inner content contains nested fences —
// otherwise this might be intentional "show me the markdown source"
if (/^```/m.test(inner)) {
return prefix + inner + trailing;
}
return text;
}
// Decode HTML entities in code block content (for extension renderers that need raw text)
function _decodeHTML(html) {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
}
// Heuristic: does this code block look like HTML?
function _looksLikeHTML(code) {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
}
// ── Code Block Actions ──────────────────────
function toggleCodeCollapse(codeId) {
const pre = document.getElementById('block-' + codeId);
if (!pre) return;
const isCollapsed = pre.classList.toggle('code-collapsed');
const btn = pre.querySelector('.code-collapse-btn');
if (btn) {
const lines = btn.textContent.replace(/[▸▾]\s*/, '');
btn.textContent = (isCollapsed ? '▸ ' : '▾ ') + lines;
btn.title = isCollapsed ? 'Expand' : 'Collapse';
}
}
function toggleHTMLPreview(codeId) {
const codeEl = document.getElementById(codeId);
if (!codeEl) return;
const rawHTML = codeEl.textContent;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
frame.srcdoc = rawHTML;
frame.style.display = '';
if (empty) empty.style.display = 'none';
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
function clearPreview() {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
if (empty) empty.style.display = '';
PanelRegistry.refreshActions();
}
function downloadCode(codeId, lang) {
const el = document.getElementById(codeId);
if (!el) return;
const extMap = {
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
golang: 'go', go: 'go', rust: 'rs', java: 'java', cpp: 'cpp',
c: 'c', csharp: 'cs', css: 'css', html: 'html', htm: 'html',
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', sql: 'sql',
bash: 'sh', shell: 'sh', sh: 'sh', markdown: 'md', md: 'md',
toml: 'toml', dockerfile: 'Dockerfile', makefile: 'Makefile',
perl: 'pl', lua: 'lua', swift: 'swift', kotlin: 'kt', php: 'php',
};
const ext = extMap[lang?.toLowerCase()] || lang || 'txt';
const filename = `code.${ext}`;
const blob = new Blob([el.textContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
UI.toast('Downloaded ' + filename, 'success');
}
// ── Preview Panel Registration ──────────────
function _registerPreviewPanel() {
const el = document.getElementById('sidePanelPreview');
if (!el) return;
PanelRegistry.register('preview', {
element: el,
label: 'Preview',
actions: [
{
id: 'sidePanelClearBtn',
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
title: 'Clear preview',
onClickName: 'clearPreview()',
visible: _hasPreviewContent,
},
],
saveState() {
const frame = document.getElementById('previewFrame');
return {
srcdoc: frame?.srcdoc || '',
hasContent: frame?.style.display !== 'none',
};
},
restoreState(state) {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame && state.srcdoc) {
frame.srcdoc = state.srcdoc;
frame.style.display = state.hasContent ? '' : 'none';
if (empty) empty.style.display = state.hasContent ? 'none' : '';
}
},
});
}
function _hasPreviewContent() {
const frame = document.getElementById('previewFrame');
return frame && frame.style.display !== 'none' && frame.srcdoc;
}
// ── Pop-out for Extension-Rendered Blocks ───
/**
* Open an extension-rendered block (Mermaid, KaTeX, etc.) in the
* preview panel at full size.
*/
function popOutExtBlock(blockId) {
const block = document.querySelector(`[data-ext-block="${blockId}"]`);
if (!block) return;
// Clone rendered content (skip the pop-out button itself)
const clone = block.cloneNode(true);
const btn = clone.querySelector('.ext-popout-btn');
if (btn) btn.remove();
const lang = block.getAttribute('data-ext-lang') || '';
// Read current theme colors so the iframe respects light/dark mode
const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-surface').trim() || '#fff';
const textColor = cs.getPropertyValue('--text').trim() || '#1a1a2e';
// Wrap in a minimal HTML document for the iframe
const html = `<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<style>
body { margin: 0; padding: 16px; display: flex; justify-content: center;
align-items: flex-start; min-height: 100vh; background: ${bgColor}; color: ${textColor};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body > * { max-width: 100%; }
svg { max-width: 100%; height: auto; }
</style>
</head><body>${clone.innerHTML}</body></html>`;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
// ── Live Preview During Streaming ───────────
let _livePreviewTimer = null;
/**
* Called on each streaming delta with the accumulated raw markdown
* content. If the preview panel is open, extracts the last completed
* HTML code block and updates the iframe. Debounced at 500ms.
*/
function _livePreviewUpdate(rawContent) {
// Only live-update if preview is the active panel
if (!PanelRegistry.isOpen('preview')) return;
clearTimeout(_livePreviewTimer);
_livePreviewTimer = setTimeout(() => {
const html = _extractLastHTMLBlock(rawContent);
if (!html) return;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
PanelRegistry.refreshActions();
}, 500);
}
/**
* Extract the last completed fenced HTML code block from raw markdown.
* Returns the raw HTML content or null if none found.
*
* "Completed" means both opening ``` and closing ``` are present.
*/
function _extractLastHTMLBlock(text) {
if (!text) return null;
// Match all completed ```html ... ``` blocks (or ```htm, or untagged that look like HTML)
const pattern = /```(?:html|htm)?\s*\n([\s\S]*?)```/gi;
let lastMatch = null;
let m;
while ((m = pattern.exec(text)) !== null) {
const blockContent = m[1];
const lang = m[0].match(/```(\w*)/)?.[1]?.toLowerCase() || '';
if (lang === 'html' || lang === 'htm' || _looksLikeHTML(blockContent)) {
lastMatch = blockContent;
}
}
return lastMatch;
}
// ── Helpers ─────────────────────────────────
// Render tool calls from stored message data (history view)
function _renderToolCallsHTML(toolCalls) {
if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return '';
const items = toolCalls.map(tc => {
const name = tc.function?.name || tc.name || 'unknown';
const args = tc.function?.arguments || tc.arguments || tc.input || '';
const result = tc.result || '';
const isError = tc.is_error || false;
const tcId = tc.id || '';
const statusCls = isError ? 'tool-error' : 'tool-done';
const statusText = isError ? 'error' : 'done';
// Check for note tool — add view link
let noteLink = '';
if (name.startsWith('note_') && result) {
try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result;
if (parsed.id) {
noteLink = `<button class="tool-note-link" data-action="_openNoteFromTool" data-args='${JSON.stringify([esc(parsed.id)])}'">📝 View</button>`;
}
} catch (e) { /* not JSON */ }
}
// Build collapsible detail
let detailHTML = '';
if (args || result) {
const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
detailHTML = `<div class="tool-detail">`;
if (argsStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Input</span><pre class="tool-detail-pre">${esc(argsStr)}</pre></div>`;
if (resultStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Output</span><pre class="tool-detail-pre">${esc(resultStr)}</pre></div>`;
detailHTML += `</div>`;
}
return `<details class="tool-call-block">
<summary class="tool-call-summary">
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status ${statusCls}">${statusText}</span>
${noteLink}
</summary>
${detailHTML}
</details>`;
});
return `<div class="msg-tools">${items.join('')}</div>`;
}
function _relativeTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
/**
* Run extension post-renderers on a container after innerHTML is set.
* Safe to call even if extensions aren't loaded — no-ops gracefully.
*/
function runExtensionPostRender(container) {
// v0.28.5: Render pipe subsumes extension post-renderers.
// The pipe includes compat-shimmed renderers from ctx.renderers.register()
// alongside new sw.pipe.render() filters.
if (typeof sw !== 'undefined' && sw.pipe) {
const renderCtx = {
element: container,
message: null, // batch render — no single message context
channel: {
id: typeof App !== 'undefined' ? App.activeId : null,
type: typeof App !== 'undefined' ? (App.activeType || 'direct') : 'direct',
},
};
sw.pipe._runRender(renderCtx);
return;
}
// Fallback: SDK not loaded (shouldn't happen, but defensive)
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
Extensions.runPostRenderers(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) {
// v0.37.9: old notes.js removed — navigate to notes surface instead
if (window.sw?.notesPane) {
console.log('[ui-format] Note tool link — navigate to /notes');
}
}
// ── Exports ─────────────────────────────────
// Cross-file function calls
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

@@ -1,277 +0,0 @@
// ==========================================
// Chat Switchboard — Virtual Scroll
// ==========================================
// Viewport-windowed message rendering for long conversations.
// Renders a window of messages around the viewport. As the user
// scrolls, older/newer chunks are rendered on demand and distant
// messages are removed from the DOM to keep memory bounded.
//
// Design: sentinel-based (IntersectionObserver on top/bottom markers)
// rather than scroll-listener based. No jank, no debounce.
//
// Integration: wraps UI._messageHTML() — no rendering duplication.
// The container element (#chatMessages) is unchanged.
//
// Streaming: the actively-streaming message is always in DOM at the
// bottom, managed by streamResponse(). VirtualScroll leaves it alone.
const WINDOW_SIZE = 80; // max messages in DOM at once
const BUFFER = 20; // render this many beyond viewport edge
const PREPEND_CHUNK = 40; // load this many when scrolling up
const LOAD_MORE_THRESHOLD = 100; // only activate for conversations this long
class VirtualScroll {
constructor() {
this._messages = []; // full message array (data, not DOM)
this._renderFn = null; // (msg, index, dimmed) => html string
this._summaryFn = null; // (msg) => html string
this._postRenderFn = null; // (container) => void
this._container = null;
this._observer = null;
this._topSentinel = null;
this._renderedRange = { start: 0, end: 0 }; // indices into _messages
this._enabled = false;
this._scrollLock = false; // prevent observer re-entry during DOM mutation
this._summaryIdx = -1;
}
// ── Configuration ────────────────────────
/**
* Bind to a container and rendering functions.
* @param {HTMLElement} container - the #chatMessages element
* @param {Function} renderFn - (msg, index, dimmed?) => html string
* @param {Function} summaryFn - (msg) => html string
* @param {Function} postRenderFn - (container) => void (extensions, images)
*/
bind(container, renderFn, summaryFn, postRenderFn) {
this._container = container;
this._renderFn = renderFn;
this._summaryFn = summaryFn;
this._postRenderFn = postRenderFn;
}
// ── Public API ───────────────────────────
/**
* Set messages and render. Called by renderMessages().
* For short conversations, returns false (caller should use
* the original innerHTML path). For long ones, renders the
* tail window and returns true.
*/
setMessages(messages, summaryIdx) {
this._messages = messages;
this._summaryIdx = summaryIdx;
if (!this._container || !this._renderFn) return false;
// Only virtualize long conversations
const visible = messages.filter(m => m.role !== 'system');
if (visible.length < LOAD_MORE_THRESHOLD) {
this._teardown();
this._enabled = false;
return false;
}
this._enabled = true;
this._renderTail();
return true;
}
/**
* Whether virtual scroll is currently active.
*/
get active() { return this._enabled; }
/**
* Clean up observers. Call when switching conversations.
*/
destroy() {
this._teardown();
this._messages = [];
this._enabled = false;
}
// ── Internal: Render ─────────────────────
/**
* Render the tail (most recent) window of messages.
* This is the initial render — user sees the latest messages.
*/
_renderTail() {
const msgs = this._filteredMessages();
const total = msgs.length;
const start = Math.max(0, total - WINDOW_SIZE);
const end = total;
this._renderedRange = { start, end };
this._buildDOM(msgs, start, end);
}
/**
* Prepend older messages when user scrolls to top.
*/
_prependChunk() {
if (this._scrollLock) return;
const msgs = this._filteredMessages();
const { start } = this._renderedRange;
if (start <= 0) return; // already showing all
this._scrollLock = true;
const newStart = Math.max(0, start - PREPEND_CHUNK);
const container = this._container;
// Remember scroll position to restore after prepend
const scrollBottom = container.scrollHeight - container.scrollTop;
// Build HTML for the new chunk
let html = '';
for (let i = newStart; i < start; i++) {
html += this._renderOne(msgs[i], i);
}
// Insert after top sentinel, before existing messages
const sentinel = this._topSentinel;
if (sentinel) {
const frag = document.createRange().createContextualFragment(html);
sentinel.after(frag);
}
// Trim bottom if DOM is too large
const totalRendered = this._renderedRange.end - newStart;
if (totalRendered > WINDOW_SIZE + BUFFER) {
const excess = totalRendered - WINDOW_SIZE;
this._removeFromBottom(excess);
this._renderedRange.end -= excess;
}
this._renderedRange.start = newStart;
// Restore scroll position (prevent jump)
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - scrollBottom;
if (this._postRenderFn) this._postRenderFn(container);
this._scrollLock = false;
this._updateSentinelState();
});
}
// ── Internal: DOM ────────────────────────
_buildDOM(msgs, start, end) {
const container = this._container;
let html = '';
// Top sentinel (triggers prepend on intersection)
html += '<div id="vs-top-sentinel" class="vs-sentinel" style="height:1px"></div>';
// "Load more" indicator
if (start > 0) {
html += '<div class="vs-load-more" id="vs-load-more" style="text-align:center;padding:12px 0">' +
'<span style="font-size:12px;color:var(--text-3)">' +
start + ' earlier messages</span></div>';
}
// Summary boundary
if (this._summaryIdx >= 0) {
const summaryMsg = this._messages[this._summaryIdx];
if (summaryMsg && this._summaryFn) {
html += this._summaryFn(summaryMsg);
}
}
// Messages
for (let i = start; i < end; i++) {
html += this._renderOne(msgs[i], i);
}
container.innerHTML = html;
// Set up intersection observer on top sentinel
this._setupObserver();
if (this._postRenderFn) this._postRenderFn(container);
}
_renderOne(msg, index) {
if (!msg) return '';
// Skip system messages and summary messages (summary handled separately)
if (msg.role === 'system') return '';
if (msg._isSummary) return '';
const dimmed = this._summaryIdx >= 0 && index < this._summaryIdx;
return this._renderFn(msg, index, dimmed);
}
_removeFromBottom(count) {
const container = this._container;
const messages = container.querySelectorAll('.message');
const toRemove = Array.from(messages).slice(-count);
toRemove.forEach(el => el.remove());
}
// ── Observer ─────────────────────────────
_setupObserver() {
this._teardownObserver();
this._topSentinel = this._container.querySelector('#vs-top-sentinel');
if (!this._topSentinel) return;
this._observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && entry.target.id === 'vs-top-sentinel') {
this._prependChunk();
}
}
}, {
root: this._container,
rootMargin: '200px 0px 0px 0px', // trigger 200px before reaching top
});
this._observer.observe(this._topSentinel);
}
_updateSentinelState() {
const loadMore = this._container.querySelector('#vs-load-more');
if (loadMore) {
if (this._renderedRange.start <= 0) {
loadMore.style.display = 'none';
} else {
loadMore.querySelector('span').textContent =
this._renderedRange.start + ' earlier messages';
}
}
}
_teardownObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
this._topSentinel = null;
}
_teardown() {
this._teardownObserver();
this._renderedRange = { start: 0, end: 0 };
}
// ── Helpers ──────────────────────────────
/**
* Returns messages with summary detection metadata.
* Mirrors the filtering logic from renderMessages().
*/
_filteredMessages() {
return this._messages.filter(m => m.role !== 'system');
}
}
// ── Singleton ────────────────────────────
const virtualScroll = new VirtualScroll();
window.VirtualScroll = virtualScroll;
sb.register('VirtualScroll', virtualScroll);

View File

@@ -1,169 +0,0 @@
// ==========================================
// Chat Switchboard — Workflow API Methods
// ==========================================
// Extends the global API object with workflow CRUD, stage management,
// publishing, instance lifecycle, and assignment queue operations.
// Loaded after api.js on the chat and admin surfaces.
// ── Workflow Definitions ────────────────
API.listWorkflows = function(teamId) {
const q = teamId ? `?team_id=${teamId}` : '';
return this._get('/api/v1/workflows' + q);
};
API.getWorkflow = function(id) {
return this._get(`/api/v1/workflows/${id}`);
};
API.createWorkflow = function(data) {
return this._post('/api/v1/workflows', data);
};
API.updateWorkflow = function(id, patch) {
return this._patch(`/api/v1/workflows/${id}`, patch);
};
API.deleteWorkflow = function(id) {
return this._del(`/api/v1/workflows/${id}`);
};
// ── Stages ──────────────────────────────
API.listStages = function(workflowId) {
return this._get(`/api/v1/workflows/${workflowId}/stages`);
};
API.createStage = function(workflowId, data) {
return this._post(`/api/v1/workflows/${workflowId}/stages`, data);
};
API.updateStage = function(workflowId, stageId, data) {
return this._put(`/api/v1/workflows/${workflowId}/stages/${stageId}`, data);
};
API.deleteStage = function(workflowId, stageId) {
return this._del(`/api/v1/workflows/${workflowId}/stages/${stageId}`);
};
API.reorderStages = function(workflowId, orderedIds) {
return this._patch(`/api/v1/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
};
// ── Versioning ──────────────────────────
API.publishWorkflow = function(workflowId) {
return this._post(`/api/v1/workflows/${workflowId}/publish`, {});
};
API.getWorkflowVersion = function(workflowId, version) {
return this._get(`/api/v1/workflows/${workflowId}/versions/${version}`);
};
// ── Instances ───────────────────────────
API.startWorkflow = function(workflowId) {
return this._post(`/api/v1/workflows/${workflowId}/start`, {});
};
API.getWorkflowStatus = function(channelId) {
return this._get(`/api/v1/channels/${channelId}/workflow/status`);
};
API.advanceWorkflow = function(channelId, data) {
return this._post(`/api/v1/channels/${channelId}/workflow/advance`, { data });
};
API.rejectWorkflow = function(channelId, reason) {
return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason });
};
// ── Forms ────────────────────────────────
API.getWorkflowForm = function(channelId) {
return this._get(`/api/v1/w/${channelId}/form`);
};
API.submitWorkflowForm = function(channelId, data) {
return this._post(`/api/v1/w/${channelId}/form-submit`, data);
};
// ── Workflow Packages (v0.30.2) ─────────
API.exportWorkflowPkg = function(workflowId) {
// Trigger download via hidden link (binary response)
const a = document.createElement('a');
a.href = (window.__BASE__ || '') + `/api/v1/admin/workflows/${workflowId}/export`;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ── Assignments ─────────────────────────
API.listMyAssignments = function() {
return this._get('/api/v1/workflow-assignments/mine');
};
API.listTeamAssignments = function(teamId, status) {
return this._get(`/api/v1/teams/${teamId}/assignments?status=${status || 'unassigned'}`);
};
API.claimAssignment = function(id) {
return this._post(`/api/v1/workflow-assignments/${id}/claim`, {});
};
API.completeAssignment = function(id) {
return this._post(`/api/v1/workflow-assignments/${id}/complete`, {});
};
// ── Team-Scoped Workflow CRUD (v0.31.2) ──
API.listTeamWorkflows = function(teamId) {
return this._get(`/api/v1/teams/${teamId}/workflows`);
};
API.getTeamWorkflow = function(teamId, id) {
return this._get(`/api/v1/teams/${teamId}/workflows/${id}`);
};
API.createTeamWorkflow = function(teamId, data) {
return this._post(`/api/v1/teams/${teamId}/workflows`, data);
};
API.updateTeamWorkflow = function(teamId, id, patch) {
return this._patch(`/api/v1/teams/${teamId}/workflows/${id}`, patch);
};
API.deleteTeamWorkflow = function(teamId, id) {
return this._del(`/api/v1/teams/${teamId}/workflows/${id}`);
};
API.listTeamWorkflowStages = function(teamId, workflowId) {
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`);
};
API.createTeamWorkflowStage = function(teamId, workflowId, data) {
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`, data);
};
API.updateTeamWorkflowStage = function(teamId, workflowId, stageId, data) {
return this._put(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`, data);
};
API.deleteTeamWorkflowStage = function(teamId, workflowId, stageId) {
return this._del(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`);
};
API.reorderTeamWorkflowStages = function(teamId, workflowId, orderedIds) {
return this._patch(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
};
API.publishTeamWorkflow = function(teamId, workflowId) {
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/publish`, {});
};
API.getTeamWorkflowVersion = function(teamId, workflowId, version) {
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/versions/${version}`);
};

View File

@@ -1,128 +0,0 @@
// workflow-monitor.js — v0.35.0 Workflow Monitoring Dashboard
//
// Admin monitoring tab showing active instances, stage funnels,
// SLA status indicators, and stale instance detection.
export function mountMonitorTab(container, { basePath }) {
container.innerHTML = `
<div class="wf-monitor">
<div class="wf-monitor-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="font-size:16px;font-weight:600">Workflow Monitor</h3>
<button id="monRefresh" class="btn btn-sm btn-outline">Refresh</button>
</div>
<div id="monInstances" style="margin-bottom:24px">Loading…</div>
<div id="monStale" style="margin-bottom:24px"></div>
</div>
`;
let refreshTimer = null;
async function loadInstances() {
try {
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/instances`);
if (!resp.ok) throw new Error(resp.statusText);
const { data } = await resp.json();
renderInstances(data);
} catch (e) {
document.getElementById('monInstances').textContent = 'Failed to load: ' + e.message;
}
}
async function loadStale() {
try {
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/stale`);
if (!resp.ok) return;
const { data } = await resp.json();
renderStale(data);
} catch (_) {}
}
function renderInstances(instances) {
const el = document.getElementById('monInstances');
if (!instances.length) {
el.innerHTML = '<p style="color:var(--text-3)">No active workflow instances.</p>';
return;
}
let html = '<table style="width:100%;border-collapse:collapse;font-size:13px">';
html += '<thead><tr style="border-bottom:2px solid var(--border);text-align:left">';
html += '<th style="padding:8px">Workflow</th><th style="padding:8px">Stage</th>';
html += '<th style="padding:8px">Age</th><th style="padding:8px">SLA</th>';
html += '</tr></thead><tbody>';
for (const inst of instances) {
const age = formatDuration(inst.age_seconds);
const sla = renderSLA(inst);
html += `<tr style="border-bottom:1px solid var(--border)">`;
html += `<td style="padding:8px">${esc(inst.workflow_name)}<br><span style="font-size:11px;color:var(--text-3)">${esc(inst.channel_title)}</span></td>`;
html += `<td style="padding:8px">${esc(inst.stage_name)} <span style="color:var(--text-3)">(${inst.current_stage})</span></td>`;
html += `<td style="padding:8px">${age}</td>`;
html += `<td style="padding:8px">${sla}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
el.innerHTML = html;
}
function renderSLA(inst) {
if (!inst.sla_seconds) return '<span style="color:var(--text-3)">—</span>';
if (inst.sla_breached) {
return '<span style="color:var(--danger,#e74c3c);font-weight:600">BREACHED</span>';
}
if (inst.sla_remaining_seconds != null) {
const remaining = inst.sla_remaining_seconds;
const pct = Math.max(0, remaining / inst.sla_seconds);
const color = pct > 0.5 ? 'var(--success,#2ecc71)' : pct > 0.2 ? 'var(--warning,#f39c12)' : 'var(--danger,#e74c3c)';
return `<span style="color:${color}">${formatDuration(remaining)}</span>`;
}
return '—';
}
function renderStale(instances) {
const el = document.getElementById('monStale');
if (!instances.length) {
el.innerHTML = '';
return;
}
let html = '<h4 style="font-size:14px;color:var(--danger,#e74c3c);margin-bottom:8px">Stale Instances (' + instances.length + ')</h4>';
html += '<ul style="list-style:none;padding:0">';
for (const inst of instances) {
html += `<li style="padding:4px 0;font-size:13px">${esc(inst.workflow_name)}${esc(inst.stage_name)} (${formatDuration(inst.age_seconds)} old)</li>`;
}
html += '</ul>';
el.innerHTML = html;
}
function formatDuration(seconds) {
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// Initial load
loadInstances();
loadStale();
// Auto-refresh every 30s
refreshTimer = setInterval(() => {
loadInstances();
loadStale();
}, 30000);
document.getElementById('monRefresh')?.addEventListener('click', () => {
loadInstances();
loadStale();
});
// Return cleanup function
return () => {
if (refreshTimer) clearInterval(refreshTimer);
};
}

View File

@@ -1,307 +0,0 @@
// ==========================================
// Chat Switchboard — Workflow Queue UI
// ==========================================
// Sidebar section showing:
// 1. Active workflow channels (type='workflow' from App.chats)
// 2. Claimed assignments from team queues
// Loaded on the chat surface after workflow-api.js.
const WorkflowQueue = {
_loaded: false,
_myAssignments: [],
// ── Init ────────────────────────────
async init() {
if (this._loaded) return;
this._loaded = true;
// Insert sidebar section after channels
var channelsSec = document.getElementById('sbSectionChannels');
if (!channelsSec) return;
var section = document.createElement('div');
section.className = 'sb-section';
section.id = 'sbSectionQueue';
section.innerHTML =
'<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>' +
'</div>' +
'<div class="sb-section-body" id="sbBodyQueue"></div>';
channelsSec.parentNode.insertBefore(section, channelsSec.nextSibling);
this.refresh();
},
// ── Pinned set ────────────────────────
// Only show workflow channels the user has explicitly opened/pinned.
// Stored in localStorage to persist across sessions.
_getPinned() {
try {
return JSON.parse(localStorage.getItem('sb_wf_pinned') || '[]');
} catch (_) { return []; }
},
_pin(channelId) {
var pinned = this._getPinned();
if (pinned.indexOf(channelId) === -1) {
pinned.push(channelId);
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
}
},
_unpin(channelId) {
var pinned = this._getPinned().filter(function(id) { return id !== channelId; });
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
this.refresh();
},
// ── Refresh ─────────────────────────
// Called after loadChats() and on assignment changes.
async refresh() {
var body = document.getElementById('sbBodyQueue');
var badge = document.getElementById('sbQueueBadge');
if (!body) return;
var self = this;
var pinned = this._getPinned();
// 1. Workflow channels — only show pinned ones
var allWorkflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow';
});
var workflows = allWorkflows.filter(function(c) {
return pinned.indexOf(c.id) !== -1;
});
// 2. Claimed assignments (async, best-effort)
var assignments = [];
try {
var resp = await API.listMyAssignments();
assignments = resp.data || [];
this._myAssignments = assignments;
// Auto-pin channels from assignments
assignments.forEach(function(a) {
if (a.channel_id) self._pin(a.channel_id);
});
} catch (e) {
// Non-critical — show channels even if assignments fail
}
var total = workflows.length + assignments.length;
var unpinnedCount = allWorkflows.length - workflows.length;
if (badge) {
badge.textContent = total;
badge.style.display = total > 0 ? '' : 'none';
}
var html = '';
// Workflow channels (pinned only)
workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id;
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' +
'title="' + esc(wf.title) + '">' +
'<span class="sb-queue-icon">&#9889;</span>' +
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
'<button class="sb-queue-unpin" data-action="WorkflowQueue.unpin" data-args=\'' + JSON.stringify([wf.id]) + '\' title="Remove from sidebar">&times;</button>' +
'</div>';
});
// Claimed assignments (deduplicated against channel list)
if (assignments.length > 0) {
assignments.forEach(function(a) {
var alreadyShown = workflows.some(function(w) { return w.id === a.channel_id; });
if (alreadyShown) return;
html += '<div class="sb-queue-item" ' +
'data-action="WorkflowQueue.openAssignment" data-args=\'' + JSON.stringify([a.id, a.channel_id]) + '\'>' +
'<span class="sb-queue-icon">&#128203;</span>' +
'<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' +
'<button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>&check;</button>' +
'</div>';
});
}
// Browse button — shows when there are unpinned workflows available
if (unpinnedCount > 0 || total === 0) {
html += '<div class="sb-queue-browse" data-action="WorkflowQueue.browse">' +
'<span class="sb-queue-icon">+</span>' +
'<span class="sb-queue-label">Browse Workflows' + (unpinnedCount > 0 ? ' (' + unpinnedCount + ')' : '') + '</span>' +
'</div>';
}
body.innerHTML = html;
},
// ── Unpin ──────────────────────────
unpin(channelId) {
this._unpin(channelId);
},
// ── Browse dialog ─────────────────────
browse() {
var self = this;
var pinned = this._getPinned();
var allWorkflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow';
});
var unpinned = allWorkflows.filter(function(c) {
return pinned.indexOf(c.id) === -1;
});
if (unpinned.length === 0) {
UI.toast('No available workflows', 'info');
return;
}
// Remove any existing browse overlay
var existing = document.getElementById('wfBrowseOverlay');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'wfBrowseOverlay';
overlay.className = 'confirm-overlay';
var rows = '';
unpinned.forEach(function(wf) {
rows += '<div class="wf-browse-row" data-wf-id="' + wf.id + '">' +
'<span class="sb-queue-icon">&#9889;</span>' +
'<span>' + esc(wf.title || 'Workflow') + '</span>' +
'</div>';
});
overlay.innerHTML =
'<div class="confirm-dialog" style="max-width:400px">' +
'<div class="confirm-header">Browse Workflows</div>' +
'<div class="confirm-body" style="padding:0">' +
'<p style="color:var(--text-2);font-size:13px;margin:0;padding:12px 16px 8px">Select a workflow to add to your sidebar:</p>' +
'<div class="wf-browse-dialog">' + rows + '</div>' +
'</div>' +
'<div class="confirm-footer">' +
'<button class="btn-small" data-action="cancel">Cancel</button>' +
'</div>' +
'</div>';
overlay.addEventListener('click', function(e) {
var row = e.target.closest('.wf-browse-row');
if (row) {
var wfId = row.getAttribute('data-wf-id');
overlay.remove();
self._pin(wfId);
self.refresh();
if (typeof selectChannel === 'function') selectChannel(wfId);
return;
}
if (e.target === overlay || e.target.closest('[data-action="cancel"]')) {
overlay.remove();
}
});
document.body.appendChild(overlay);
},
// ── Open Assignment ─────────────────
openAssignment(assignmentId, channelId) {
this._pin(channelId);
if (typeof selectChannel === 'function') {
selectChannel(channelId);
}
},
// ── Claim ───────────────────────────
async claim(assignmentId) {
try {
await API.claimAssignment(assignmentId);
UI.toast('Assignment claimed', 'success');
await this.refresh();
} catch (e) {
UI.toast('Failed to claim: ' + e.message, 'error');
}
},
// ── Complete ────────────────────────
async complete(assignmentId) {
try {
await API.completeAssignment(assignmentId);
UI.toast('Assignment completed', 'success');
await this.refresh();
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
},
// ── Team Queue Panel ────────────────
async openTeamPanel(teamId) {
try {
var unassigned = await API.listTeamAssignments(teamId, 'unassigned');
var claimed = await API.listTeamAssignments(teamId, 'claimed');
var uItems = unassigned.data || [];
var cItems = claimed.data || [];
if (uItems.length === 0 && cItems.length === 0) {
UI.toast('Queue is empty', 'info');
return;
}
var html = '<div class="wf-queue-panel"><h4>Team Queue</h4>';
if (uItems.length > 0) {
html += '<h5>Unassigned (' + uItems.length + ')</h5>';
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Created</th><th></th></tr></thead><tbody>';
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" data-action="WorkflowQueue.claim" data-args=\'' + JSON.stringify([a.id]) + '\'>Claim</button></td></tr>';
});
html += '</tbody></table>';
}
if (cItems.length > 0) {
html += '<h5>In Progress (' + cItems.length + ')</h5>';
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Assigned</th><th></th></tr></thead><tbody>';
cItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</td>' +
'<td>' + (a.assigned_to || '—') + '</td>' +
'<td><button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>Complete</button></td></tr>';
});
html += '</tbody></table>';
}
html += '</div>';
if (typeof showModal === 'function') {
showModal('Team Queue', html);
} else {
var el = document.getElementById('adminDynamic');
if (el) el.innerHTML = html;
}
} catch (e) {
UI.toast('Failed to load queue: ' + e.message, 'error');
}
},
};
sb.ns('WorkflowQueue', WorkflowQueue);
// Auto-init when chat surface loads
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
if (typeof API !== 'undefined' && API.accessToken) {
WorkflowQueue.init();
}
}, 2000);
});