// === CC-Web Frontend === (function () { 'use strict'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const SLASH_COMMANDS = [ { cmd: '/clear', desc: '清除当前会话' }, { cmd: '/model', desc: '查看/切换模型' }, { cmd: '/mode', desc: '查看/切换权限模式' }, { cmd: '/cost', desc: '查看会话费用' }, { cmd: '/compact', desc: '压缩上下文' }, { cmd: '/help', desc: '显示帮助' }, ]; const MODE_LABELS = { default: '默认', plan: 'Plan', yolo: 'YOLO', }; const MODEL_OPTIONS = [ { value: 'opus', label: 'Opus', desc: '最强大,适合复杂任务' }, { value: 'sonnet', label: 'Sonnet', desc: '平衡性能与速度' }, { value: 'haiku', label: 'Haiku', desc: '最快速,适合简单任务' }, ]; const MODE_PICKER_OPTIONS = [ { value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' }, { value: 'plan', label: 'Plan', desc: '执行前需确认计划' }, { value: 'default', label: '默认', desc: '标准权限审批' }, ]; // --- State --- let ws = null; let authToken = localStorage.getItem('cc-web-token'); let currentSessionId = null; let sessions = []; let isGenerating = false; let reconnectAttempts = 0; let reconnectTimer = null; let pendingText = ''; let renderTimer = null; let activeToolCalls = new Map(); let cmdMenuIndex = -1; let currentMode = localStorage.getItem('cc-web-mode') || 'yolo'; let currentModel = 'opus'; let loginPasswordValue = ''; // store login password for force-change flow let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; // --- DOM --- const $ = (sel) => document.querySelector(sel); const loginOverlay = $('#login-overlay'); const loginForm = $('#login-form'); const loginPassword = $('#login-password'); const loginError = $('#login-error'); const rememberPw = $('#remember-pw'); const app = $('#app'); const sidebar = $('#sidebar'); const sidebarOverlay = $('#sidebar-overlay'); const menuBtn = $('#menu-btn'); const newChatBtn = $('#new-chat-btn'); const sessionList = $('#session-list'); const chatTitle = $('#chat-title'); const costDisplay = $('#cost-display'); const messagesDiv = $('#messages'); const msgInput = $('#msg-input'); const sendBtn = $('#send-btn'); const abortBtn = $('#abort-btn'); const cmdMenu = $('#cmd-menu'); const modeSelect = $('#mode-select'); // --- Viewport height fix for mobile browsers --- function setVH() { document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); } setVH(); window.addEventListener('resize', setVH); window.addEventListener('orientationchange', () => setTimeout(setVH, 100)); // --- marked config --- const renderer = new marked.Renderer(); renderer.code = function (code, language) { const lang = language || 'plaintext'; let highlighted; try { if (hljs.getLanguage(lang)) { highlighted = hljs.highlight(code, { language: lang }).value; } else { highlighted = hljs.highlightAuto(code).value; } } catch { highlighted = escapeHtml(code); } return `
${escapeHtml(lang)}
${highlighted}
`; }; marked.setOptions({ renderer, breaks: true, gfm: true }); window.ccCopyCode = function (btn) { const code = btn.closest('.code-block-wrapper').querySelector('code').textContent; navigator.clipboard.writeText(code).then(() => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); }); }; // --- WebSocket --- function connect() { if (ws && ws.readyState <= 1) return; ws = new WebSocket(WS_URL); ws.onopen = () => { reconnectAttempts = 0; if (authToken) send({ type: 'auth', token: authToken }); }; ws.onmessage = (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } handleServerMessage(msg); }; ws.onclose = () => scheduleReconnect(); ws.onerror = () => {}; } function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); } function scheduleReconnect() { if (reconnectTimer) return; const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); reconnectAttempts++; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } // --- Server Message Handler --- function handleServerMessage(msg) { switch (msg.type) { case 'auth_result': if (msg.success) { authToken = msg.token; localStorage.setItem('cc-web-token', msg.token); loginOverlay.hidden = true; app.hidden = false; // Check if must change password if (msg.mustChangePassword) { showForceChangePassword(); } else { // Auto-load last viewed session const lastSession = localStorage.getItem('cc-web-session'); if (lastSession) { send({ type: 'load_session', sessionId: lastSession }); } } } else { authToken = null; localStorage.removeItem('cc-web-token'); loginOverlay.hidden = false; app.hidden = true; loginError.hidden = false; } break; case 'session_list': sessions = msg.sessions || []; renderSessionList(); break; case 'session_info': // Reset generating state (will be re-set by resume_generating if process is active) if (isGenerating) { isGenerating = false; sendBtn.hidden = false; abortBtn.hidden = true; pendingText = ''; activeToolCalls.clear(); } currentSessionId = msg.sessionId; localStorage.setItem('cc-web-session', currentSessionId); chatTitle.textContent = msg.title || '新会话'; // 同步 session 的 mode(如有) if (msg.mode && MODE_LABELS[msg.mode]) { currentMode = msg.mode; modeSelect.value = currentMode; localStorage.setItem('cc-web-mode', currentMode); } // 同步 session 的 model(如有) if (msg.model) { currentModel = msg.model; } renderMessages(msg.messages || []); highlightActiveSession(); closeSidebar(); // Show notification for sessions completed in background if (msg.hasUnread) { showToast('后台任务已完成', msg.sessionId); } break; case 'session_renamed': if (msg.sessionId === currentSessionId) { chatTitle.textContent = msg.title; } break; case 'text_delta': if (!isGenerating) startGenerating(); pendingText += msg.text; scheduleRender(); break; case 'tool_start': if (!isGenerating) startGenerating(); activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, done: false }); appendToolCall(msg.toolUseId, msg.name, msg.input, false); break; case 'tool_end': if (activeToolCalls.has(msg.toolUseId)) { activeToolCalls.get(msg.toolUseId).done = true; } updateToolCall(msg.toolUseId, msg.result); break; case 'cost': costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`; break; case 'done': finishGenerating(msg.sessionId); break; case 'system_message': appendSystemMessage(msg.message); break; case 'mode_changed': if (msg.mode && MODE_LABELS[msg.mode]) { currentMode = msg.mode; modeSelect.value = currentMode; localStorage.setItem('cc-web-mode', currentMode); } break; case 'model_changed': if (msg.model) { currentModel = msg.model; } break; case 'resume_generating': // Server has an active process for this session — resume streaming startGenerating(); pendingText = msg.text || ''; if (pendingText) flushRender(); if (msg.toolCalls && msg.toolCalls.length > 0) { for (const tc of msg.toolCalls) { activeToolCalls.set(tc.id, { name: tc.name, done: tc.done }); appendToolCall(tc.id, tc.name, tc.input, tc.done); if (tc.done && tc.result) { updateToolCall(tc.id, tc.result); } } } break; case 'error': appendError(msg.message); if (isGenerating) finishGenerating(); break; case 'notify_config': if (typeof _onNotifyConfig === 'function') _onNotifyConfig(msg.config); break; case 'notify_test_result': if (typeof _onNotifyTestResult === 'function') _onNotifyTestResult(msg); break; case 'model_config': if (typeof _onModelConfig === 'function') _onModelConfig(msg.config); break; case 'background_done': // A background task completed (browser was disconnected or viewing another session) showToast(`「${msg.title}」任务完成`, msg.sessionId); showBrowserNotification(msg.title); if (msg.sessionId === currentSessionId) { // Reload current session to show completed response send({ type: 'load_session', sessionId: msg.sessionId }); } else { send({ type: 'list_sessions' }); } break; case 'password_changed': handlePasswordChanged(msg); break; } } // --- Generating State --- function startGenerating() { isGenerating = true; pendingText = ''; activeToolCalls.clear(); sendBtn.hidden = true; abortBtn.hidden = false; // 不禁用输入框,允许用户继续输入(但无法发送) const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const msgEl = createMsgElement('assistant', ''); msgEl.id = 'streaming-msg'; // 流式消息 bubble 拆为 .msg-text 和 .msg-tools 两个子容器 const bubble = msgEl.querySelector('.msg-bubble'); bubble.innerHTML = ''; const textDiv = document.createElement('div'); textDiv.className = 'msg-text'; textDiv.innerHTML = '
'; const toolsDiv = document.createElement('div'); toolsDiv.className = 'msg-tools'; bubble.appendChild(textDiv); bubble.appendChild(toolsDiv); messagesDiv.appendChild(msgEl); scrollToBottom(); } function finishGenerating(sessionId) { isGenerating = false; sendBtn.hidden = false; abortBtn.hidden = true; msgInput.focus(); if (pendingText) flushRender(); const typing = document.querySelector('.typing-indicator'); if (typing) typing.remove(); const streamEl = document.getElementById('streaming-msg'); if (streamEl) streamEl.removeAttribute('id'); if (sessionId) currentSessionId = sessionId; pendingText = ''; activeToolCalls.clear(); } // --- Rendering --- function scheduleRender() { if (renderTimer) return; renderTimer = setTimeout(() => { renderTimer = null; flushRender(); }, RENDER_DEBOUNCE); } function flushRender() { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; let textDiv = bubble.querySelector('.msg-text'); if (!textDiv) { textDiv = bubble; } textDiv.innerHTML = renderMarkdown(pendingText); scrollToBottom(); } function renderMarkdown(text) { if (!text) return '
'; try { return marked.parse(text); } catch { return escapeHtml(text); } } function createMsgElement(role, content) { const div = document.createElement('div'); div.className = `msg ${role}`; if (role === 'system') { const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; bubble.textContent = content; div.appendChild(bubble); return div; } const avatar = document.createElement('div'); avatar.className = 'msg-avatar'; avatar.textContent = role === 'user' ? 'U' : 'C'; const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; if (role === 'user') { bubble.style.whiteSpace = 'pre-wrap'; bubble.textContent = content; } else { bubble.innerHTML = content ? renderMarkdown(content) : '
'; } div.appendChild(avatar); div.appendChild(bubble); return div; } let renderEpoch = 0; function buildMsgElement(m) { const el = createMsgElement(m.role, m.content); if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); for (const tc of m.toolCalls) { const details = document.createElement('details'); details.className = 'tool-call'; details.dataset.toolName = tc.name || ''; if (tc.name === 'AskUserQuestion') details.open = true; const summary = document.createElement('summary'); summary.innerHTML = ` ${escapeHtml(tc.name)}`; details.appendChild(summary); const displayInput = tc.name === 'AskUserQuestion' ? tc.input : (tc.result || tc.input); details.appendChild(buildToolContentElement(tc.name, displayInput)); bubble.appendChild(details); } } return el; } function renderMessages(messages) { renderEpoch++; const epoch = renderEpoch; messagesDiv.innerHTML = ''; if (messages.length === 0) { messagesDiv.innerHTML = '

