// 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);
})();