// Reuse the existing app.js logic - this was copied over from chat/public/app.js // In a production step, you'd avoid duplication: use a symlink or keep a single source const state = { sessions: [], currentSessionId: null, models: [], pollingTimer: null, mode: 'agent', // agent or plan showingHome: false, cliOptions: ['opencode', 'gemini-cli'], currentCli: 'opencode', activeStreams: new Map(), // Track active SSE connections opencodeStatus: null, }; const el = { sessionList: document.getElementById('session-list'), chatArea: document.getElementById('chat-area'), chatTitle: document.getElementById('chat-title'), sessionId: document.getElementById('session-id'), sessionModel: document.getElementById('session-model'), sessionPending: document.getElementById('session-pending'), queueIndicator: document.getElementById('queue-indicator'), cliSelect: document.getElementById('cli-select'), modelSelect: document.getElementById('model-select'), modeSelect: document.getElementById('mode-select'), customModelLabel: document.getElementById('custom-model-label'), customModelInput: document.getElementById('custom-model-input'), newChat: document.getElementById('new-chat'), homeBtn: document.getElementById('home-btn'), homeView: document.getElementById('home-view'), homeSessionList: document.getElementById('home-session-list'), sessionMeta: document.getElementById('session-meta'), messageInput: document.getElementById('message-input'), sendBtn: document.getElementById('send-btn'), statusLine: document.getElementById('status-line'), quickButtons: document.querySelectorAll('[data-quick]'), gitButtons: document.querySelectorAll('[data-git]'), gitOutput: document.getElementById('git-output'), diagnosticsButton: document.getElementById('diagnostics-button'), commitMessage: document.getElementById('commit-message'), githubButton: document.getElementById('github-button'), githubModal: document.getElementById('github-modal'), githubClose: document.getElementById('github-close'), modalCommitMessage: document.getElementById('modal-commit-message'), }; function setStatus(msg) { el.statusLine.textContent = msg || ''; } async function api(path, options = {}) { const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options }); const text = await res.text(); const json = text ? JSON.parse(text) : {}; if (!res.ok) { const err = new Error(json.error || res.statusText); // include stdout/stderr if returned by the server if (json.stdout) err.stdout = json.stdout; if (json.stderr) err.stderr = json.stderr; throw err; } return json; } function populateCliSelect() { if (!el.cliSelect) return; el.cliSelect.innerHTML = ''; state.cliOptions.forEach((cli) => { const opt = document.createElement('option'); opt.value = cli; opt.textContent = cli.toUpperCase(); el.cliSelect.appendChild(opt); }); el.cliSelect.value = state.currentCli; } function renderSessions() { el.sessionList.innerHTML = ''; if (!state.sessions.length) { el.sessionList.innerHTML = '
No sessions yet.
'; return; } state.sessions.forEach((session) => { const div = document.createElement('div'); div.className = `session-item ${session.id === state.currentSessionId ? 'active' : ''}`; div.innerHTML = `
${session.title || 'Chat'}
${session.cli || 'opencode'} ${session.model} ${session.pending || 0} queued
`; div.addEventListener('click', () => selectSession(session.id)); const deleteBtn = div.querySelector('.delete-btn'); const cancelBtn = div.querySelector('.cancel-delete'); const confirmBtn = div.querySelector('.confirm-delete'); if (deleteBtn) { deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = div.classList.toggle('show-delete-menu'); if (isOpen) { document.querySelectorAll('.session-item.show-delete-menu').forEach((item) => { if (item !== div) item.classList.remove('show-delete-menu'); }); const onDocClick = (ev) => { if (!div.contains(ev.target)) div.classList.remove('show-delete-menu'); }; setTimeout(() => document.addEventListener('click', onDocClick, { once: true }), 0); } }); } if (cancelBtn) cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); div.classList.remove('show-delete-menu'); }); if (confirmBtn) confirmBtn.addEventListener('click', async (e) => { e.stopPropagation(); try { await api(`/api/sessions/${session.id}`, { method: 'DELETE' }); state.sessions = state.sessions.filter((s) => s.id !== session.id); if (state.currentSessionId === session.id) { if (state.sessions.length) await selectSession(state.sessions[0].id); else { state.currentSessionId = null; el.sessionList.innerHTML = ''; await createSession(); } } else { renderSessions(); } setStatus('Chat deleted'); } catch (error) { setStatus(`Failed to delete chat: ${error.message}`); } }); el.sessionList.appendChild(div); }); } function renderSessionMeta(session) { el.sessionId.textContent = session.id; el.sessionModel.textContent = `${session.cli || 'opencode'} / ${session.model}`; el.sessionPending.textContent = session.pending || 0; el.queueIndicator.textContent = session.pending ? `${session.pending} queued` : 'Idle'; el.queueIndicator.style.borderColor = session.pending ? 'var(--accent)' : 'var(--border)'; el.chatTitle.textContent = session.title || 'Chat'; if (session.cli && el.cliSelect && el.cliSelect.value !== session.cli) { el.cliSelect.value = session.cli; state.currentCli = session.cli; } if (session.model && el.modelSelect.value !== session.model) el.modelSelect.value = session.model; } function renderMessages(session) { el.chatArea.innerHTML = ''; if (!session.messages || !session.messages.length) { el.chatArea.innerHTML = '
Send a message to start the conversation.
'; return; } session.messages.forEach((msg) => { const status = msg.status || 'done'; const userCard = document.createElement('div'); userCard.className = 'message user'; const userMeta = document.createElement('div'); userMeta.className = 'meta'; userMeta.innerHTML = ` You ${msg.model || session.model} ${status} `; const userBody = document.createElement('div'); userBody.className = 'body'; userBody.appendChild(renderContentWithTodos(msg.content || '')); userCard.appendChild(userMeta); userCard.appendChild(userBody); // Attachments if (Array.isArray(msg.attachments) && msg.attachments.length) { const attachWrap = document.createElement('div'); attachWrap.className = 'attachments'; msg.attachments.forEach((a) => { if (a && a.url && (a.type || '').startsWith('image/')) { const img = document.createElement('img'); img.className = 'attachment-image'; img.src = a.url; img.alt = a.name || 'image'; img.style.maxWidth = '400px'; img.style.display = 'block'; img.style.marginTop = '8px'; attachWrap.appendChild(img); } }); userCard.appendChild(attachWrap); } el.chatArea.appendChild(userCard); if (msg.reply || msg.error || (status === 'running' && msg.partialOutput)) { const assistantCard = document.createElement('div'); assistantCard.className = 'message assistant'; const assistantMeta = document.createElement('div'); assistantMeta.className = 'meta'; assistantMeta.innerHTML = `${(session.cli || 'opencode').toUpperCase()}`; // Add a small raw toggle button for diagnostics const rawBtn = document.createElement('button'); rawBtn.className = 'ghost'; rawBtn.style.marginLeft = '8px'; rawBtn.textContent = 'Raw'; assistantMeta.appendChild(rawBtn); const assistantBody = document.createElement('div'); assistantBody.className = 'body'; assistantBody.appendChild(renderContentWithTodos(msg.reply || msg.partialOutput || msg.opencodeSummary || '')); assistantCard.appendChild(assistantMeta); assistantCard.appendChild(assistantBody); if (Array.isArray(msg.attachments) && msg.attachments.length) { const attachWrap = document.createElement('div'); attachWrap.className = 'attachments'; msg.attachments.forEach((a) => { if (a && a.url && (a.type || '').startsWith('image/')) { const img = document.createElement('img'); img.className = 'attachment-image'; img.src = a.url; img.alt = a.name || 'image'; img.style.maxWidth = '400px'; img.style.display = 'block'; img.style.marginTop = '8px'; attachWrap.appendChild(img); } }); assistantCard.appendChild(attachWrap); } if (msg.error) { const err = document.createElement('div'); err.className = 'body'; err.style.color = 'var(--danger)'; err.textContent = msg.error; assistantCard.appendChild(err); } if ((!msg.reply || !msg.reply.length) && (!msg.partialOutput || !msg.partialOutput.length) && msg.opencodeSummary) { const summary = document.createElement('div'); summary.className = 'body'; summary.style.color = 'var(--muted)'; summary.textContent = `Opencode output: ${msg.opencodeSummary}`; assistantCard.appendChild(summary); } // Raw output container const rawPre = document.createElement('pre'); rawPre.className = 'raw-output muted'; rawPre.style.display = 'none'; rawPre.textContent = [(msg.partialOutput || ''), (msg.opencodeSummary || '')].filter(Boolean).join('\n\n'); assistantCard.appendChild(rawPre); rawBtn.addEventListener('click', () => { rawPre.style.display = rawPre.style.display === 'none' ? 'block' : 'none'; }); el.chatArea.appendChild(assistantCard); } }); el.chatArea.scrollTop = el.chatArea.scrollHeight; } // Helper: render text content and convert markdown task-list lines to actual checkboxes function renderContentWithTodos(text) { const wrapper = document.createElement('div'); if (!text) return document.createTextNode(''); const processedText = String(text).replace(/\.\s+/g, '.\n'); const lines = processedText.split(/\r?\n/); let currentList = null; for (const line of lines) { const taskMatch = line.match(/^\s*[-*]\s*\[( |x|X)\]\s*(.*)$/); if (taskMatch) { if (!currentList) { currentList = document.createElement('ul'); wrapper.appendChild(currentList); } const li = document.createElement('li'); const label = document.createElement('label'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.disabled = true; // non-interactive for now checkbox.checked = !!taskMatch[1].trim(); label.appendChild(checkbox); label.appendChild(document.createTextNode(' ' + taskMatch[2])); li.appendChild(label); currentList.appendChild(li); } else { if (currentList) currentList = null; const p = document.createElement('div'); p.textContent = line; wrapper.appendChild(p); } } return wrapper; } async function loadModels(cli = state.currentCli || 'opencode') { try { state.currentCli = cli; if (el.cliSelect && el.cliSelect.value !== cli) el.cliSelect.value = cli; const data = await api(`/api/models?cli=${encodeURIComponent(cli)}`); state.models = data.models || []; el.modelSelect.innerHTML = ''; state.models.forEach((m) => { const option = document.createElement('option'); option.value = m.name || m.id || m; option.textContent = m.label || m.name || m.id || m; el.modelSelect.appendChild(option); }); const customOpt = document.createElement('option'); customOpt.value = 'custom'; customOpt.textContent = 'Custom model...'; el.modelSelect.appendChild(customOpt); } catch (error) { setStatus(`Model load failed: ${error.message}`); } } async function loadSessions() { const data = await api('/api/sessions'); state.sessions = data.sessions || []; if (state.sessions.length && !state.currentCli) { state.currentCli = state.sessions[0].cli || 'opencode'; if (el.cliSelect) el.cliSelect.value = state.currentCli; } renderSessions(); if (!state.currentSessionId && state.sessions.length && !state.showingHome) { await selectSession(state.sessions[0].id); } } async function selectSession(id) { state.currentSessionId = id; const session = state.sessions.find((s) => s.id === id); if (session) { state.currentCli = session.cli || 'opencode'; if (el.cliSelect) el.cliSelect.value = state.currentCli; await loadModels(state.currentCli); } state.showingHome = false; showChatView(); renderSessions(); await refreshCurrentSession(); setPollingInterval(2500); } function showHomeView() { state.showingHome = true; state.currentSessionId = null; el.homeView.style.display = 'block'; el.chatArea.style.display = 'none'; el.sessionMeta.style.display = 'none'; document.querySelector('.composer').style.display = 'none'; // Render all sessions in home view el.homeSessionList.innerHTML = ''; state.sessions.forEach((session) => { const card = document.createElement('div'); card.style.cssText = 'padding:16px; border:1px solid var(--border); border-radius:8px; cursor:pointer; transition:all 0.2s;'; card.innerHTML = `
${session.title || 'Chat'}
Model: ${session.model} • ${session.messages.length} messages • Updated: ${new Date(session.updatedAt).toLocaleString()}
`; card.onmouseenter = () => card.style.borderColor = 'var(--accent)'; card.onmouseleave = () => card.style.borderColor = 'var(--border)'; card.onclick = () => selectSession(session.id); el.homeSessionList.appendChild(card); }); } function showChatView() { state.showingHome = false; el.homeView.style.display = 'none'; el.chatArea.style.display = 'block'; el.sessionMeta.style.display = 'flex'; document.querySelector('.composer').style.display = 'block'; } // Set up SSE stream for a message function streamMessage(sessionId, messageId) { // Close existing stream if any if (state.activeStreams.has(messageId)) { const existing = state.activeStreams.get(messageId); existing.close(); state.activeStreams.delete(messageId); } const url = `/api/sessions/${sessionId}/messages/${messageId}/stream`; const eventSource = new EventSource(url); eventSource.onopen = () => { console.log('SSE stream opened for message', messageId); }; eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); // Update the session with streaming data const session = state.sessions.find(s => s.id === sessionId); if (!session) return; const message = session.messages.find(m => m.id === messageId); if (!message) return; if (data.type === 'start') { message.status = 'running'; setStatus('OpenCode is responding...'); } else if (data.type === 'chunk') { // Update partial output immediately message.partialOutput = data.filtered || data.partialOutput || data.content; message.outputType = data.outputType; message.partialUpdatedAt = data.timestamp; message.status = 'running'; // Re-render messages to show new content immediately renderMessages(session); setStatus('Streaming response...'); } else if (data.type === 'complete') { message.reply = data.content; message.status = 'done'; message.finishedAt = data.timestamp; message.outputType = data.outputType; message.opencodeExitCode = data.exitCode; eventSource.close(); state.activeStreams.delete(messageId); renderMessages(session); setStatus('Complete'); // Update session list renderSessions(); } else if (data.type === 'error') { message.error = data.error; message.status = 'error'; message.finishedAt = data.timestamp; message.opencodeExitCode = data.code; eventSource.close(); state.activeStreams.delete(messageId); renderMessages(session); setStatus('Error: ' + data.error); // Update session list renderSessions(); } } catch (err) { console.error('Failed to parse SSE message', err); } }; eventSource.onerror = (err) => { console.error('SSE error', err); eventSource.close(); state.activeStreams.delete(messageId); // Fall back to polling for this message setTimeout(() => refreshCurrentSession(), 1000); }; state.activeStreams.set(messageId, eventSource); } async function refreshCurrentSession() { if (!state.currentSessionId) return; try { const { session } = await api(`/api/sessions/${state.currentSessionId}`); state.sessions = state.sessions.map((s) => (s.id === session.id ? session : s)); renderSessions(); renderSessionMeta(session); renderMessages(session); // Set up streaming for any running messages that don't have streams yet const running = (session.messages || []).filter((m) => m.status === 'running' || m.status === 'queued'); running.forEach(msg => { if (!state.activeStreams.has(msg.id)) { streamMessage(session.id, msg.id); } }); // Adjust polling - slower when using SSE if (running.length > 0) setPollingInterval(2000); else setPollingInterval(5000); } catch (error) { setStatus(error.message); } } function setPollingInterval(intervalMs) { if (!intervalMs) return; if (state.pollingInterval === intervalMs) return; if (state.pollingTimer) clearInterval(state.pollingTimer); state.pollingInterval = intervalMs; state.pollingTimer = setInterval(refreshCurrentSession, state.pollingInterval); } async function createSession(options = {}) { const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode'; state.currentCli = cli; const model = el.modelSelect.value || 'default'; const payload = { model, cli }; if (options.appId) payload.appId = options.appId; const { session } = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) }); state.sessions.unshift(session); renderSessions(); await selectSession(session.id); } async function checkOpencodeStatus() { try { const status = await api('/api/opencode/status'); state.opencodeStatus = status; if (!status.available) { setStatus(`Warning: OpenCode CLI not available - ${status.error || 'unknown error'}`); } return status; } catch (error) { console.error('Failed to check opencode status', error); return null; } } async function sendMessage() { const content = el.messageInput.value.trim(); if (!content) return; // Create session if needed if (!state.currentSessionId) await createSession(); const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode'; state.currentCli = cli; let model = el.modelSelect.value; if (model === 'custom') { const txt = el.customModelInput.value.trim(); if (txt) model = txt; else { setStatus('Please enter a custom model name'); el.sendBtn.disabled = false; return; } } const mode = state.mode || 'agent'; el.sendBtn.disabled = true; setStatus('Sending...'); try { // Auto-generate title from first message if it's "New Chat" const session = state.sessions.find(s => s.id === state.currentSessionId); const isFirstMessage = session && session.title === 'New Chat' && session.messages.length === 0; const response = await api(`/api/sessions/${state.currentSessionId}/messages`, { method: 'POST', body: JSON.stringify({ content, model, mode, cli }), }); if (isFirstMessage) { const title = content.slice(0, 50) + (content.length > 50 ? '...' : ''); await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ title }), }); } el.messageInput.value = ''; // Start streaming immediately for the new message if (response.message && response.message.id) { streamMessage(state.currentSessionId, response.message.id); } await refreshCurrentSession(); } catch (error) { setStatus(error.message); } finally { el.sendBtn.disabled = false; } } function hookEvents() { el.newChat.addEventListener('click', () => { const currentSession = state.sessions.find(s => s.id === state.currentSessionId); const currentAppId = currentSession?.appId; createSession(currentAppId ? { appId: currentAppId } : {}); }); el.homeBtn.addEventListener('click', showHomeView); el.sendBtn.addEventListener('click', sendMessage); if (el.cliSelect) { el.cliSelect.addEventListener('change', async () => { state.currentCli = el.cliSelect.value; await loadModels(state.currentCli); if (state.currentSessionId) { try { await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ cli: state.currentCli }) }); await refreshCurrentSession(); } catch (err) { setStatus(`Failed to update CLI: ${err.message}`); } } }); } el.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) sendMessage(); }); // Support paste image handling: if user pastes an image, convert to base64 and send el.messageInput.addEventListener('paste', async (e) => { try { // Check clipboard items for an image const items = e.clipboardData && e.clipboardData.items ? Array.from(e.clipboardData.items) : []; const imageItem = items.find(it => it.type && it.type.startsWith('image/')); if (!imageItem) return; // no image in clipboard e.preventDefault(); const blob = imageItem.getAsFile(); if (!blob) return; const reader = new FileReader(); reader.onload = async () => { const dataUrl = reader.result; // data:image/png;base64,... // Build a message with an attachment // Create session if needed if (!state.currentSessionId) await createSession(); const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode'; state.currentCli = cli; const model = el.modelSelect.value === 'custom' ? el.customModelInput.value.trim() || 'default' : el.modelSelect.value || 'default'; const payload = { content: '', model, cli, mode: state.mode || 'agent', attachments: [{ name: blob.name || 'pasted-image', type: blob.type, data: dataUrl.split(',')[1] }] }; setStatus('Uploading image...'); el.sendBtn.disabled = true; try { const response = await api(`/api/sessions/${state.currentSessionId}/messages`, { method: 'POST', body: JSON.stringify(payload) }); setStatus('Image sent'); el.messageInput.value = ''; // Start streaming for the new message if (response.message && response.message.id) { streamMessage(state.currentSessionId, response.message.id); } await refreshCurrentSession(); } catch (err) { setStatus(`Failed to upload image: ${err.message}`); } el.sendBtn.disabled = false; }; reader.readAsDataURL(blob); } catch (err) { console.error('Paste handler error', err); } }); // Mode select handler el.modeSelect.addEventListener('change', () => { state.mode = el.modeSelect.value; }); el.quickButtons.forEach((btn) => { btn.addEventListener('click', () => { const tag = btn.dataset.quick; const map = { shorter: 'Please condense the last answer.', more: 'Tell me more about this topic.', }; el.messageInput.value = map[tag] || ''; el.messageInput.focus(); }); }); el.gitButtons.forEach((btn) => { btn.addEventListener('click', async () => { const action = btn.dataset.git; el.gitOutput.textContent = `Running ${action}...`; try { const payload = {}; // Disable all git buttons while running el.gitButtons.forEach((b) => b.disabled = true); if (action === 'push' || action === 'sync') payload.message = (el.commitMessage && el.commitMessage.value) ? el.commitMessage.value : (el.modalCommitMessage && el.modalCommitMessage.value) || 'Update from web UI'; const data = await api(`/api/git/${action}`, { method: 'POST', body: JSON.stringify(payload) }); const out = data.output || data.stdout || data.stderr || 'Done'; el.gitOutput.textContent = out; } catch (error) { // Show detailed output if available const lines = []; lines.push(error.message); if (error.stdout) lines.push('\nSTDOUT:\n' + error.stdout.trim()); if (error.stderr) lines.push('\nSTDERR:\n' + error.stderr.trim()); el.gitOutput.textContent = lines.join('\n'); } // Re-enable the buttons el.gitButtons.forEach((b) => b.disabled = false); }); }); // Hook up GitHub modal and its buttons if (el.githubButton) { el.githubButton.addEventListener('click', () => { console.log('GitHub button clicked, showing modal'); el.githubModal.style.display = 'flex'; }); } else { console.error('GitHub button element not found'); } if (el.githubClose) { el.githubClose.addEventListener('click', () => { console.log('GitHub close button clicked'); el.githubModal.style.display = 'none'; }); } const modalButtons = document.querySelectorAll('#github-modal [data-git]'); modalButtons.forEach((btn) => { btn.addEventListener('click', async () => { const action = btn.dataset.git; el.gitOutput.textContent = `Running ${action}...`; try { const payload = {}; // Disable all git buttons while running el.gitButtons.forEach((b) => b.disabled = true); if (action === 'push' || action === 'sync') payload.message = el.modalCommitMessage && el.modalCommitMessage.value ? el.modalCommitMessage.value : 'Update from web UI'; const data = await api(`/api/git/${action}`, { method: 'POST', body: JSON.stringify(payload) }); const out = data.output || data.stdout || data.stderr || 'Done'; el.gitOutput.textContent = out; } catch (error) { const lines = []; lines.push(error.message); if (error.stdout) lines.push('\nSTDOUT:\n' + error.stdout.trim()); if (error.stderr) lines.push('\nSTDERR:\n' + error.stderr.trim()); el.gitOutput.textContent = lines.join('\n'); } el.gitButtons.forEach((b) => b.disabled = false); }); }); if (el.diagnosticsButton) { el.diagnosticsButton.addEventListener('click', async () => { el.gitOutput.textContent = 'Running diagnostics...'; try { const data = await api('/api/diagnostics'); const out = `Version:\n${data.version || ''}\n\nModels Output:\n${data.modelsOutput || ''}`; el.gitOutput.textContent = out; } catch (error) { el.gitOutput.textContent = `Diagnostics failed: ${error.message}`; } }); } el.modelSelect.addEventListener('change', async () => { const selected = el.modelSelect.value; if (selected === 'custom') { el.customModelLabel.style.display = 'inline-flex'; el.customModelInput.focus(); return; } el.customModelLabel.style.display = 'none'; // If a session is selected, update the session's active model on the server if (state.currentSessionId) { try { await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ model: selected }) }); await refreshCurrentSession(); } catch (e) { setStatus(`Failed to update model: ${e.message}`); } } }); el.customModelInput.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { const txt = el.customModelInput.value.trim(); if (!txt) return setStatus('Please enter a custom model name'); if (state.currentSessionId) { try { await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ model: txt }) }); await refreshCurrentSession(); } catch (err) { setStatus(`Failed to update custom model: ${err.message}`); } } } }); } // Handle page visibility changes to maintain polling document.addEventListener('visibilitychange', () => { const isVisible = document.visibilityState === 'visible'; if (isVisible) { // User came back to the page, refresh immediately console.log('Page became visible, refreshing...'); refreshCurrentSession().catch(err => console.error('Refresh failed', err)); // Ensure polling interval is set if (!state.pollingTimer) { setPollingInterval(2500); } } else { // User left the page, but keep polling in the background at a slower rate console.log('Page became hidden, maintaining background polling...'); if (state.pollingTimer) { setPollingInterval(5000); // Slower polling in background } } }); // Handle page unload gracefully window.addEventListener('beforeunload', (e) => { // Check if there are running processes const running = state.sessions.flatMap(s => s.messages || []).filter(m => m.status === 'running' || m.status === 'queued'); if (running.length > 0) { console.log('Page unloading with running processes. They will continue on the server.'); // Don't prevent unload, just log it } }); // When user comes back to the page after a long time, ensure we reconnect to running processes window.addEventListener('focus', () => { console.log('Window focused, checking for running processes to reconnect...'); if (state.currentSessionId) { refreshCurrentSession().catch(err => console.error('Refresh on focus failed', err)); } }); (async function init() { populateCliSelect(); hookEvents(); // Check opencode status on startup checkOpencodeStatus(); await loadModels(state.currentCli); await loadSessions(); if (!state.sessions.length) await createSession(); // Periodically check opencode status setInterval(checkOpencodeStatus, 30000); // Keep polling going even in background (for running processes) // Start with reasonable interval that will be adjusted by refreshCurrentSession setPollingInterval(5000); })();