This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/ui-core.js
2026-03-14 14:43:55 +00:00

1655 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// Chat Switchboard UI Core
// ==========================================
// UI object definition: sidebar, chat list, messages, streaming,
// model selector, capability badges, user, generating state, toast,
// settings modal dispatcher, export, message actions, scroll.
// Extended by ui-settings.js and ui-admin.js via Object.assign.
//
// Exports: window.UI, window.renderPersonaForm,
// window.toggleSummarizedHistory (onclick handler)
// ==========================================
(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) {
if (avatarDataURI) {
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
}
return role === 'user' ? '👤' : role === 'other-user' ? '👤' : '🤖';
}
// Look up the current model/persona avatar for assistant messages.
function assistantAvatarURI(msg) {
// 1. Check channel model roster for persona avatar (v0.23.0)
if (typeof ChannelModels !== 'undefined' && msg?.model) {
const info = ChannelModels.resolvePersonaInfo(msg.model);
if (info?.avatar) return info.avatar;
}
// 2. Check participant_id directly (for chained messages)
if (msg?.participant_type === 'persona' && msg?.participant_id) {
const m = App.models.find(x => x.personaId === msg.participant_id);
if (m?.personaAvatar) return m.personaAvatar;
}
// 3. Standard lookup by model ID or persona ID
const modelId = msg?.model || msg?.persona_id;
if (modelId) {
const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
if (m?.personaAvatar) return m.personaAvatar;
}
return null;
}
// ── Shared Persona Form Component ────────────
// Renders a persona creation/edit form into a container element.
// Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel }
// Returns: { getValues(), setValues(persona), clearForm(), container }
function renderPersonaForm(containerEl, options = {}) {
const pfx = options.prefix || 'pf';
const showAvatar = options.showAvatar !== false;
const showConfig = !!options.showProviderConfig;
containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
<div class="form-group"><label>@mention Handle</label><input type="text" id="${pfx}_handle" placeholder="code-reviewer" maxlength="50" class="mono-input"><div class="form-hint">Auto-generated from name. Used for @mentions in chat.</div></div>
${showAvatar ? `
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
<span id="${pfx}_avatarPlaceholder">🤖</span>
</div>
<div class="avatar-actions">
<button class="btn-small" type="button" id="${pfx}_avatarUploadBtn">Upload Avatar</button>
<button class="btn-small btn-danger" type="button" id="${pfx}_avatarRemoveBtn" style="display:none">Remove</button>
<input type="file" id="${pfx}_avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
<div class="form-hint">Optional · 128×128 · shown in chat</div>
</div>
</div>` : ''}
<div class="form-group"><label>Description</label><input type="text" id="${pfx}_desc" placeholder="Reviews code for best practices"></div>
<div class="form-row">
<div class="form-group"><label>Base Model</label><select id="${pfx}_model"></select></div>
${showConfig ? `<div class="form-group"><label>Provider Config</label><select id="${pfx}_config"><option value="">Auto-resolve</option></select></div>` : ''}
</div>
<div class="form-group"><label>System Prompt</label><textarea id="${pfx}_prompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
<div class="form-row">
<div class="form-group"><label>Temperature</label><input type="number" id="${pfx}_temp" step="0.1" min="0" max="2" placeholder="default"></div>
<div class="form-group"><label>Max Tokens</label><input type="number" id="${pfx}_maxTokens" placeholder="default"></div>
</div>
<div class="form-group memory-persona-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_memoryEnabled" checked> Enable Memory</label>
<p class="form-hint" style="margin:2px 0 6px">When enabled, the AI can save and recall facts about users across conversations.</p>
<div id="${pfx}_memoryExtractionPromptWrap">
<label>Custom Extraction Prompt <span class="form-hint">(optional)</span></label>
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
</div>
</div>
<div class="form-group tool-grants-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_toolsAll" checked> All tools (inherit from context)</label>
<p class="form-hint" style="margin:2px 0 6px">When unchecked, restrict this persona to only the selected tools below.</p>
<div id="${pfx}_toolsList" class="tool-grants-list" style="display:none;max-height:200px;overflow-y:auto;padding:4px 0"></div>
</div>
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
</div>
`;
// Avatar wiring
if (showAvatar) {
const uploadBtn = document.getElementById(`${pfx}_avatarUploadBtn`);
const fileInput = document.getElementById(`${pfx}_avatarFileInput`);
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
uploadBtn?.addEventListener('click', () => fileInput.click());
fileInput?.addEventListener('change', function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => form.updateAvatarPreview(reader.result);
reader.readAsDataURL(file);
this.value = '';
});
removeBtn?.addEventListener('click', () => form.updateAvatarPreview(null));
}
// Cancel wiring
document.getElementById(`${pfx}_cancelBtn`)?.addEventListener('click', () => {
if (options.onCancel) options.onCancel();
});
// Auto-generate handle from name
let _handleManuallyEdited = false;
document.getElementById(`${pfx}_handle`)?.addEventListener('input', () => { _handleManuallyEdited = true; });
document.getElementById(`${pfx}_name`)?.addEventListener('input', () => {
if (_handleManuallyEdited) return;
const name = document.getElementById(`${pfx}_name`)?.value || '';
const handle = name.toLowerCase().trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
const hEl = document.getElementById(`${pfx}_handle`);
if (hEl) hEl.value = handle;
});
// Submit wiring
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
if (options.onSubmit) options.onSubmit(form.getValues());
});
// v0.25.0: Tool grants — toggle "All tools" checkbox shows/hides tool list
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
const toolsListEl = document.getElementById(`${pfx}_toolsList`);
if (toolsAllCb && toolsListEl) {
toolsAllCb.addEventListener('change', () => {
toolsListEl.style.display = toolsAllCb.checked ? 'none' : '';
if (!toolsAllCb.checked && toolsListEl.children.length === 0) {
form.loadToolList();
}
});
}
const form = {
getValues() {
const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
handle: document.getElementById(`${pfx}_handle`)?.value.trim() || '',
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
};
if (showConfig) {
const cfgId = document.getElementById(`${pfx}_config`)?.value;
if (cfgId) v.provider_config_id = cfgId;
}
const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value);
if (!isNaN(temp)) v.temperature = temp;
const maxTok = parseInt(document.getElementById(`${pfx}_maxTokens`)?.value);
if (!isNaN(maxTok) && maxTok > 0) v.max_tokens = maxTok;
// Pending avatar data URI
const img = document.querySelector(`#${pfx}_avatarPreview img`);
if (img?.src?.startsWith('data:')) v._pendingAvatar = img.src;
// Memory fields (v0.18.0)
const memCb = document.getElementById(`${pfx}_memoryEnabled`);
if (memCb) v.memory_enabled = memCb.checked;
const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim();
if (memPrompt) v.memory_extraction_prompt = memPrompt;
// v0.25.0: Tool grants
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
if (toolsAllCb && !toolsAllCb.checked) {
const checked = document.querySelectorAll(`#${pfx}_toolsList input[type="checkbox"]:checked`);
v.tool_grants = Array.from(checked).map(cb => cb.value);
} else {
v.tool_grants = []; // empty = inherit all
}
return v;
},
setValues(p) {
const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = p.name || '';
if (el('handle')) { el('handle').value = p.handle || ''; _handleManuallyEdited = !!p.handle; }
if (el('desc')) el('desc').value = p.description || '';
if (el('prompt')) el('prompt').value = p.system_prompt || '';
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : '';
if (el('model')) el('model').value = p.base_model_id || '';
if (showConfig && el('config')) el('config').value = p.provider_config_id || '';
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
// Memory fields (v0.18.0)
if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false;
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
// v0.25.0: Tool grants — load and apply
if (p.id) form.loadToolGrants(p.id);
},
clearForm() {
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
const modelSel = document.getElementById(`${pfx}_model`);
if (modelSel) modelSel.selectedIndex = 0;
if (showConfig) {
const cfgSel = document.getElementById(`${pfx}_config`);
if (cfgSel) cfgSel.selectedIndex = 0;
}
if (showAvatar) form.updateAvatarPreview(null);
},
updateAvatarPreview(dataURI) {
const preview = document.getElementById(`${pfx}_avatarPreview`);
const placeholder = document.getElementById(`${pfx}_avatarPlaceholder`);
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
if (!preview) return;
const existing = preview.querySelector('img');
if (dataURI) {
if (placeholder) placeholder.style.display = 'none';
if (existing) { existing.src = dataURI; }
else {
const img = document.createElement('img');
img.src = dataURI; img.alt = 'Persona avatar';
preview.insertBefore(img, placeholder);
}
if (removeBtn) removeBtn.style.display = '';
} else {
if (existing) existing.remove();
if (placeholder) placeholder.style.display = '';
if (removeBtn) removeBtn.style.display = 'none';
}
},
setSubmitLabel(label) {
const btn = document.getElementById(`${pfx}_submitBtn`);
if (btn) btn.textContent = label;
},
getModelSelect() { return document.getElementById(`${pfx}_model`); },
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
// v0.25.0: Tool grants management
async loadToolList() {
const listEl = document.getElementById(`${pfx}_toolsList`);
if (!listEl) return;
try {
const resp = await API._get('/api/v1/tools');
const tools = resp.tools || resp.data || resp || [];
listEl.innerHTML = '';
// Group by category
const groups = {};
tools.forEach(t => {
const cat = t.category || 'other';
(groups[cat] = groups[cat] || []).push(t);
});
Object.keys(groups).sort().forEach(cat => {
const hdr = document.createElement('div');
hdr.className = 'tool-grants-category';
hdr.textContent = cat.charAt(0).toUpperCase() + cat.slice(1);
listEl.appendChild(hdr);
groups[cat].forEach(t => {
const label = document.createElement('label');
label.className = 'tool-grants-item';
label.innerHTML = `<input type="checkbox" value="${t.name}"> ${t.display_name || t.name}`;
listEl.appendChild(label);
});
});
} catch (e) {
listEl.innerHTML = '<div class="form-hint">Failed to load tools</div>';
}
},
async loadToolGrants(personaId) {
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
const listEl = document.getElementById(`${pfx}_toolsList`);
if (!toolsAllCb || !listEl) return;
try {
const resp = await API._get(`/api/v1/personas/${personaId}/tool-grants`);
const grants = resp.data || [];
if (grants.length === 0) {
toolsAllCb.checked = true;
listEl.style.display = 'none';
} else {
toolsAllCb.checked = false;
listEl.style.display = '';
await form.loadToolList();
const grantSet = new Set(grants);
listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = grantSet.has(cb.value);
});
}
} catch (_) {
// Endpoint may not exist yet — default to all tools
toolsAllCb.checked = true;
listEl.style.display = 'none';
}
},
};
return form;
}
// ── Summary Message Helpers ─────────────────
function _isSummaryMessage(msg) {
if (!msg.metadata) return false;
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
return meta?.type === 'summary';
}
function toggleSummarizedHistory() {
const el = document.getElementById('summarizedHistory');
const btn = document.querySelector('.message-summary-divider .summary-toggle');
if (!el) return;
const show = el.style.display === 'none';
el.style.display = show ? 'block' : 'none';
if (btn) {
const count = el.querySelectorAll('.message').length;
btn.textContent = show ? `▾ Hide ${count} earlier messages` : `${count} earlier messages summarized`;
}
}
const UI = {
// ── Sidebar ──────────────────────────────
toggleSidebar() {
const sb = document.getElementById('sidebar');
sb.classList.toggle('collapsed');
const collapsed = sb.classList.contains('collapsed');
localStorage.setItem('sb_sidebar', collapsed ? '1' : '0');
const overlay = document.getElementById('sidebarOverlay');
if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none');
},
restoreSidebar() {
const el = document.getElementById('sidebar');
if (!el) return; // not all surfaces have a sidebar (e.g. editor)
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
el.classList.add('collapsed');
}
},
// ── User Menu Flyout ─────────────────────
toggleUserMenu() {
const flyout = document.getElementById('userFlyout');
const opening = !flyout.classList.contains('open');
flyout.classList.toggle('open');
// On open: refresh team admin button visibility (fire-and-forget)
if (opening) {
const menuBtn = document.getElementById('menuTeamAdmin');
if (menuBtn) {
// Show from cache immediately if available
if (UI._myTeams && UI._myTeams.length > 0) {
const cached = UI._myTeams.some(t => t.my_role === 'admin');
menuBtn.style.display = cached ? '' : 'none';
}
// Refresh in background
API.listMyTeams().then(resp => {
const teams = resp.data || [];
UI._myTeams = teams;
menuBtn.style.display = teams.some(t => t.my_role === 'admin') ? '' : 'none';
}).catch(() => {});
}
}
},
closeUserMenu() {
const el = document.getElementById('userFlyout');
if (el) el.classList.remove('open');
},
// ── Sidebar Section Collapse ──────────────
// State persisted in localStorage per section key.
_sidebarSections: null,
_getSidebarSections() {
if (!this._sidebarSections) {
try {
this._sidebarSections = JSON.parse(localStorage.getItem('cs-sidebar-sections') || '{}');
} catch (_) { this._sidebarSections = {}; }
}
return this._sidebarSections;
},
toggleSidebarSection(key) {
const sections = this._getSidebarSections();
sections[key] = !sections[key]; // true = collapsed
try { localStorage.setItem('cs-sidebar-sections', JSON.stringify(sections)); } catch (_) {}
this._applySidebarSection(key, sections[key]);
},
_applySidebarSection(key, collapsed) {
const body = document.getElementById('sbBody' + key[0].toUpperCase() + key.slice(1));
const arrow = document.getElementById('sbArrow' + key[0].toUpperCase() + key.slice(1));
if (body) body.style.display = collapsed ? 'none' : '';
if (arrow) arrow.textContent = collapsed ? '▸' : '▾';
},
restoreSidebarSections() {
const sections = this._getSidebarSections();
['projects', 'channels', 'chats'].forEach(k => {
this._applySidebarSection(k, !!sections[k]);
});
},
// ── Projects Section ──────────────────────
renderProjectsSection() {
const el = document.getElementById('sbBodyProjects');
if (!el) return;
const projects = App.projects || [];
const showArchived = App.showArchivedProjects || false;
const visible = showArchived ? projects : projects.filter(p => !p.isArchived);
const hasArchived = projects.some(p => p.isArchived);
const sidebar = document.getElementById('sidebar');
const collapsed = sidebar?.classList.contains('collapsed');
if (visible.length === 0 && !hasArchived) {
el.innerHTML = collapsed ? '' :
'<div class="sb-section-empty">No projects yet</div>';
return;
}
let html = '';
visible.forEach(proj => {
const isCollapsed = App.collapsedProjects?.[proj.id];
const isActive = App.activeProjectId === proj.id;
const dot = proj.color
? `<span class="sb-proj-dot" style="background:${esc(proj.color)}"></span>`
: '<span class="sb-proj-dot"></span>';
const arrow = isCollapsed ? '▸' : '▾';
const pinIcon = isActive ? '<span class="sb-proj-pin" title="Pinned for new chats">📍</span>' : '';
const chats = (App.chats || []).filter(c => c.projectId === proj.id);
html += `<div class="sb-proj-group${isActive ? ' active' : ''}${proj.isArchived ? ' archived' : ''}"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${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" data-action="showProjectMenu" data-args='${JSON.stringify([proj.id])}' data-pass-event title="Options">⋯</button>` : ''}
</div>`;
if (!isCollapsed && !collapsed) {
if (chats.length === 0) {
html += '<div class="sb-proj-empty">Drop chats here</div>';
} else {
chats.forEach(c => { html += this._chatItemHTML(c); });
}
}
html += '</div>';
});
if (hasArchived && !collapsed) {
html += `<button class="sb-show-archived" data-action="_toggleArchivedProjects"">
${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`}
</button>`;
}
el.innerHTML = html;
},
// ── Channels Section ──────────────────────
renderChannelsSection() {
const el = document.getElementById('sbBodyChannels');
if (!el) return;
const channels = App.channels || [];
const sidebar = document.getElementById('sidebar');
const collapsed = sidebar?.classList.contains('collapsed');
if (channels.length === 0) {
el.innerHTML = collapsed ? '' :
'<div class="sb-section-empty">No channels yet</div>';
return;
}
let html = '';
channels.forEach(ch => {
const isActive = App.activeId === ch.id;
const isDM = ch.type === 'dm';
const isChannel = ch.type === 'channel';
const online = App.presence?.[ch.dmPartnerId] === 'online';
// Icon: @ for DM, # for channel
const icon = isDM
? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`;
const onlineDot = isDM && online
? '<span class="sb-presence online"></span>'
: '';
const unreadBadge = ch.unread > 0
? `<span class="sb-unread">${ch.unread > 99 ? '99+' : ch.unread}</span>`
: '';
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
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" data-action="showChannelContextMenu" data-args='${JSON.stringify([ch.id])}' data-pass-event title="Options">⋯</button>` : unreadBadge}
</div>`;
});
el.innerHTML = html;
},
// ── Chats Section (renderChatList) ────────
renderChatList() {
// Target the new section body; fall back to legacy id during transition.
const el = document.getElementById('sbBodyChats') || document.getElementById('chatHistory');
if (!el) return;
const searchInput = document.getElementById('chatSearchInput');
const searchWrap = document.getElementById('sidebarSearch');
const query = (searchInput?.value || '').trim().toLowerCase();
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
// Chats section shows only direct/group chats not in a project.
// type='channel' and type='dm' belong to the Channels section (App.channels).
// type='workflow' belongs to the Queue section (WorkflowQueue).
// Project chats rendered by renderProjectsSection.
let chats = (App.chats || []).filter(c =>
!c.projectId &&
c.type !== 'channel' &&
c.type !== 'dm' &&
c.type !== 'workflow'
);
if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query));
const folders = App.folders || [];
if (chats.length === 0 && folders.length === 0) {
el.innerHTML = query
? `<div class="sb-section-empty">No chats matching "${esc(query)}"</div>`
: '<div class="sb-section-empty">No chats yet</div>';
// Still re-render projects in case search is cross-cutting
this.renderProjectsSection();
return;
}
// Group by folder, then by time for unfiled
const folderMap = {};
folders.forEach(f => { folderMap[f.id] = []; });
const unfiled = [];
chats.forEach(c => {
if (c.folderId && folderMap[c.folderId]) {
folderMap[c.folderId].push(c);
} else {
unfiled.push(c);
}
});
let html = '';
// Folder groups — always render, even when empty (with drag handlers)
folders.forEach(f => {
const items = folderMap[f.id] || [];
const isOpen = !App.collapsedFolders?.[f.id];
html += `<div class="sb-folder-group"
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" 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" 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
? (items.length > 0
? items.map(c => this._chatItemHTML(c, true)).join('')
: '<div class="sb-folder-empty">Drop chats here</div>')
: ''}
</div>`;
});
// Unfiled chats grouped by time
const now = Date.now();
const DAY = 86400000;
const groups = { today: [], yesterday: [], week: [], older: [] };
unfiled.forEach(c => {
const age = now - new Date(c.updatedAt).getTime();
if (age < DAY) groups.today.push(c);
else if (age < 2*DAY) groups.yesterday.push(c);
else if (age < 7*DAY) groups.week.push(c);
else groups.older.push(c);
});
// When folders exist, wrap unfiled chats in a drop zone
if (folders.length > 0) {
html += `<div class="sb-unfiled-zone"
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="onUnfiledDrop(event)">`;
if (unfiled.length === 0) {
html += '<div class="sb-unfiled-hint">Drop here to unfile</div>';
}
}
const renderTimeGroup = (label, items) => {
if (!items.length) return;
html += `<div class="chat-group-label">${label}</div>`;
items.forEach(c => { html += this._chatItemHTML(c); });
};
renderTimeGroup('Today', groups.today);
renderTimeGroup('Yesterday', groups.yesterday);
renderTimeGroup('Previous 7 days', groups.week);
renderTimeGroup('Older', groups.older);
if (folders.length > 0) {
html += '</div>'; // close .sb-unfiled-zone
}
el.innerHTML = html;
// Always keep projects section in sync
this.renderProjectsSection();
// Refresh workflow queue section (workflow channels filtered out of chat list)
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
// Refresh task sidebar section (v0.27.4)
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
},
toggleFolder(folderId) {
if (!App.collapsedFolders) App.collapsedFolders = {};
App.collapsedFolders[folderId] = !App.collapsedFolders[folderId];
this.renderChatList();
},
_chatItemHTML(c, indented = false) {
const time = _relativeTime(c.updatedAt);
const active = c.id === App.activeId ? ' active' : '';
const indent = indented ? ' indented' : '';
const typeIcon = c.type === 'group'
? '<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}"
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" data-action="deleteChat" data-args='${JSON.stringify([c.id])}' title="Delete">✕</button>
</div>`;
},
// ── Messages ─────────────────────────────
renderMessages(messages) {
const el = document.getElementById('chatMessages');
if (!messages || messages.length === 0) {
this.showEmptyState();
return;
}
// Find the most recent summary boundary
let summaryIdx = -1;
for (let i = 0; i < messages.length; i++) {
if (_isSummaryMessage(messages[i])) summaryIdx = i;
}
// Render: if summary exists, show collapsed-history toggle + summary + messages after
let html = '';
if (summaryIdx >= 0) {
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" data-action="toggleSummarizedHistory"">
${hiddenCount} earlier messages summarized
</button>
</div>
<div id="summarizedHistory" style="display:none">
${messages.slice(0, summaryIdx)
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i, true))
.join('')}
</div>`;
}
// Render summary node
html += this._summaryHTML(messages[summaryIdx]);
// Render messages after summary
html += messages.slice(summaryIdx + 1)
.filter(m => m.role !== 'system' && !_isSummaryMessage(m))
.map((m, i) => this._messageHTML(m, summaryIdx + 1 + i))
.join('');
} else {
html = messages
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i))
.join('');
}
el.innerHTML = html;
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
if (typeof loadAuthImages === 'function') loadAuthImages(el);
this._scrollToBottom(true);
},
_summaryHTML(msg) {
const meta = msg.metadata || {};
const count = meta.summarized_count || '?';
const model = meta.utility_model || 'utility model';
const trigger = meta.trigger === 'auto' ? ' · auto' : '';
return `
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
<div class="summary-header">
<span>📝 Conversation summary (${count} messages, by ${esc(model)}${trigger})</span>
</div>
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>`;
},
_messageHTML(msg, index, dimmed) {
const isUser = msg.role === 'user';
// Skip summary messages in normal rendering — they get _summaryHTML
if (_isSummaryMessage(msg)) return '';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
// v0.23.2: Sender attribution — distinguish self from other humans
const isSelf = !isUser ? false
: (!msg.participant_id || msg.participant_id === API.user?.id);
const isOtherUser = isUser && !isSelf;
let senderLabel = 'You';
let senderAvatarURI = API.user?.avatar || null;
if (isOtherUser) {
senderLabel = msg.sender_name || 'User';
senderAvatarURI = msg.sender_avatar || null;
} else if (!isUser) {
// Assistant message — use model/persona label
senderLabel = 'Assistant';
senderAvatarURI = assistantAvatarURI(msg);
if (msg.sender_name) {
senderLabel = msg.sender_name;
} else if (msg.modelName) {
senderLabel = msg.modelName;
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
senderLabel = ChannelModels.resolveDisplayName(msg.model);
} else if (msg.model) {
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
senderLabel = m?.name || msg.model;
}
if (msg.sender_avatar) {
senderAvatarURI = msg.sender_avatar;
}
}
// CSS class: self = right-aligned "user", other humans = left-aligned "other-user", AI = "assistant"
const roleClass = isSelf ? 'user' : isOtherUser ? 'other-user' : msg.role;
// Branch indicator: show 2/3 when siblings exist
const hasSiblings = (msg.siblingCount || 1) > 1;
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
const sibTotal = msg.siblingCount || 1;
const branchNav = hasSiblings ? `
<div class="branch-nav">
<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" 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" 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}">
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML(isOtherUser ? 'other-user' : msg.role, senderAvatarURI)}</div>
<div class="msg-body">
<div class="msg-head">
<span class="msg-role">${esc(senderLabel)}</span>
${branchNav}
${time ? `<span class="msg-time">${time}</span>` : ''}
<div class="msg-actions">
${editBtn}
${regenBtn}
<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) : ''}
<div class="msg-text">${formatMessage(msg.content)}</div>
${typeof renderMessageFiles === 'function' ? renderMessageFiles(msgId) : ''}
</div>
</div>
</div>`;
},
showEmptyState() {
var base = window.__BASE__ || '';
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-logo"><img src="${base}/favicon-256.png" class="empty-logo-img" alt=""></div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>`;
this.updateContextBanner();
},
// v0.23.2: Show/hide context banner based on active conversation type
updateContextBanner() {
const el = document.getElementById('channelContextBanner');
if (!el) return;
const ac = App.activeConversation;
if (!ac || ac.type === 'direct') {
el.style.display = 'none';
this.updateWorkflowStageBar();
return;
}
let html = '';
if (ac.type === 'dm') {
const ch = (App.channels || []).find(c => c.id === ac.id);
const name = ch?.name || 'someone';
html = `<span class="ctx-mode">DM</span>` +
`<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" 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" 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" 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" data-action="openParticipantsPanel" data-args='${JSON.stringify([ac.id])}' title="Manage participants">👥 Members</button>`;
}
el.innerHTML = html;
el.style.display = '';
this.updateWorkflowStageBar();
},
// Fetches workflow status and renders stage dots, name, advance/reject buttons.
updateWorkflowStageBar() {
const el = document.getElementById('workflowStageBar');
if (!el) return;
const ac = App.activeConversation;
if (!ac || ac.type !== 'workflow') {
el.style.display = 'none';
return;
}
// Fetch status (async, non-blocking — renders on completion)
const base = window.__BASE__ || '';
API._get(`/api/v1/channels/${ac.id}/workflow/status`).then(ws => {
if (!ws || !ws.workflow_id) {
el.style.display = 'none';
return;
}
// Cache on App for WS event handler access
App._workflowStatus = ws;
// Load stage definitions for name display
API._get(`/api/v1/workflows/${ws.workflow_id}`).then(wf => {
const stages = wf.stages || [];
const current = ws.current_stage || 0;
const status = ws.status || 'active';
const totalStages = stages.length || 1;
const stageName = stages[current] ? stages[current].name : 'Stage ' + current;
// Step dots
let dots = '';
for (let i = 0; i < totalStages; i++) {
const cls = i < current ? 'completed' : i === current ? 'current' : '';
dots += '<div class="wf-bar-step ' + cls + '" title="Stage ' + i + (stages[i] ? ': ' + esc(stages[i].name) : '') + '"></div>';
}
// Status badge
const statusCls = status === 'completed' ? 'completed' : status === 'stale' ? 'stale' : 'active';
const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
// Advance/reject buttons (only for active workflows, admin or team members)
let actions = '';
if (status === 'active' && API.isAdmin) {
actions =
'<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 =
'<div class="wf-bar-steps">' + dots + '</div>' +
'<span class="wf-bar-stage-name">' + esc(stageName) + '</span>' +
'<span class="wf-bar-status ' + statusCls + '">' + esc(statusLabel) + '</span>' +
'<span class="wf-bar-spacer"></span>' +
'<span style="font-size:11px;color:var(--text-3)">Step ' + (current + 1) + ' of ' + totalStages + '</span>' +
(actions ? '<div class="wf-bar-actions">' + actions + '</div>' : '');
el.style.display = '';
}).catch(() => {
el.style.display = 'none';
});
}).catch(() => {
el.style.display = 'none';
});
},
showEditInline(messageId, currentContent) {
const msgEl = document.querySelector(`.message[data-msg-id="${messageId}"]`);
if (!msgEl) return;
const textEl = msgEl.querySelector('.msg-text');
if (!textEl) return;
// Replace message text with an edit form
const escaped = currentContent.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
textEl.innerHTML = `
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
<div class="msg-edit-actions">
<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
const textarea = document.getElementById(`editInput_${messageId}`);
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
// Auto-resize
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
});
// Ctrl+Enter to submit
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitEdit(messageId, textarea.value);
}
if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
}
},
// ── Streaming ────────────────────────────
async streamResponse(response, messages, modelName) {
const container = document.getElementById('chatMessages');
document.getElementById('typingIndicator')?.remove();
const label = modelName || 'Assistant';
const currentModel = App.findModel(App.settings.model);
// Resolve avatar: channel roster persona → selected model → null
let streamAvatar = null;
if (typeof ChannelModels !== 'undefined') {
const info = ChannelModels.resolvePersonaInfo(currentModel?.baseModelId);
if (info?.avatar) streamAvatar = info.avatar;
}
if (!streamAvatar) {
streamAvatar = currentModel?.personaAvatar || null;
}
// Multi-model state: when model_start events arrive, we create
// separate message bubbles per model. Without them, single-bubble.
let multiModel = false;
let activeContentEl = null;
let activeToolsEl = null;
let content = '';
let reasoning = '';
// Create initial single-model bubble (replaced if multi-model)
const _createBubble = (bubbleLabel) => {
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">${esc(bubbleLabel)}</span></div>
<div class="msg-tools" style="display:none"></div>
<div class="msg-text"></div>
</div>
</div>`;
container.appendChild(div);
activeContentEl = div.querySelector('.msg-text');
activeToolsEl = div.querySelector('.msg-tools');
return div;
};
_createBubble(label);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = ''; // SSE event type
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
// SSE event type line
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
continue;
}
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') { currentEvent = ''; continue; }
try {
const parsed = JSON.parse(data);
// ── Multi-model: model_start creates a new bubble ──
if (currentEvent === 'model_start') {
if (!multiModel) {
// First model_start: remove the initial empty bubble
multiModel = true;
container.lastElementChild?.remove();
}
content = '';
reasoning = '';
const dn = parsed.display_name || parsed.model_id || 'Model';
_createBubble(dn);
currentEvent = '';
continue;
}
if (currentEvent === 'model_end') {
currentEvent = '';
continue;
}
if (currentEvent === 'tool_use') {
this._renderToolUseInEl(activeToolsEl, parsed);
currentEvent = '';
continue;
}
if (currentEvent === 'tool_result') {
this._renderToolResultInEl(activeToolsEl, parsed);
currentEvent = '';
continue;
}
// Reasoning content delta (thinking blocks)
const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || '';
if (reasoningDelta) {
reasoning += reasoningDelta;
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
if (activeContentEl) activeContentEl.innerHTML = formatMessage(full);
this._scrollToBottom();
}
// Normal content delta
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
if (activeContentEl) activeContentEl.innerHTML = formatMessage(full);
this._scrollToBottom();
// Live-update preview panel if open (debounced)
if (typeof _livePreviewUpdate === 'function') {
_livePreviewUpdate(content);
}
}
currentEvent = '';
} catch (e) { /* partial JSON */ }
}
}
return content;
},
// Tool rendering helpers that take explicit element refs (for multi-model)
_renderToolUseInEl(toolsEl, toolCalls) {
if (!toolsEl) {
// Fallback to id-based (single model compat)
this._renderToolUse(toolCalls);
return;
}
toolsEl.style.display = '';
for (const tc of toolCalls) {
const name = tc.function?.name || tc.name || 'unknown';
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-activity';
toolDiv.dataset.toolId = tc.id;
toolDiv.innerHTML = `
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status tool-running">running…</span>`;
toolsEl.appendChild(toolDiv);
}
this._scrollToBottom();
},
_renderToolResultInEl(toolsEl, result) {
if (!toolsEl) {
this._renderToolResult(result);
return;
}
const el = toolsEl.querySelector(`[data-tool-id="${result.tool_call_id}"]`);
if (el) {
const statusEl = el.querySelector('.tool-status');
if (statusEl) {
statusEl.textContent = result.is_error ? 'error' : 'done';
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
}
}
},
_renderToolUse(toolCalls) {
const el = document.getElementById('streamTools');
if (!el) return;
el.style.display = '';
for (const tc of toolCalls) {
const name = tc.function?.name || tc.name || 'unknown';
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-activity';
toolDiv.dataset.toolId = tc.id;
toolDiv.innerHTML = `
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status tool-running">running…</span>`;
el.appendChild(toolDiv);
}
this._scrollToBottom();
},
_renderToolResult(result) {
// Search all tool-activity elements by data-tool-id
const el = document.querySelector(`[data-tool-id="${result.tool_call_id}"]`);
if (el) {
const statusEl = el.querySelector('.tool-status');
if (statusEl) {
statusEl.textContent = result.is_error ? 'error' : 'done';
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
}
// Parse tool result content for a brief summary
try {
const parsed = JSON.parse(result.content);
let summary = '';
if (parsed.title) summary = parsed.title;
else if (parsed.count != null) summary = `${parsed.count} results`;
if (summary) {
const hint = document.createElement('span');
hint.className = 'tool-hint';
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
el.appendChild(hint);
}
// Add "View note" link for note tools
if (result.name && result.name.startsWith('note_') && parsed.id) {
const link = document.createElement('button');
link.className = 'tool-note-link';
link.textContent = '📝 View note';
link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
el.appendChild(link);
}
} catch (e) { /* not JSON or no useful summary */ }
}
this._scrollToBottom();
},
// ── Model Selector (Custom Dropdown) ────
_modelValue: '',
getModelValue() { return UI._modelValue || App.settings.model || ''; },
setModelValue(val, label) {
UI._modelValue = val;
if (val) { App.settings.model = val; saveSettings(); }
const btn = document.getElementById('modelDropdownLabel');
if (btn) btn.textContent = label || val || 'Select a model';
// Highlight selected item
document.querySelectorAll('#modelDropdownMenu .model-dropdown-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === val);
});
UI.updateCapabilityBadges();
if (typeof updateContextWarning === 'function') updateContextWarning();
if (typeof updateInputTokens === 'function') updateInputTokens();
},
updateModelSelector() {
const menu = document.getElementById('modelDropdownMenu');
if (!menu) return;
const current = App.settings.model;
menu.innerHTML = '';
if (App.models.length === 0) {
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
UI.setModelValue('', 'No models loaded');
} else {
const globalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'global');
const teamPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'team');
const personalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'personal');
const models = App.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;
menu.appendChild(hdr);
items.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
};
addGroup('⚡ 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(`👥 ${tn}`, teamGroups[tn]);
});
addGroup('🔧 My Personas', personalPersonas);
// No team model groups — team providers are for persona building only.
// Team members access team models through curated personas.
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
// Restore selection — resolution chain: localStorage → admin default → first visible
const visibleModels = App.models.filter(m => !m.hidden);
const match = visibleModels.find(m => m.id === current || m.baseModelId === current);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
} else if (App.defaultModel && visibleModels.length > 0) {
// Admin-configured default
const def = visibleModels.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel);
if (def) {
UI.setModelValue(def.id, def.name + (def.provider ? ` (${def.provider})` : ''));
} else {
const first = visibleModels[0];
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
}
} else if (visibleModels.length > 0) {
const first = visibleModels[0];
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
} else {
UI.setModelValue('', 'No visible models');
}
}
// Sync settings modal selector (flat select is fine there)
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = '';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
settingsSel.appendChild(opt);
});
if (current) settingsSel.value = current;
}
},
_createDropdownItem(m) {
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
const handleHint = 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">👥 ${esc(m.teamName)}</span>` : '';
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}${handleHint}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
div.addEventListener('click', () => {
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
UI._closeModelDropdown();
});
return div;
},
_closeModelDropdown() {
document.getElementById('modelDropdownMenu')?.classList.remove('open');
},
initModelDropdown() {
const btn = document.getElementById('modelDropdownBtn');
const menu = document.getElementById('modelDropdownMenu');
if (!btn || !menu) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#modelDropdown')) menu.classList.remove('open');
});
},
// ── Capability Badges ────────────────────
getSelectedModelCaps() {
const modelId = UI.getModelValue();
if (!modelId) return {};
const model = App.findModel(modelId);
// Model in list has resolved caps from backend (catalog → heuristic)
return model?.capabilities || {};
},
updateCapabilityBadges() {
const el = document.getElementById('modelCaps');
if (!el) return;
const caps = this.getSelectedModelCaps();
if (!caps || Object.keys(caps).length === 0) {
el.innerHTML = '';
return;
}
el.innerHTML = renderCapBadges(caps);
},
// ── User / Connection ────────────────────
updateUser() {
const name = API.user?.display_name || API.user?.username || 'User';
const letter = (name[0] || '?').toUpperCase();
const avatarEl = document.getElementById('userAvatar');
const letterEl = document.getElementById('avatarLetter');
if (!avatarEl || !letterEl) return; // not all surfaces have user menu DOM
// Show avatar image or initial letter
let existingImg = avatarEl.querySelector('.user-avatar-img');
if (API.user?.avatar) {
letterEl.style.display = 'none';
if (existingImg) {
existingImg.src = API.user.avatar;
} else {
const img = document.createElement('img');
img.src = API.user.avatar;
img.alt = '';
img.className = 'user-avatar-img';
img.onerror = function() {
this.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
};
avatarEl.insertBefore(img, letterEl);
}
} else {
if (existingImg) existingImg.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
}
const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = name;
},
showAdminButton(show) {
const adminEl = document.getElementById('menuAdmin');
if (adminEl) adminEl.style.display = show ? '' : 'none';
var dbg = document.getElementById('menuDebug');
if (dbg) dbg.style.display = show ? '' : 'none';
},
// ── Generating State ─────────────────────
setGenerating(on) {
document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on;
// Swap favicon to animated version during generation
const faviconLink = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
if (faviconLink) {
if (on) {
// Pulsing favicon: dots fade in/out rapidly
faviconLink._origHref = faviconLink._origHref || faviconLink.href;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="4" y="4" width="56" height="56" rx="10" fill="%23252220" stroke="%234a4540" stroke-width="1.5"/>
<circle cx="22" cy="23" r="8" fill="%23111"/><circle cx="42" cy="23" r="8" fill="%23111"/>
<circle cx="42" cy="43" r="8" fill="%23111"/><circle cx="22" cy="43" r="8" fill="%23111"/>
<circle cx="22" cy="23" r="5.5" fill="%232D7DD2"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0s"/></circle>
<circle cx="42" cy="23" r="5.5" fill="%23E8852E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.3s"/></circle>
<circle cx="42" cy="43" r="5.5" fill="%239B59B6"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.6s"/></circle>
<circle cx="22" cy="43" r="5.5" fill="%232EAA4E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.9s"/></circle>
</svg>`;
faviconLink.href = 'data:image/svg+xml,' + svg;
} else if (faviconLink._origHref) {
faviconLink.href = faviconLink._origHref;
}
}
if (on) {
const container = document.getElementById('chatMessages');
const currentModel = App.findModel(App.settings.model);
const typingAvatar = currentModel?.personaAvatar || null;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
typing.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML('assistant', typingAvatar)}</div>
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
</div>`;
container.appendChild(typing);
this._scrollToBottom(true);
} else {
document.getElementById('typingIndicator')?.remove();
}
},
showRegenerate(show) {
// Bottom-bar regenerate button removed in v0.7.1.
// Regen is now per-message via inline buttons.
},
// ── Toast ────────────────────────────────
toast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerHTML = `<span>${esc(message)}</span><button onclick="this.parentElement.remove()">✕</button>`;
container.appendChild(el);
setTimeout(() => el.remove(), 5000);
},
// ── Settings Modal ───────────────────────
openSettings(section) {
const base = window.__BASE__ || '';
window.location.href = base + '/settings/' + (section || 'general');
},
closeSettings() {
const base = window.__BASE__ || '';
const returnURL = sessionStorage.getItem('sb_settings_return');
sessionStorage.removeItem('sb_settings_return');
window.location.href = returnURL || (base + '/');
},
switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'providers') {
UI.loadProviderList();
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
if (tab === 'personas') { UI.loadUserPersonas(); UI.checkUserPersonasAllowed(); }
if (tab === 'usage') UI.loadMyUsage();
if (tab === 'roles') UI.loadUserRoles();
if (tab === 'knowledgeBases') {
if (typeof KnowledgeUI === 'undefined') {
console.error('[Settings] KnowledgeUI not loaded — check js/knowledge-ui.js');
const c = document.getElementById('kbManagePanel');
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Knowledge UI failed to load. Check browser console.</div>';
} else {
KnowledgeUI.openManagePanel();
}
}
if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'memory') {
if (typeof MemoryUI !== 'undefined') {
MemoryUI.openSettingsPanel();
} else {
const c = document.getElementById('settingsMemoryContent');
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Memory UI failed to load.</div>';
}
}
if (tab === 'notifPrefs') {
if (typeof NotifPrefs !== 'undefined') {
NotifPrefs.load();
}
}
},
// ── Export ────────────────────────────────
exportChat(format) {
const chat = App.getActiveChat();
if (!chat) return UI.toast('No chat to export', 'warning');
let content, ext, mime;
const safeName = chat.title.replace(/[^a-z0-9]/gi, '_');
if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
else if (format === 'md') {
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
.map(m => `## ${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}\n\n${m.content}`).join('\n\n---\n\n');
ext = 'md'; mime = 'text/markdown';
} else {
content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}]\n${m.content}`).join('\n\n');
ext = 'txt'; mime = 'text/plain';
}
const blob = new Blob([content], { type: mime });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href);
},
// ── Message Actions ──────────────────────
copyMessage(index) {
const chat = App.getActiveChat();
if (chat?.messages[index]) {
navigator.clipboard.writeText(chat.messages[index].content)
.then(() => UI.toast('Copied', 'success'))
.catch(() => UI.toast('Copy failed', 'error'));
}
},
// ── Scroll ───────────────────────────────
_isNearBottom() {
const el = document.getElementById('chatMessages');
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
},
_scrollToBottom(force) {
if (force || this._isNearBottom()) {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
},
// ── Appearance (scale + font) ────────────
// Shared across ALL surfaces. Previously in ui-settings.js
// which only loaded on chat/settings — editor surface was skipped.
applyAppearance(scale, msgFont) {
// Scale #surfaceInner — the generic wrapper injected by base.html
// around every surface template. No surface-specific selectors.
// #surface stays as flex child, banners are siblings — untouched.
const inner = document.getElementById('surfaceInner');
if (inner) {
if (scale === 100) {
inner.style.transform = '';
inner.style.width = '';
inner.style.height = '';
} else {
const z = scale / 100;
inner.style.transform = `scale(${z})`;
inner.style.width = (100 / z) + '%';
inner.style.height = (100 / z) + '%';
}
}
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => {
if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t);
});
});
},
restoreAppearance() {
// Clear stale zoom/transform from any previous scaling approaches
document.body.style.zoom = '';
document.documentElement.style.zoom = '';
try {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
if (scale !== 100 || msgFont !== 14) {
UI.applyAppearance(scale, msgFont);
} else {
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
}
} catch (_) { /* corrupt localStorage — ignore */ }
}
};
// ── Body-level delegation for data-action buttons ──
// Wired once, handles all data-action clicks across sidebar, messages, etc.
_uiDispatch(document.body);
// ── Exports ─────────────────────────────────
sb.ns('UI', UI);
sb.register('renderPersonaForm', renderPersonaForm);
sb.register('_uiDispatch', _uiDispatch);
sb.register('_toggleArchivedProjects', _toggleArchivedProjects);
sb.register('_submitEditFromDOM', _submitEditFromDOM);
sb.register('toggleSummarizedHistory', toggleSummarizedHistory);
})();