欢迎使用 CC-Web

开始与 Claude Code 对话

'; return; } // Batch render: last 10 first, then next 20, then the rest const batches = []; const len = messages.length; if (len <= 10) { batches.push([0, len]); } else if (len <= 30) { batches.push([len - 10, len]); batches.push([0, len - 10]); } else { batches.push([len - 10, len]); batches.push([len - 30, len - 10]); batches.push([0, len - 30]); } // Render first batch immediately const frag0 = document.createDocumentFragment(); for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i])); messagesDiv.appendChild(frag0); scrollToBottom(); // Render remaining batches asynchronously, prepending each let delay = 0; for (let b = 1; b < batches.length; b++) { const [start, end] = batches[b]; delay += 16; setTimeout(() => { if (renderEpoch !== epoch) return; // session switched, abort stale render const frag = document.createDocumentFragment(); for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i])); messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateScrollbar(); }, delay); } } function normalizeAskUserInput(input) { if (input === null || input === undefined) return null; if (typeof input === 'string') { const trimmed = input.trim(); if (!trimmed) return null; try { return JSON.parse(trimmed); } catch { return null; } } return input; } function extractAskUserQuestions(input) { const parsed = normalizeAskUserInput(input); if (!parsed || !Array.isArray(parsed.questions)) return []; return parsed.questions; } function appendAskOptionToInput(question, option) { const header = (question?.header || '').trim() || '问题'; const line = `【${header}】${option?.label || ''}`; const current = msgInput.value.trim(); msgInput.value = current ? `${current}\n${line}` : line; autoResize(); msgInput.focus(); } function createAskUserQuestionView(questions) { const wrapper = document.createElement('div'); wrapper.className = 'ask-user-question'; questions.forEach((q, idx) => { const card = document.createElement('div'); card.className = 'ask-question-card'; const header = document.createElement('div'); header.className = 'ask-question-header'; header.textContent = `${idx + 1}. ${q.header || '问题'}`; card.appendChild(header); const body = document.createElement('div'); body.className = 'ask-question-text'; body.textContent = q.question || ''; card.appendChild(body); if (Array.isArray(q.options) && q.options.length > 0) { const opts = document.createElement('div'); opts.className = 'ask-question-options'; q.options.forEach((opt, i) => { const item = document.createElement('button'); item.type = 'button'; item.className = 'ask-option-item'; item.addEventListener('click', () => appendAskOptionToInput(q, opt)); const title = document.createElement('div'); title.className = 'ask-option-label'; title.textContent = `${i + 1}. ${opt.label || ''}`; item.appendChild(title); if (opt.description) { const desc = document.createElement('div'); desc.className = 'ask-option-desc'; desc.textContent = opt.description; item.appendChild(desc); } opts.appendChild(item); }); card.appendChild(opts); } wrapper.appendChild(card); }); return wrapper; } function buildToolContentElement(name, input) { if (name === 'AskUserQuestion') { const questions = extractAskUserQuestions(input); if (questions.length > 0) { return createAskUserQuestionView(questions); } } const inputStr = typeof input === 'string' ? input : (input ? JSON.stringify(input, null, 2) : ''); const content = document.createElement('div'); content.className = 'tool-call-content'; content.textContent = inputStr; return content; } function appendToolCall(toolUseId, name, input, done) { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; let toolsDiv = bubble.querySelector('.msg-tools'); if (!toolsDiv) { toolsDiv = bubble; } const details = document.createElement('details'); details.className = 'tool-call'; details.id = `tool-${toolUseId}`; details.dataset.toolName = name || ''; if (name === 'AskUserQuestion') details.open = true; const summary = document.createElement('summary'); summary.innerHTML = ` ${escapeHtml(name)}`; details.appendChild(summary); details.appendChild(buildToolContentElement(name, input)); toolsDiv.appendChild(details); scrollToBottom(); } function updateToolCall(toolUseId, result) { const el = document.getElementById(`tool-${toolUseId}`); if (!el) return; const icon = el.querySelector('.tool-call-icon'); if (icon) { icon.classList.remove('running'); icon.classList.add('done'); } if (result) { if (el.dataset.toolName === 'AskUserQuestion') { return; } const content = el.querySelector('.tool-call-content'); if (content) content.textContent = result; } } function showDeleteConfirm(onConfirm) { const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; overlay.style.zIndex = '10002'; const box = document.createElement('div'); box.className = 'settings-panel'; box.innerHTML = `
删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?
`; overlay.appendChild(box); document.body.appendChild(overlay); const close = () => document.body.removeChild(overlay); box.querySelector('#del-confirm-ok').addEventListener('click', () => { close(); onConfirm(); }); box.querySelector('#del-confirm-skip').addEventListener('click', () => { skipDeleteConfirm = true; localStorage.setItem('cc-web-skip-delete-confirm', '1'); close(); onConfirm(); }); box.querySelector('#del-confirm-cancel').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); } function appendSystemMessage(message) { const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); messagesDiv.appendChild(createMsgElement('system', message)); scrollToBottom(); } function appendError(message) { const div = document.createElement('div'); div.className = 'msg system'; div.innerHTML = `
⚠ ${escapeHtml(message)}
`; messagesDiv.appendChild(div); scrollToBottom(); } function scrollToBottom() { requestAnimationFrame(() => { messagesDiv.scrollTop = messagesDiv.scrollHeight; updateScrollbar(); }); } // --- Custom Scrollbar --- const scrollbarEl = document.getElementById('custom-scrollbar'); const thumbEl = document.getElementById('custom-scrollbar-thumb'); function updateScrollbar() { if (!scrollbarEl || !thumbEl) return; const { scrollTop, scrollHeight, clientHeight } = messagesDiv; if (scrollHeight <= clientHeight) { thumbEl.style.display = 'none'; return; } thumbEl.style.display = ''; const trackH = scrollbarEl.clientHeight; const thumbH = Math.max(30, trackH * clientHeight / scrollHeight); const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackH - thumbH); thumbEl.style.height = thumbH + 'px'; thumbEl.style.top = thumbTop + 'px'; } messagesDiv.addEventListener('scroll', () => updateScrollbar(), { passive: true }); new ResizeObserver(updateScrollbar).observe(messagesDiv); // Drag logic let dragStartY = 0, dragStartScrollTop = 0, isDragging = false; function onDragStart(e) { isDragging = true; dragStartY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY; dragStartScrollTop = messagesDiv.scrollTop; thumbEl.classList.add('dragging'); scrollbarEl.classList.add('active'); e.preventDefault(); } function onDragMove(e) { if (!isDragging) return; const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY; const dy = clientY - dragStartY; const { scrollHeight, clientHeight } = messagesDiv; const trackH = scrollbarEl.clientHeight; const thumbH = Math.max(30, trackH * clientHeight / scrollHeight); const ratio = (scrollHeight - clientHeight) / (trackH - thumbH); messagesDiv.scrollTop = dragStartScrollTop + dy * ratio; e.preventDefault(); } function onDragEnd() { if (!isDragging) return; isDragging = false; thumbEl.classList.remove('dragging'); scrollbarEl.classList.remove('active'); } thumbEl.addEventListener('mousedown', onDragStart); thumbEl.addEventListener('touchstart', onDragStart, { passive: false }); document.addEventListener('mousemove', onDragMove); document.addEventListener('touchmove', onDragMove, { passive: false }); document.addEventListener('mouseup', onDragEnd); document.addEventListener('touchend', onDragEnd); updateScrollbar(); function renderSessionList() { sessionList.innerHTML = ''; for (const s of sessions) { const item = document.createElement('div'); item.className = `session-item${s.id === currentSessionId ? ' active' : ''}`; item.dataset.id = s.id; item.innerHTML = ` ${escapeHtml(s.title || 'Untitled')} ${s.hasUnread ? '' : ''} ${timeAgo(s.updated)}
`; item.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('delete')) { e.stopPropagation(); const doDelete = () => { send({ type: 'delete_session', sessionId: s.id }); if (s.id === currentSessionId) { currentSessionId = null; messagesDiv.innerHTML = '

欢迎使用 CC-Web

开始与 Claude Code 对话

'; chatTitle.textContent = '新会话'; costDisplay.textContent = ''; } }; if (skipDeleteConfirm) { doDelete(); } else { showDeleteConfirm(doDelete); } return; } if (target.classList.contains('edit')) { e.stopPropagation(); startEditSessionTitle(item, s); return; } send({ type: 'load_session', sessionId: s.id }); }); sessionList.appendChild(item); } } function startEditSessionTitle(itemEl, session) { const titleEl = itemEl.querySelector('.session-item-title'); const currentTitle = session.title || ''; const input = document.createElement('input'); input.className = 'session-item-edit-input'; input.value = currentTitle; input.maxLength = 100; titleEl.replaceWith(input); input.focus(); input.select(); // Hide actions during edit const actions = itemEl.querySelector('.session-item-actions'); const time = itemEl.querySelector('.session-item-time'); if (actions) actions.style.display = 'none'; if (time) time.style.display = 'none'; function save() { const newTitle = input.value.trim() || currentTitle; if (newTitle !== currentTitle) { send({ type: 'rename_session', sessionId: session.id, title: newTitle }); } // Restore const span = document.createElement('span'); span.className = 'session-item-title'; span.textContent = newTitle; input.replaceWith(span); if (actions) actions.style.display = ''; if (time) time.style.display = ''; } input.addEventListener('blur', save); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Escape') { input.value = currentTitle; input.blur(); } }); } function highlightActiveSession() { document.querySelectorAll('.session-item').forEach((el) => { el.classList.toggle('active', el.dataset.id === currentSessionId); }); } // --- Header title editing (contenteditable) --- chatTitle.addEventListener('click', () => { if (!currentSessionId || chatTitle.contentEditable === 'true') return; const originalText = chatTitle.textContent; chatTitle.contentEditable = 'true'; chatTitle.style.background = '#fff'; chatTitle.style.outline = '1px solid var(--accent)'; chatTitle.style.borderRadius = '6px'; chatTitle.style.padding = '2px 8px'; chatTitle.focus(); // Select all text const range = document.createRange(); range.selectNodeContents(chatTitle); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); function finish(save) { chatTitle.contentEditable = 'false'; chatTitle.style.background = ''; chatTitle.style.outline = ''; chatTitle.style.borderRadius = ''; chatTitle.style.padding = ''; const newTitle = chatTitle.textContent.trim() || originalText; chatTitle.textContent = newTitle; if (save && newTitle !== originalText && currentSessionId) { send({ type: 'rename_session', sessionId: currentSessionId, title: newTitle }); } } chatTitle.addEventListener('blur', () => finish(true), { once: true }); chatTitle.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { e.preventDefault(); chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); } if (e.key === 'Escape') { chatTitle.textContent = originalText; chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); } }); }); // --- Sidebar --- function openSidebar() { sidebar.classList.add('open'); sidebarOverlay.hidden = false; } function closeSidebar() { sidebar.classList.remove('open'); sidebarOverlay.hidden = true; } // --- Slash Command Menu --- function showCmdMenu(filter) { const filtered = SLASH_COMMANDS.filter(c => c.cmd.startsWith(filter) || c.desc.includes(filter.slice(1)) ); // Exact match first (fixes /mode vs /model ambiguity) filtered.sort((a, b) => (b.cmd === filter ? 1 : 0) - (a.cmd === filter ? 1 : 0)); if (filtered.length === 0) { hideCmdMenu(); return; } cmdMenuIndex = 0; cmdMenu.innerHTML = filtered.map((c, i) => `
${c.cmd} ${c.desc}
` ).join(''); cmdMenu.hidden = false; // Click handlers cmdMenu.querySelectorAll('.cmd-item').forEach(el => { el.addEventListener('click', () => { const cmd = el.dataset.cmd; if (cmd === '/model') { hideCmdMenu(); msgInput.value = ''; showModelPicker(); return; } if (cmd === '/mode') { hideCmdMenu(); msgInput.value = ''; showModePicker(); return; } msgInput.value = cmd + ' '; hideCmdMenu(); msgInput.focus(); }); }); } function hideCmdMenu() { cmdMenu.hidden = true; cmdMenuIndex = -1; } function navigateCmdMenu(direction) { const items = cmdMenu.querySelectorAll('.cmd-item'); if (items.length === 0) return; items[cmdMenuIndex]?.classList.remove('active'); cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length; items[cmdMenuIndex]?.classList.add('active'); } function selectCmdMenuItem() { const items = cmdMenu.querySelectorAll('.cmd-item'); if (cmdMenuIndex >= 0 && items[cmdMenuIndex]) { const cmd = items[cmdMenuIndex].dataset.cmd; if (cmd === '/model') { hideCmdMenu(); msgInput.value = ''; showModelPicker(); return; } if (cmd === '/mode') { hideCmdMenu(); msgInput.value = ''; showModePicker(); return; } msgInput.value = cmd + ' '; hideCmdMenu(); msgInput.focus(); } } // --- Option Picker (generic) --- function showOptionPicker(title, options, currentValue, onSelect) { hideOptionPicker(); const picker = document.createElement('div'); picker.className = 'option-picker'; picker.id = 'option-picker'; picker.innerHTML = `
${escapeHtml(title)}
${options.map(opt => `
${escapeHtml(opt.label)}
${escapeHtml(opt.desc)}
${opt.value === currentValue ? '' : ''}
`).join('')} `; const chatMain = document.querySelector('.chat-main'); chatMain.appendChild(picker); picker.querySelectorAll('.option-picker-item').forEach(el => { el.addEventListener('click', () => { onSelect(el.dataset.value); hideOptionPicker(); }); }); // Close on outside click (delayed to avoid immediate close) setTimeout(() => { document.addEventListener('click', _pickerOutsideClick); }, 0); document.addEventListener('keydown', _pickerEscape); } function hideOptionPicker() { const picker = document.getElementById('option-picker'); if (picker) picker.remove(); document.removeEventListener('click', _pickerOutsideClick); document.removeEventListener('keydown', _pickerEscape); } function _pickerOutsideClick(e) { const picker = document.getElementById('option-picker'); if (picker && !picker.contains(e.target)) { hideOptionPicker(); } } function _pickerEscape(e) { if (e.key === 'Escape') { hideOptionPicker(); } } function showModelPicker() { showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => { send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode }); }); } function showModePicker() { showOptionPicker('选择权限模式', MODE_PICKER_OPTIONS, currentMode, (value) => { currentMode = value; modeSelect.value = currentMode; localStorage.setItem('cc-web-mode', currentMode); if (currentSessionId) { send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); } }); } // --- Send Message --- function sendMessage() { const text = msgInput.value.trim(); if (!text || isGenerating) return; hideCmdMenu(); hideOptionPicker(); // Slash commands: don't show as user bubble if (text.startsWith('/')) { // /model without argument → show interactive picker if (text === '/model' || text === '/model ') { showModelPicker(); msgInput.value = ''; autoResize(); return; } // /mode without argument → show interactive picker if (text === '/mode' || text === '/mode ') { showModePicker(); msgInput.value = ''; autoResize(); return; } send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode }); msgInput.value = ''; autoResize(); return; } // Regular message const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); messagesDiv.appendChild(createMsgElement('user', text)); scrollToBottom(); send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode }); msgInput.value = ''; autoResize(); startGenerating(); } function autoResize() { msgInput.style.height = 'auto'; const max = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--input-max-height')) || 200; msgInput.style.height = Math.min(msgInput.scrollHeight, max) + 'px'; } function isMobileInputMode() { return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches; } // --- Event Listeners --- loginForm.addEventListener('submit', (e) => { e.preventDefault(); const pw = loginPassword.value; if (!pw) return; loginError.hidden = true; loginPasswordValue = pw; // Remember password if (rememberPw.checked) { localStorage.setItem('cc-web-pw', pw); } else { localStorage.removeItem('cc-web-pw'); } send({ type: 'auth', password: pw }); // Request notification permission on first user interaction requestNotificationPermission(); }); menuBtn.addEventListener('click', () => { sidebar.classList.contains('open') ? closeSidebar() : openSidebar(); }); sidebarOverlay.addEventListener('click', closeSidebar); newChatBtn.addEventListener('click', () => send({ type: 'new_session' })); sendBtn.addEventListener('click', sendMessage); abortBtn.addEventListener('click', () => send({ type: 'abort' })); // Mode selector modeSelect.value = currentMode; modeSelect.addEventListener('change', () => { currentMode = modeSelect.value; localStorage.setItem('cc-web-mode', currentMode); if (currentSessionId) { send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); } }); msgInput.addEventListener('input', () => { autoResize(); const val = msgInput.value; // Show slash command menu if (val.startsWith('/') && !val.includes('\n')) { showCmdMenu(val); } else { hideCmdMenu(); } }); msgInput.addEventListener('keydown', (e) => { // Command menu navigation if (!cmdMenu.hidden) { if (e.key === 'ArrowDown') { e.preventDefault(); navigateCmdMenu(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); navigateCmdMenu(-1); return; } if (e.key === 'Tab') { e.preventDefault(); selectCmdMenuItem(); return; } if (e.key === 'Escape') { hideCmdMenu(); return; } } if (e.key === 'Enter' && !e.shiftKey) { if (isMobileInputMode()) { if (!cmdMenu.hidden) { e.preventDefault(); selectCmdMenuItem(); } return; } e.preventDefault(); if (!cmdMenu.hidden) { // If menu is open and user presses Enter, select the item selectCmdMenuItem(); } else { sendMessage(); } } }); // Close cmd menu on outside click document.addEventListener('click', (e) => { if (!cmdMenu.contains(e.target) && e.target !== msgInput) { hideCmdMenu(); } }); // --- Toast Notification --- function showToast(text, sessionId) { const toast = document.createElement('div'); toast.className = 'toast-notification'; toast.textContent = text; if (sessionId) { toast.style.cursor = 'pointer'; toast.addEventListener('click', () => { send({ type: 'load_session', sessionId }); toast.remove(); }); } document.body.appendChild(toast); setTimeout(() => toast.classList.add('show'), 10); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 5000); } // --- Browser Notification (via Service Worker for mobile) --- function showBrowserNotification(title) { if (!('Notification' in window) || Notification.permission !== 'granted') return; if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then((reg) => { reg.showNotification('CC-Web', { body: `「${title}」任务完成`, tag: 'cc-web-task', renotify: true, }); }).catch(() => {}); } } function requestNotificationPermission() { if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } } // --- Settings Panel --- let _onNotifyConfig = null; let _onNotifyTestResult = null; let _onModelConfig = null; const settingsBtn = $('#settings-btn'); const PROVIDER_OPTIONS = [ { value: 'off', label: '关闭' }, { value: 'pushplus', label: 'PushPlus' }, { value: 'telegram', label: 'Telegram' }, { value: 'serverchan', label: 'Server酱' }, { value: 'feishu', label: '飞书机器人' }, { value: 'qqbot', label: 'QQ(Qmsg)' }, ]; function showSettingsPanel() { // Request current configs send({ type: 'get_notify_config' }); send({ type: 'get_model_config' }); const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; overlay.id = 'settings-overlay'; const panel = document.createElement('div'); panel.className = 'settings-panel'; panel.innerHTML = `

