// ========================================== // Chat Switchboard – Chat Operations // ========================================== // Chat management, send, stream, regenerate, edit, branch, // summarize, per-chat model persistence. // ── Chat Input Abstraction ────────────────── // Wraps CM6 editor (when available) or fallback textarea. // All code that reads/writes the message input goes through this. const ChatInput = { _editor: null, // CM6 instance, set during init _textarea: null, // fallback textarea element getValue() { if (this._editor) return this._editor.getValue(); return this._textarea?.value || ''; }, setValue(text) { if (this._editor) { this._editor.setValue(text || ''); } else if (this._textarea) { this._textarea.value = text || ''; this._textarea.style.height = 'auto'; } }, focus() { if (this._editor) this._editor.focus(); else this._textarea?.focus(); }, /** Get the DOM element (for paste listeners, etc.) */ getDom() { if (this._editor) return this._editor.getView().contentDOM; return this._textarea; }, /** Get the visible input element (for positioning, getBoundingClientRect) */ get el() { if (this._editor) return this._editor.getView().dom; return this._textarea; }, /** Get the wrapper DOM element (for resize, etc.) */ getWrapDom() { if (this._editor) return this._editor.getView().dom; return this._textarea; }, /** Get cursor byte offset (for @mention autocomplete) */ getCursorPos() { if (this._editor) { const view = this._editor.getView(); return view.state.selection.main.head; } return this._textarea?.selectionStart || 0; }, /** Set cursor byte offset (for @mention autocomplete) */ setCursorPos(pos) { if (this._editor) { const view = this._editor.getView(); view.dispatch({ selection: { anchor: pos } }); } else if (this._textarea) { this._textarea.selectionStart = this._textarea.selectionEnd = pos; } }, /** Initialize — try CM6, fall back to textarea */ init() { this._textarea = document.getElementById('messageInput'); const wrap = document.getElementById('messageInputWrap'); if (window.CM?.chatInput && wrap) { // Hide the textarea, create CM6 editor in its place this._textarea.style.display = 'none'; this._editor = CM.chatInput(wrap, { placeholder: 'Send a message...', onSubmit: () => sendMessage(), onChange: () => { updateInputTokens(); // @mention autocomplete (v0.20.0) if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput); }, maxHeight: 200, }); // @mention autocomplete: intercept keys on CM6 contentDOM (v0.20.0) this._editor.getView().contentDOM.addEventListener('keydown', (e) => { if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) { e.preventDefault(); e.stopPropagation(); } }); DebugLog?.push?.('chat', '[CM6] Chat input initialized'); } else { // Fallback: textarea with manual event handling DebugLog?.push?.('chat', '[CM6] Not available — using textarea fallback'); this._textarea.addEventListener('keydown', (e) => { // @mention autocomplete intercepts arrow/enter/escape (v0.20.0) if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); this._textarea.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; updateInputTokens(); // @mention autocomplete (v0.20.0) if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this); }); } }, }; // ── Summarize & Continue ────────────────── async function summarizeAndContinue() { const channelId = App.currentChatId; if (!channelId) return; const btn = document.getElementById('summarizeBtn'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Summarizing…'; } try { const result = await API.summarizeChannel(channelId); UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success'); // Reload the active path to get the updated message tree const resp = await API.getActivePath(channelId); const chat = App.chats.find(c => c.id === channelId); if (chat && resp) { chat.messages = (resp.path || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, tool_calls: m.tool_calls || null, metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); UI.renderMessages(chat.messages); updateContextWarning(); updateChatTokenCount(); updateInputTokens(); } } catch (err) { console.error('Summarize failed:', err); UI.toast(err.message || 'Summarization failed', 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = '📝 Summarize & Continue'; } } } // ── Per-Channel Auto-Compaction Toggle ────── async function toggleChannelAutoCompact(enabled) { const chatId = App.currentChatId; if (!chatId) return; const chat = App.chats.find(c => c.id === chatId); if (!chat) return; // Update local state if (!chat.settings) chat.settings = {}; chat.settings.auto_compaction = enabled; // Persist to server (JSONB merge) try { await API.updateChannel(chatId, { settings: { auto_compaction: enabled } }); } catch (e) { console.warn('Failed to save auto-compaction setting:', e.message); } } // ── Chat Management ────────────────────────── async function loadChats() { try { const resp = await API.listChannels(1, 100, 'direct'); App.chats = (resp.data || []).map(c => ({ id: c.id, title: c.title, type: c.type || 'direct', model: c.model || '', messageCount: c.message_count || 0, projectId: c.project_id || null, messages: [], updatedAt: c.updated_at, settings: c.settings || {}, })); } catch (e) { console.error('Failed to load chats:', e.message); UI.toast('Failed to load chats', 'error'); } } async function selectChat(chatId) { clearStaged(); // Discard any staged attachments from previous chat App.currentChatId = chatId; try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {} UI.renderChatList(); if (window.innerWidth <= 768) { document.getElementById('sidebar').classList.add('collapsed'); const ov = document.getElementById('sidebarOverlay'); if (ov) ov.style.display = 'none'; } const chat = App.chats.find(c => c.id === chatId); if (!chat) return; if (chat.messages.length === 0 && chat.messageCount > 0) { try { const resp = await API.getActivePath(chatId); chat.messages = (resp.path || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, tool_calls: m.tool_calls || null, metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); } catch (e) { console.error('Failed to load messages:', e.message); UI.toast('Failed to load messages', 'error'); } } // Restore the last-used model/preset for this chat _restoreChatModel(chatId, chat); // Load multi-model roster for @mention routing (v0.20.0) if (typeof ChannelModels !== 'undefined') ChannelModels.load(chatId); // Load attachments for message rendering (non-blocking, best-effort) await loadChannelAttachments(chatId); UI.renderMessages(chat.messages); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); Tokens._warningDismissed = false; updateContextWarning(); updateChatTokenCount(); updateInputTokens(); // Refresh KB toggle state for the new channel if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged(); updateChatTokenCount(); } // ── Chat Header Token Count ────────────────── function updateChatTokenCount() { const el = document.getElementById('chatTokenCount'); if (!el) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat || !chat.messages.length) { el.textContent = ''; return; } const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt); const budget = Tokens.getContextBudget(); if (budget.maxContext > 0) { const pct = tokens / budget.maxContext; el.textContent = `${Tokens.format(tokens)} / ${Tokens.format(budget.maxContext)}`; el.className = 'chat-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : ''); } else { el.textContent = `~${Tokens.format(tokens)}`; el.className = 'chat-token-count'; } } // ── Per-Chat Model/Preset Persistence ──────── // Server-side: saved in channel.settings.last_selector_id (JSONB merge). // localStorage used as write-through cache for instant restore without // waiting for the channel list API call. function _saveChatModel(chatId) { if (!chatId) return; const selectorId = UI.getModelValue(); if (!selectorId) return; // Write-through: localStorage for instant restore on next visit try { const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); map[chatId] = selectorId; localStorage.setItem('cs-chat-models', JSON.stringify(map)); } catch (e) { /* quota exceeded, etc */ } // Update in-memory chat object const chat = App.chats.find(c => c.id === chatId); if (chat) { if (!chat.settings) chat.settings = {}; chat.settings.last_selector_id = selectorId; } // Persist to server (fire-and-forget — non-critical) API.updateChannel(chatId, { settings: { last_selector_id: selectorId } }) .catch(e => console.debug('Failed to persist model selection:', e.message)); } function _restoreChatModel(chatId, chat) { // Priority: server settings → localStorage fallback → channel base model → keep current let targetId = null; // 1. Server-side settings (most authoritative, roams across devices) if (chat?.settings?.last_selector_id) { targetId = chat.settings.last_selector_id; } // 2. localStorage fallback (covers race where settings haven't loaded yet) if (!targetId) { try { const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); if (map[chatId]) targetId = map[chatId]; } catch (e) { /* */ } } // 3. Verify the stored ID still exists in available models if (targetId) { const found = App.models.find(m => m.id === targetId); if (found) { UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : '')); return; } // Stored model removed — clean up stale entries _cleanStaleChatModel(chatId); } // 4. Fall back to channel's base model ID (match by baseModelId) if (chat?.model) { const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden); if (match) { UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : '')); return; } } // 5. Stored model gone — fall back to admin default → first visible const visible = App.models.filter(m => !m.hidden); const def = App.defaultModel ? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel) : null; const fallback = def || visible[0]; if (fallback) { UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : '')); } } function _cleanStaleChatModel(chatId) { // Remove from localStorage try { const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); delete map[chatId]; localStorage.setItem('cs-chat-models', JSON.stringify(map)); } catch (e) { /* */ } // Clear from in-memory settings (server cleanup is fire-and-forget) const chat = App.chats.find(c => c.id === chatId); if (chat?.settings?.last_selector_id) { delete chat.settings.last_selector_id; } } async function newChat() { clearStaged(); // Discard any staged attachments App.currentChatId = null; UI.renderChatList(); UI.showEmptyState(); UI.showRegenerate(false); Tokens._warningDismissed = false; updateContextWarning(); updateChatTokenCount(); updateInputTokens(); ChatInput.focus(); if (window.innerWidth <= 768) { document.getElementById('sidebar').classList.add('collapsed'); const ov = document.getElementById('sidebarOverlay'); if (ov) ov.style.display = 'none'; } if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged(); } async function deleteChat(chatId) { if (!await showConfirm('Delete this chat?')) return; try { await API.deleteChannel(chatId); App.chats = App.chats.filter(c => c.id !== chatId); // Clean up stored model preference try { const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); delete map[chatId]; localStorage.setItem('cs-chat-models', JSON.stringify(map)); } catch (e) { /* */ } if (App.currentChatId === chatId) { clearPreview(); newChat(); } UI.renderChatList(); } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); } } // ── Auto-Name Chat (utility model) ─────────── let _autoNamePending = new Set(); // debounce: one inflight per chat async function autoNameChat(chatId, chat) { if (!chatId || !chat) return; if (_autoNamePending.has(chatId)) return; // Only auto-name if title looks auto-generated (first 50 chars of user msg or "New Chat") const firstUserMsg = chat.messages.find(m => m.role === 'user'); if (!firstUserMsg) return; const truncated = firstUserMsg.content.slice(0, 50).trim(); const currentTitle = (chat.title || '').trim(); const isAutoGenerated = !currentTitle || currentTitle === 'New Chat' || currentTitle === truncated || truncated.startsWith(currentTitle); if (!isAutoGenerated) return; _autoNamePending.add(chatId); try { const resp = await API.generateTitle(chatId); if (resp?.title && resp.title !== chat.title) { chat.title = resp.title; UI.renderChatList(); } } catch (e) { // Silently fail — keep truncated title console.debug('Auto-name failed:', e.message); } finally { _autoNamePending.delete(chatId); } } // ── Chat Rename (inline edit) ──────────────── function startRenameChat(chatId) { const chat = App.chats.find(c => c.id === chatId); if (!chat) return; // Find the title span for this chat const items = document.querySelectorAll('.chat-item'); let titleSpan = null; for (const item of items) { if (item.getAttribute('onclick')?.includes(chatId)) { titleSpan = item.querySelector('.chat-item-title'); break; } } if (!titleSpan) return; // Replace span with input const input = document.createElement('input'); input.type = 'text'; input.className = 'chat-rename-input'; input.value = chat.title || ''; input.onclick = (e) => e.stopPropagation(); input.ondblclick = (e) => e.stopPropagation(); const commit = async () => { const newTitle = input.value.trim(); if (newTitle && newTitle !== chat.title) { try { await API.updateChannel(chatId, { title: newTitle }); chat.title = newTitle; } catch (e) { UI.toast('Rename failed: ' + e.message, 'error'); } } UI.renderChatList(); }; input.addEventListener('blur', commit); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Escape') { input.value = chat.title || ''; input.blur(); } }); titleSpan.replaceWith(input); input.focus(); input.select(); } // ── Send Message ───────────────────────────── async function sendMessage() { const text = ChatInput.getValue().trim(); const hasAttachments = hasStagedAttachments(); // Need text or attachments, not generating, not blocked by uploads if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return; ChatInput.setValue(''); // Snapshot attachment IDs before clearing staged state const attachmentIds = getStagedAttachmentIds(); const selectedId = UI.getModelValue(); const modelInfo = App.findModel(selectedId); // For presets, send the base model ID; for regular models, send the model ID const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; if (!App.currentChatId) { try { const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct'); const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, projectId: resp.project_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} }; // Auto-assign to active project (v0.19.1) if (App.activeProjectId && !chat.projectId) { try { await API.addChannelToProject(App.activeProjectId, chat.id, 0); chat.projectId = App.activeProjectId; } catch (_) { /* best effort — chat still works without project */ } } App.chats.unshift(chat); App.currentChatId = chat.id; UI.renderChatList(); } catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; } } const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; // Optimistic: show user message immediately const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : ''); chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 }); UI.renderMessages(chat.messages); // Clear staged attachments (they're now part of this message) if (hasAttachments) consumeStaged(); App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds, typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []); await UI.streamResponse(resp, chat.messages, modelInfo?.name); // Reload active path from server to get proper IDs and sibling metadata await reloadActivePath(); UI.showRegenerate(true); // Persist the model/preset selection for this chat _saveChatModel(App.currentChatId); // Auto-name: title the chat after first assistant response (v0.17.0) if (chat.messages.length <= 3) { autoNameChat(App.currentChatId, chat); } } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); await reloadActivePath(); } else { console.error('Completion error:', e); const msg = e.message || ''; if (e.proxyBlocked) { UI.toast('Network proxy blocked this request — contact your network admin', 'error'); } else if (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings → Providers', 'error'); } else if (msg.includes('provider error') && msg.includes('429')) { UI.toast('Provider rate limit — wait and retry', 'warning'); } else if (msg.includes('no API config') || msg.includes('no model')) { UI.toast('No provider configured — add one in Settings', 'error'); } else { UI.toast(msg, 'error'); } } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } function stopGeneration() { if (App.abortController) App.abortController.abort(); } // ── Reload Active Path from Server ────────── async function reloadActivePath() { if (!App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; try { const resp = await API.getActivePath(App.currentChatId); chat.messages = (resp.path || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, tool_calls: m.tool_calls || null, metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); chat.messageCount = chat.messages.length; await loadChannelAttachments(App.currentChatId); UI.renderMessages(chat.messages); updateContextWarning(); updateChatTokenCount(); } catch (e) { console.error('Failed to reload path:', e.message); } } // ── Regenerate (tree-aware: creates sibling) ─ async function regenerateMessage(messageId) { if (App.isGenerating || !App.currentChatId) return; const selectedId = UI.getModelValue(); const modelInfo = App.findModel(selectedId); const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; // Truncate display to the parent of the message being regenerated. // This makes the stream appear in the correct position — as a fresh // response to the parent user message, not appended at the bottom. const chat = App.chats.find(c => c.id === App.currentChatId); if (chat) { const msgIdx = chat.messages.findIndex(m => m.id === messageId); if (msgIdx > 0) { const truncated = chat.messages.slice(0, msgIdx); UI.renderMessages(truncated); } } App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { const resp = await API.streamRegenerate( App.currentChatId, messageId, App.abortController.signal, model, presetId, modelInfo?.configId, typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : [] ); await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name); // Reload from server to get proper tree state await reloadActivePath(); UI.showRegenerate(true); } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); await reloadActivePath(); } else { const msg = e.message || ''; if (e.proxyBlocked) { UI.toast('Network proxy blocked this request', 'error'); } else if (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings', 'error'); } else { UI.toast(msg, 'error'); } } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } // Legacy: bottom-bar regenerate button targets the last assistant message async function regenerate() { if (App.isGenerating || !App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat || chat.messages.length === 0) return; // Find the last assistant message with an ID for (let i = chat.messages.length - 1; i >= 0; i--) { if (chat.messages[i].role === 'assistant' && chat.messages[i].id) { return regenerateMessage(chat.messages[i].id); } } UI.toast('No assistant message to regenerate', 'warning'); } // ── Edit Message (creates sibling, triggers completion) ── async function editMessage(messageId) { if (App.isGenerating || !App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat) return; const msg = chat.messages.find(m => m.id === messageId); if (!msg || msg.role !== 'user') return; // Show inline edit UI UI.showEditInline(messageId, msg.content); } async function submitEdit(messageId, newContent) { if (App.isGenerating || !App.currentChatId) return; if (!newContent.trim()) return; const selectedId = UI.getModelValue(); const modelInfo = App.findModel(selectedId); const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { const chat = App.chats.find(c => c.id === App.currentChatId); // Step 1: Create sibling user message via edit endpoint const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim()); // Step 2: Reload path to show the new edited message await reloadActivePath(); // Step 3: Generate assistant response as child of the new edit. // Uses regenerate endpoint (which handles user messages by creating // a child response, not a sibling). This avoids the duplicate user // message that streamCompletion would create. const resp = await API.streamRegenerate( App.currentChatId, editResp.id, App.abortController.signal, model, presetId, modelInfo?.configId, typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : [] ); await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name); // Step 4: Final reload to get the complete tree state await reloadActivePath(); UI.showRegenerate(true); } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); await reloadActivePath(); } else { console.error('Edit error:', e); UI.toast(e.message || 'Edit failed', 'error'); await reloadActivePath(); } } finally { App.isGenerating = false; App.abortController = null; UI.setGenerating(false); } } function cancelEdit() { // Re-render to remove the edit form const chat = App.chats.find(c => c.id === App.currentChatId); if (chat) UI.renderMessages(chat.messages); } // ── Switch Branch (sibling navigation) ────── async function switchSibling(messageId, direction) { if (App.isGenerating || !App.currentChatId) return; try { // Get siblings for this message const data = await API.listSiblings(App.currentChatId, messageId); const siblings = data.siblings || []; const currentIdx = data.current_index ?? 0; const targetIdx = currentIdx + direction; if (targetIdx < 0 || targetIdx >= siblings.length) return; const targetSibling = siblings[targetIdx]; // Update cursor to the target sibling (backend walks to leaf) const resp = await API.updateCursor(App.currentChatId, targetSibling.id); // Update local state with the new path const chat = App.chats.find(c => c.id === App.currentChatId); if (chat && resp.path) { chat.messages = resp.path.map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, content: m.content, model: m.model || '', modelName: '', timestamp: m.created_at, tool_calls: m.tool_calls || null, metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); chat.messageCount = chat.messages.length; UI.renderMessages(chat.messages); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); } } catch (e) { console.error('Branch switch failed:', e.message); UI.toast('Failed to switch branch', 'error'); } } // ── Chat Listeners (extracted from initListeners) ── function _initChatListeners() { // Sidebar document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar); document.getElementById('newChatBtn').addEventListener('click', newChat); // Split button dropdown document.getElementById('newChatDropBtn').addEventListener('click', (e) => { e.stopPropagation(); document.getElementById('newChatDropdown').classList.toggle('open'); }); document.addEventListener('click', (e) => { if (!e.target.closest('.split-btn')) { document.getElementById('newChatDropdown').classList.remove('open'); } }); // User flyout document.getElementById('userMenuBtn').addEventListener('click', (e) => { e.stopPropagation(); UI.toggleUserMenu(); }); document.addEventListener('click', (e) => { if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu(); }); // Flyout items document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); }); document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); }); document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); }); document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin()); document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); }); document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); }); // Sidebar chat search const _chatSearchInput = document.getElementById('chatSearchInput'); _chatSearchInput?.addEventListener('input', () => UI.renderChatList()); document.getElementById('chatSearchClear')?.addEventListener('click', () => { if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); } }); // Chat actions document.getElementById('sendBtn').addEventListener('click', sendMessage); document.getElementById('stopBtn').addEventListener('click', stopGeneration); // Model selector (custom dropdown) UI.initModelDropdown(); UI.initAppearance(); // Mobile hamburger document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar); document.getElementById('sidebarOverlay')?.addEventListener('click', () => { document.getElementById('sidebar').classList.add('collapsed'); document.getElementById('sidebarOverlay').style.display = 'none'; localStorage.setItem('sb_sidebar', '1'); }); document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => { await fetchModels(); const visible = App.models.filter(m => !m.hidden).length; UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success'); }); // Input — CM6 or textarea fallback ChatInput.init(); // Multi-model channel roster (v0.20.0) if (typeof ChannelModels !== 'undefined') ChannelModels.init(); // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(overlay.id); }); }); }