// ==========================================
// 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: if data URI available, emoji fallback.
function avatarHTML(role, avatarDataURI) {
if (avatarDataURI) {
return ` `;
}
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 = `
Name
${showAvatar ? `
` : ''}
Description
System Prompt
Create
Cancel
`;
// 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 = ` ${t.display_name || t.name}`;
listEl.appendChild(label);
});
});
} catch (e) {
listEl.innerHTML = 'Failed to load tools
';
}
},
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 ? '' :
'No projects yet
';
return;
}
let html = '';
visible.forEach(proj => {
const isCollapsed = App.collapsedProjects?.[proj.id];
const isActive = App.activeProjectId === proj.id;
const dot = proj.color
? ` `
: ' ';
const arrow = isCollapsed ? '▸' : '▾';
const pinIcon = isActive ? '📍 ' : '';
const chats = (App.chats || []).filter(c => c.projectId === proj.id);
html += `
`;
if (!isCollapsed && !collapsed) {
if (chats.length === 0) {
html += '
Drop chats here
';
} else {
chats.forEach(c => { html += this._chatItemHTML(c); });
}
}
html += '
';
});
if (hasArchived && !collapsed) {
html += `
${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`}
`;
}
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 ? '' :
'No channels yet
';
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
? ` `
: ` `;
const onlineDot = isDM && online
? ' '
: '';
const unreadBadge = ch.unread > 0
? `${ch.unread > 99 ? '99+' : ch.unread} `
: '';
html += `
${icon}
${!collapsed ? `${esc(ch.name)} ${onlineDot}${unreadBadge}
` : unreadBadge}
`;
});
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
? `No chats matching "${esc(query)}"
`
: 'No chats yet
';
// 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 += `
${isOpen
? (items.length > 0
? items.map(c => this._chatItemHTML(c, true)).join('')
: '
Drop chats here
')
: ''}
`;
});
// 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 += ``;
if (unfiled.length === 0) {
html += '
Drop here to unfile
';
}
}
const renderTimeGroup = (label, items) => {
if (!items.length) return;
html += `
${label}
`;
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 += '
'; // 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'
? '👥 '
: c.type === 'workflow' ? '⚙ ' : '';
return `
${typeIcon}${esc(c.title)}
${time}
✕
`;
},
// ── 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 += `
▸ ${hiddenCount} earlier messages summarized
${messages.slice(0, summaryIdx)
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i, true))
.join('')}
`;
}
// 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 `
${formatMessage(msg.content)}
`;
},
_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 ? `
‹
${sibPos}/${sibTotal}
= sibTotal ? 'disabled' : ''} title="Next version">›
` : '';
// Action buttons
const msgId = msg.id ? esc(msg.id) : '';
const editBtn = isSelf && msgId ? `Edit ` : '';
const regenBtn = !isUser && msgId ? `Regen ` : '';
return `
${avatarHTML(isOtherUser ? 'other-user' : msg.role, senderAvatarURI)}
${esc(senderLabel)}
${branchNav}
${time ? `
${time} ` : ''}
${editBtn}
${regenBtn}
Copy
Note
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
${formatMessage(msg.content)}
${typeof renderMessageFiles === 'function' ? renderMessageFiles(msgId) : ''}
`;
},
showEmptyState() {
var base = window.__BASE__ || '';
document.getElementById('chatMessages').innerHTML = `
Chat Switchboard
Select a model and start chatting
`;
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 = `DM ` +
`with ${esc(name)} ` +
`@mention a persona to bring in AI ` +
` ` +
`👥 Members `;
} else if (ac.type === 'group') {
html = `Group ` +
`No @mention = leader responds · @mention to route · @all for everyone ` +
` ` +
`👥 Members `;
} 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 = `${esc(modeLabel)} `;
if (topic) html += `${esc(topic)} `;
html += ` `;
html += `👥 Members `;
}
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 += '
';
}
// 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 =
'Advance ▸ ' +
(current > 0 ? '◂ Reject ' : '');
}
el.innerHTML =
'' + dots + '
' +
'' + esc(stageName) + ' ' +
'' + esc(statusLabel) + ' ' +
' ' +
'Step ' + (current + 1) + ' of ' + totalStages + ' ' +
(actions ? '' + actions + '
' : '');
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, '&').replace(//g, '>');
textEl.innerHTML = `
Cancel
Submit & Regenerate
`;
// 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 = `
${avatarHTML('assistant', streamAvatar)}
`;
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 ? '' + reasoning + ' ' : '') + 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 ? '' + reasoning + ' ' : '') + 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 = `
🔧
${esc(name)}
running… `;
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 = `
🔧
${esc(name)}
running… `;
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 = 'No models loaded
';
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 ? ` ` : '';
const handleHint = m.isPersona && m.personaHandle ? `@${esc(m.personaHandle)} ` : '';
const teamBadge = m.source === 'team' && m.teamName ? `👥 ${esc(m.teamName)} ` : '';
div.innerHTML = `${avatar}${esc(m.name || m.id)}${handleHint} ${teamBadge}${m.provider ? `${esc(m.provider)} ` : ''}`;
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 = `
`;
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 = `
${avatarHTML('assistant', typingAvatar)}
`;
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 = `${esc(message)} ✕ `;
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 = 'Knowledge UI failed to load. Check browser console.
';
} 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 = 'Memory UI failed to load.
';
}
}
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);
})();