⚙ 设置

模型配置
通知设置
修改密码
至少 8 位,包含大写/小写/数字/特殊字符中的 2 种
`; overlay.appendChild(panel); document.body.appendChild(overlay); // === Model Config UI === const modelModeSelect = panel.querySelector('#model-mode'); const modelCustomArea = panel.querySelector('#model-custom-area'); const modelActionsDiv = panel.querySelector('#model-actions'); const modelSaveBtn = panel.querySelector('#model-save-btn'); const modelStatusDiv = panel.querySelector('#model-status'); let modelCurrentConfig = null; let modelEditingTemplates = []; let modelActiveTemplate = ''; function showModelStatus(msg, type) { modelStatusDiv.textContent = msg; modelStatusDiv.className = 'settings-status ' + (type || ''); } function renderModelCustomArea() { if (modelModeSelect.value === 'local') { modelCustomArea.innerHTML = `
⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。
`; modelActionsDiv.style.display = 'flex'; } else { renderModelTemplateEditor(); modelActionsDiv.style.display = 'flex'; } } function renderModelTemplateEditor() { const activeName = modelActiveTemplate; const tpl = modelEditingTemplates.find(t => t.name === activeName) || null; const tplOptions = modelEditingTemplates.map(t => `` ).join(''); if (modelEditingTemplates.length === 0) { modelCustomArea.innerHTML = `
尚无模板,点击下方按钮新建。
`; panel.querySelector('#model-tpl-add-first').addEventListener('click', () => { const newName = prompt('输入新模板名称:'); if (!newName || !newName.trim()) return; const n = newName.trim(); modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' }); modelActiveTemplate = n; renderModelTemplateEditor(); }); return; } modelCustomArea.innerHTML = `
`; panel.querySelector('#model-tpl-select').addEventListener('change', (e) => { if (e.target.value === '__new__') { const newName = prompt('输入新模板名称:'); if (!newName || !newName.trim()) { e.target.value = modelActiveTemplate; return; } const n = newName.trim(); if (modelEditingTemplates.find(t => t.name === n)) { alert('模板名称已存在'); e.target.value = modelActiveTemplate; return; } modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' }); modelActiveTemplate = n; renderModelTemplateEditor(); openTplEditModal(); } else { modelActiveTemplate = e.target.value; renderModelTemplateEditor(); } }); panel.querySelector('#model-tpl-edit').addEventListener('click', () => { openTplEditModal(); }); const delBtn = panel.querySelector('#model-tpl-del'); if (delBtn) { delBtn.addEventListener('click', () => { if (!modelActiveTemplate) return; if (!confirm(`确认删除模板「${modelActiveTemplate}」?`)) return; modelEditingTemplates = modelEditingTemplates.filter(t => t.name !== modelActiveTemplate); modelActiveTemplate = modelEditingTemplates[0]?.name || ''; renderModelTemplateEditor(); }); } } function openTplEditModal() { const tpl = modelEditingTemplates.find(t => t.name === modelActiveTemplate); if (!tpl) return; const modalOverlay = document.createElement('div'); modalOverlay.className = 'settings-overlay'; modalOverlay.style.zIndex = '10001'; const modal = document.createElement('div'); modal.className = 'settings-panel'; modal.style.maxWidth = '460px'; modal.innerHTML = `

编辑模板: ${escapeHtml(tpl.name)}

`; modalOverlay.appendChild(modal); document.body.appendChild(modalOverlay); const closeModal = () => { document.body.removeChild(modalOverlay); }; modal.querySelector('#tpl-modal-close').addEventListener('click', closeModal); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); }); modal.querySelector('#tpl-ed-ok').addEventListener('click', () => { const newName = modal.querySelector('#tpl-ed-name').value.trim(); if (newName && newName !== tpl.name) { if (modelEditingTemplates.find(t => t.name === newName && t !== tpl)) { alert('模板名称已存在'); return; } tpl.name = newName; modelActiveTemplate = newName; } tpl.apiKey = modal.querySelector('#tpl-ed-apikey').value.trim(); tpl.apiBase = modal.querySelector('#tpl-ed-apibase').value.trim(); tpl.defaultModel = modal.querySelector('#tpl-ed-default').value.trim(); tpl.opusModel = modal.querySelector('#tpl-ed-opus').value.trim(); tpl.sonnetModel = modal.querySelector('#tpl-ed-sonnet').value.trim(); tpl.haikuModel = modal.querySelector('#tpl-ed-haiku').value.trim(); closeModal(); renderModelTemplateEditor(); }); } function saveTplFields() { // Fields are now saved via modal, no inline fields to read } modelModeSelect.addEventListener('change', renderModelCustomArea); modelSaveBtn.addEventListener('click', () => { if (modelModeSelect.value === 'custom') saveTplFields(); const config = { mode: modelModeSelect.value, activeTemplate: modelActiveTemplate, templates: modelEditingTemplates, }; send({ type: 'save_model_config', config }); showModelStatus('已保存', 'success'); }); _onModelConfig = (config) => { modelCurrentConfig = config; modelEditingTemplates = (config.templates || []).map(t => Object.assign({}, t)); modelActiveTemplate = config.activeTemplate || (modelEditingTemplates[0]?.name || ''); modelModeSelect.value = config.mode || 'local'; renderModelCustomArea(); }; // === Notify Config UI === const providerSelect = panel.querySelector('#notify-provider'); const fieldsDiv = panel.querySelector('#notify-fields'); const statusDiv = panel.querySelector('#notify-status'); const closeBtn = panel.querySelector('.settings-close'); const testBtn = panel.querySelector('#notify-test-btn'); const saveBtn = panel.querySelector('#notify-save-btn'); let currentConfig = null; function renderFields(provider) { fieldsDiv.innerHTML = ''; if (provider === 'pushplus') { fieldsDiv.innerHTML = `
`; } else if (provider === 'telegram') { fieldsDiv.innerHTML = `
`; } else if (provider === 'serverchan') { fieldsDiv.innerHTML = `
`; } else if (provider === 'feishu') { fieldsDiv.innerHTML = `
`; } else if (provider === 'qqbot') { fieldsDiv.innerHTML = `
`; } } providerSelect.addEventListener('change', () => renderFields(providerSelect.value)); function collectConfig() { const provider = providerSelect.value; const config = { provider }; const pp = panel.querySelector('#notify-pushplus-token'); const tgBot = panel.querySelector('#notify-tg-bottoken'); const tgChat = panel.querySelector('#notify-tg-chatid'); const sc = panel.querySelector('#notify-sc-sendkey'); const feishuWh = panel.querySelector('#notify-feishu-webhook'); const qmsgKey = panel.querySelector('#notify-qmsg-key'); config.pushplus = { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') }; config.telegram = { botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''), chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || '') }; config.serverchan = { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') }; config.feishu = { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') }; config.qqbot = { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') }; return config; } function showStatus(msg, type) { statusDiv.textContent = msg; statusDiv.className = 'settings-status ' + type; } _onNotifyConfig = (config) => { currentConfig = config; providerSelect.value = config.provider || 'off'; renderFields(config.provider || 'off'); }; _onNotifyTestResult = (msg) => { showStatus(msg.message, msg.success ? 'success' : 'error'); }; closeBtn.addEventListener('click', hideSettingsPanel); overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); }); testBtn.addEventListener('click', () => { // Save first then test const config = collectConfig(); send({ type: 'save_notify_config', config }); showStatus('正在发送测试消息...', ''); send({ type: 'test_notify' }); }); saveBtn.addEventListener('click', () => { const config = collectConfig(); send({ type: 'save_notify_config', config }); showStatus('已保存', 'success'); }); // Password change in settings const settingsCurrentPw = panel.querySelector('#settings-current-pw'); const settingsNewPw = panel.querySelector('#settings-new-pw'); const settingsConfirmPw = panel.querySelector('#settings-confirm-pw'); const pwHint = panel.querySelector('#settings-pw-hint'); const pwChangeBtn = panel.querySelector('#pw-change-btn'); const pwStatus = panel.querySelector('#pw-status'); function checkSettingsPw() { const newPw = settingsNewPw.value; const confirmPw = settingsConfirmPw.value; const currentPw = settingsCurrentPw.value; if (!newPw) { pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; pwHint.className = 'password-hint'; pwChangeBtn.disabled = true; return; } const result = clientValidatePassword(newPw); if (!result.valid) { pwHint.textContent = result.message; pwHint.className = 'password-hint error'; pwChangeBtn.disabled = true; return; } pwHint.textContent = '密码强度符合要求'; pwHint.className = 'password-hint success'; pwChangeBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw; } settingsCurrentPw.addEventListener('input', checkSettingsPw); settingsNewPw.addEventListener('input', checkSettingsPw); settingsConfirmPw.addEventListener('input', checkSettingsPw); pwChangeBtn.addEventListener('click', () => { const currentPw = settingsCurrentPw.value; const newPw = settingsNewPw.value; const confirmPw = settingsConfirmPw.value; if (newPw !== confirmPw) { pwStatus.textContent = '两次密码不一致'; pwStatus.className = 'settings-status error'; return; } pwChangeBtn.disabled = true; pwStatus.textContent = '正在修改...'; pwStatus.className = 'settings-status'; _onPasswordChanged = (result) => { if (result.success) { pwStatus.textContent = result.message || '密码修改成功'; pwStatus.className = 'settings-status success'; settingsCurrentPw.value = ''; settingsNewPw.value = ''; settingsConfirmPw.value = ''; pwHint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; pwHint.className = 'password-hint'; } else { pwStatus.textContent = result.message || '修改失败'; pwStatus.className = 'settings-status error'; pwChangeBtn.disabled = false; } }; send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw }); }); document.addEventListener('keydown', _settingsEscape); } function hideSettingsPanel() { const overlay = document.getElementById('settings-overlay'); if (overlay) overlay.remove(); _onNotifyConfig = null; _onNotifyTestResult = null; _onModelConfig = null; document.removeEventListener('keydown', _settingsEscape); } function _settingsEscape(e) { if (e.key === 'Escape') hideSettingsPanel(); } if (settingsBtn) { settingsBtn.addEventListener('click', showSettingsPanel); } // --- Force Change Password --- function showForceChangePassword() { const overlay = document.createElement('div'); overlay.className = 'force-change-overlay'; overlay.id = 'force-change-overlay'; const panel = document.createElement('div'); panel.className = 'force-change-panel'; panel.innerHTML = `

修改初始密码

首次登录需要设置新密码

至少 8 位,包含大写/小写/数字/特殊字符中的 2 种
`; overlay.appendChild(panel); document.body.appendChild(overlay); const newPwInput = panel.querySelector('#fc-new-pw'); const confirmPwInput = panel.querySelector('#fc-confirm-pw'); const hintEl = panel.querySelector('#fc-hint'); const submitBtn = panel.querySelector('#fc-submit-btn'); const statusEl = panel.querySelector('#fc-status'); function checkStrength() { const pw = newPwInput.value; const confirm = confirmPwInput.value; if (!pw) { hintEl.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; hintEl.className = 'password-hint'; submitBtn.disabled = true; return; } const result = clientValidatePassword(pw); if (!result.valid) { hintEl.textContent = result.message; hintEl.className = 'password-hint error'; submitBtn.disabled = true; return; } hintEl.textContent = '密码强度符合要求'; hintEl.className = 'password-hint success'; submitBtn.disabled = !confirm || confirm !== pw; } newPwInput.addEventListener('input', checkStrength); confirmPwInput.addEventListener('input', checkStrength); submitBtn.addEventListener('click', () => { const newPw = newPwInput.value; const confirmPw = confirmPwInput.value; if (newPw !== confirmPw) { statusEl.textContent = '两次密码不一致'; statusEl.className = 'fc-status error'; return; } submitBtn.disabled = true; statusEl.textContent = '正在修改...'; statusEl.className = 'fc-status'; send({ type: 'change_password', currentPassword: loginPasswordValue || localStorage.getItem('cc-web-pw') || '', newPassword: newPw }); }); newPwInput.focus(); } function hideForceChangePassword() { const overlay = document.getElementById('force-change-overlay'); if (overlay) overlay.remove(); } function clientValidatePassword(pw) { if (!pw || pw.length < 8) { return { valid: false, message: '密码长度至少 8 位' }; } let types = 0; if (/[a-z]/.test(pw)) types++; if (/[A-Z]/.test(pw)) types++; if (/[0-9]/.test(pw)) types++; if (/[^a-zA-Z0-9]/.test(pw)) types++; if (types < 2) { return { valid: false, message: '需包含至少 2 种字符类型(大写/小写/数字/特殊字符)' }; } return { valid: true, message: '' }; } // --- Password Changed Handler --- let _onPasswordChanged = null; function handlePasswordChanged(msg) { if (msg.success) { // Update token authToken = msg.token; localStorage.setItem('cc-web-token', msg.token); // Update remembered password if (localStorage.getItem('cc-web-pw')) { // Clear old remembered password since it's changed localStorage.removeItem('cc-web-pw'); } // If force-change overlay is open, close it and load sessions const fcOverlay = document.getElementById('force-change-overlay'); if (fcOverlay) { hideForceChangePassword(); const lastSession = localStorage.getItem('cc-web-session'); if (lastSession) { send({ type: 'load_session', sessionId: lastSession }); } showToast('密码修改成功'); } // If settings panel change password if (_onPasswordChanged) { _onPasswordChanged({ success: true, message: msg.message }); _onPasswordChanged = null; } } else { // Force-change error const fcStatus = document.querySelector('#fc-status'); if (fcStatus) { fcStatus.textContent = msg.message || '修改失败'; fcStatus.className = 'fc-status error'; const btn = document.querySelector('#fc-submit-btn'); if (btn) btn.disabled = false; } // Settings panel error if (_onPasswordChanged) { _onPasswordChanged({ success: false, message: msg.message }); _onPasswordChanged = null; } } } // --- Helpers --- function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function timeAgo(dateStr) { if (!dateStr) return ''; const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return '刚刚'; if (mins < 60) return `${mins}分钟前`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}小时前`; const days = Math.floor(hours / 24); if (days < 30) return `${days}天前`; return new Date(dateStr).toLocaleDateString('zh-CN'); } // --- Init --- connect(); // Register Service Worker for mobile push notifications if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); } // Restore remembered password const savedPw = localStorage.getItem('cc-web-pw'); if (savedPw) { loginPassword.value = savedPw; rememberPw.checked = true; } // Visibility change: re-sync state when user returns to tab (critical for mobile) document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; if (!ws || ws.readyState > 1) { // WS is dead, force reconnect connect(); } else if (ws.readyState === 1 && currentSessionId) { // WS alive, re-check session state to sync UI (fixes stuck stop button) send({ type: 'load_session', sessionId: currentSessionId }); } }); if (!authToken) { loginOverlay.hidden = false; app.hidden = true; } else { loginOverlay.hidden = true; app.hidden = false; } })();