Files

1555 lines
57 KiB
JavaScript

const state = {
sessions: [],
currentSessionId: null,
models: [],
pollingTimer: null,
cliOptions: ['opencode'],
currentCli: 'opencode',
activeStreams: new Map(), // Track active SSE connections
opencodeStatus: null,
userId: null,
accountPlan: 'hobby',
usageSummary: null,
};
const TOKENS_TO_WORD_RATIO = 0.75;
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'),
modelIcon: document.getElementById('model-icon'),
customModelLabel: document.getElementById('custom-model-label'),
customModelInput: document.getElementById('custom-model-input'),
newChat: document.getElementById('new-chat'),
messageInput: document.getElementById('message-input'),
uploadMediaBtn: document.getElementById('upload-media-btn'),
uploadMediaInput: document.getElementById('upload-media-input'),
attachmentPreview: document.getElementById('attachment-preview'),
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'),
};
console.log('DOM elements initialized:', {
uploadMediaBtn: el.uploadMediaBtn,
uploadMediaInput: el.uploadMediaInput,
messageInput: el.messageInput
});
const pendingAttachments = [];
function isPaidPlanClient() {
const plan = (state.accountPlan || '').toLowerCase();
return plan === 'business' || plan === 'enterprise';
}
function isEnterprisePlan() {
const plan = (state.accountPlan || '').toLowerCase();
return plan === 'enterprise';
}
function bytesToFriendly(bytes) {
const n = Number(bytes || 0);
if (!Number.isFinite(n) || n <= 0) return '0 B';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function renderAttachmentPreview() {
if (!el.attachmentPreview) return;
if (!pendingAttachments.length) {
el.attachmentPreview.style.display = 'none';
el.attachmentPreview.innerHTML = '';
return;
}
el.attachmentPreview.style.display = 'flex';
el.attachmentPreview.innerHTML = '';
pendingAttachments.forEach((att, idx) => {
const chip = document.createElement('div');
chip.className = 'attachment-chip';
const img = document.createElement('img');
img.className = 'attachment-thumb';
img.alt = att.name || 'image';
img.src = att.previewUrl || '';
const meta = document.createElement('div');
meta.className = 'attachment-meta';
const name = document.createElement('div');
name.className = 'name';
name.textContent = att.name || 'image';
const size = document.createElement('div');
size.className = 'size';
size.textContent = `${att.type || 'image'}${bytesToFriendly(att.size || 0)}`;
meta.appendChild(name);
meta.appendChild(size);
const remove = document.createElement('button');
remove.className = 'attachment-remove';
remove.type = 'button';
remove.textContent = 'Remove';
remove.onclick = () => {
try {
const removed = pendingAttachments.splice(idx, 1);
if (removed[0] && removed[0].previewUrl && removed[0].previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(removed[0].previewUrl);
}
} catch (_) { }
renderAttachmentPreview();
};
chip.appendChild(img);
chip.appendChild(meta);
chip.appendChild(remove);
el.attachmentPreview.appendChild(chip);
});
}
async function fileToCompressedWebpAttachment(file) {
// Best-effort client-side compression to reduce JSON payload sizes.
// Server will also compress on write.
const maxDim = 1600;
const quality = 0.8;
const mime = (file && file.type) ? file.type : 'application/octet-stream';
if (!file || !mime.startsWith('image/')) throw new Error('Only images are supported');
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch (_) {
// Fallback: no bitmap support
bitmap = null;
}
if (!bitmap) {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read image'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
const base64 = dataUrl.split(',')[1] || '';
return { name: file.name || 'image', type: mime, data: base64, size: Math.floor((base64.length * 3) / 4), previewUrl: dataUrl };
}
const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { alpha: false });
ctx.drawImage(bitmap, 0, 0, width, height);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
const outBlob = blob || file;
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read compressed image'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(outBlob);
});
const base64 = dataUrl.split(',')[1] || '';
const previewUrl = URL.createObjectURL(outBlob);
return { name: file.name || 'image', type: 'image/webp', data: base64, size: outBlob.size || Math.floor((base64.length * 3) / 4), previewUrl };
}
function syncUploadButtonState() {
if (!el.uploadMediaBtn) return;
const allowed = isPaidPlanClient();
el.uploadMediaBtn.style.display = allowed ? 'flex' : 'none';
el.uploadMediaBtn.title = 'Attach images';
}
function cyrb53(str, seed = 0) {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
function computeAccountId(email) {
const normalized = (email || '').trim().toLowerCase();
if (!normalized) return '';
const hash = cyrb53(normalized);
return `acct-${hash.toString(16)}`;
}
function getDeviceUserId() {
try {
const existing = localStorage.getItem('shopify_ai_user_id');
if (existing) return existing;
const uuidPart = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 12);
const generated = `user-${uuidPart}`;
localStorage.setItem('shopify_ai_user_id', generated);
return generated;
} catch (_) {
const fallbackPart = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return `user-${fallbackPart}`;
}
}
function resolveUserId() {
try {
// Check both shopify and wordpress keys for compatibility
const keys = ['shopify_ai_user', 'wordpress_plugin_ai_user'];
for (const key of keys) {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && parsed.email) {
const accountId = parsed.accountId || computeAccountId(parsed.email);
if (accountId && (!parsed.accountId || parsed.accountId !== accountId)) {
try { localStorage.setItem(key, JSON.stringify({ ...parsed, accountId })); } catch (_) { }
}
return accountId;
}
}
}
} catch (_) { /* ignore */ }
return '';
}
state.userId = resolveUserId();
if (!state.userId) {
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?next=${next}`;
}
try {
document.cookie = `chat_user=${encodeURIComponent(state.userId)}; path=/; SameSite=Lax`;
} catch (_) { /* ignore */ }
async function checkAuthAndLoadUser() {
try {
// Check if we have a valid session with the server
const res = await fetch('/api/me');
if (res.ok) {
const data = await res.json();
if (data.ok && data.user) {
// Update local state with server user info
state.userId = data.user.id;
return data.user;
}
}
} catch (_) {
// Server auth not available, continue with device auth
}
return null;
}
function isFreePlan() {
return (state.accountPlan || '').toLowerCase() === 'hobby';
}
function tokensToFriendly(limit) {
const usage = Math.round(Math.max(0, limit || 0) * TOKENS_TO_WORD_RATIO);
if (!usage) return '—';
if (usage < 10_000) return `${usage.toLocaleString()} usage`;
return `${(usage / 1000).toFixed(1)}k usage`;
}
window.TOKENS_TO_WORD_RATIO = TOKENS_TO_WORD_RATIO;
window.tokensToFriendly = tokensToFriendly;
function ensureUsageFooter() {
let footer = document.getElementById('usage-footer');
if (!footer) {
footer = document.createElement('div');
footer.id = 'usage-footer';
footer.style.position = 'fixed';
footer.style.left = '0';
footer.style.right = '0';
footer.style.bottom = '0';
footer.style.zIndex = '9998';
footer.style.background = '#0f172a';
footer.style.color = '#f8fafc';
footer.style.padding = '10px 14px';
footer.style.boxShadow = '0 -8px 30px rgba(0,0,0,0.2)';
footer.style.fontSize = '13px';
footer.style.display = 'none';
document.body.appendChild(footer);
}
return footer;
}
window.ensureUsageFooter = window.ensureUsageFooter || ensureUsageFooter;
function renderUsageFooter(summary) {
const footer = window.ensureUsageFooter ? window.ensureUsageFooter() : ensureUsageFooter();
if (!summary) {
footer.style.display = 'none';
return;
}
// Handle both simple format (used by builder.js) and tiered format (used by app.js)
const isSimpleFormat = !summary.tiers;
const isTieredFormat = summary.tiers && Object.keys(summary.tiers).length > 0;
if (isSimpleFormat) {
footer.style.display = 'none';
return;
}
// Tiered format: { tiers: { free: {...}, plus: {...}, pro: {...} }, plan }
const applicable = Object.entries(summary.tiers).filter(([, data]) => (data.limit || 0) > 0);
if (!applicable.length) {
footer.style.display = 'none';
return;
}
const tierMeta = {
free: { label: '1x models', color: '#34d399', blurb: 'Standard burn (1x)', multiplier: 1 },
plus: { label: '2x models', color: '#38bdf8', blurb: 'Advanced burn (2x)', multiplier: 2 },
pro: { label: '3x models', color: '#22d3ee', blurb: 'Premium burn (3x)', multiplier: 3 },
};
const upgradeCta = summary.plan === 'enterprise' ? '' : `<a href="/upgrade" style="color:#a5f3fc; font-weight:700; text-decoration:underline;">Upgrade</a>`;
footer.innerHTML = `
<div style="display:flex; gap:10px; align-items:flex-start; flex-wrap:wrap; justify-content:space-between;">
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
${applicable.map(([tier, data]) => {
const remainingRatio = data.limit > 0 ? (data.remaining / data.limit) : 0;
const nearOut = remainingRatio <= 0.1;
const meta = tierMeta[tier] || tierMeta.free;
const burnMultiplier = data.multiplier || meta.multiplier;
return `
<div style="min-width:220px; background:#0b1221; border:1px solid rgba(255,255,255,0.08); border-radius:10px; padding:10px;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:6px;">
<strong>${meta.label}</strong>
<span style="color:${nearOut ? '#fcd34d' : '#cbd5e1'};">${data.used.toLocaleString()} / ${data.limit.toLocaleString()}${burnMultiplier}x</span>
</div>
<div style="height:8px; background:rgba(255,255,255,0.07); border-radius:999px; overflow:hidden; margin:8px 0 4px;">
<div style="width:${data.percent}%; height:100%; background:${meta.color};"></div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; color:#cbd5e1; gap:6px;">
<span>${tokensToFriendly(data.limit)} left at ${burnMultiplier}x: ${data.remaining.toLocaleString()}${nearOut ? ' • almost out' : ''}</span>
<button data-boost="${tier}" style="background:transparent; border:1px solid rgba(255,255,255,0.25); color:#f8fafc; border-radius:8px; padding:4px 8px; cursor:pointer;">Add boost</button>
</div>
<div style="color:#9ca3af; font-size:11px; margin-top:4px;">${meta.blurb}</div>
</div>
`;
}).join('')}
</div>
<div style="display:flex; align-items:center; gap:10px; color:#cbd5e1; flex:1; min-width:200px; justify-content:flex-end;">
<span>${upgradeCta ? 'Need more runway?' : 'You are on the top tier.'} ${upgradeCta || ''}</span>
</div>
</div>
`;
footer.style.display = 'block';
footer.querySelectorAll('[data-boost]').forEach((btn) => {
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = 'Adding...';
try {
await buyBoost(btn.dataset.boost);
} catch (err) {
alert(err.message || 'Unable to add boost');
} finally {
btn.disabled = false;
btn.textContent = 'Add boost';
}
};
});
}
window.renderUsageFooter = window.renderUsageFooter || renderUsageFooter;
async function loadUsageSummary() {
try {
const data = await api('/api/account/usage');
if (data?.summary) {
state.usageSummary = data.summary;
renderUsageFooter(state.usageSummary);
}
} catch (_) {
// Ignore silently
}
}
async function buyBoost(tier) {
const payload = tier ? { tier } : {};
const res = await api('/api/account/boost', { method: 'POST', body: JSON.stringify(payload) });
if (res?.summary) {
state.usageSummary = res.summary;
renderUsageFooter(state.usageSummary);
setStatus('Added extra AI energy to your account');
}
}
async function loadAccountPlan() {
try {
// Start fetching account info but avoid blocking UI indefinitely
const accountPromise = api('/api/account');
const timeoutMs = 3000;
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
const data = await Promise.race([accountPromise, timeoutPromise]);
if (!data) {
// Timed out — keep default plan visible and continue fetching in background
state.accountPlan = state.accountPlan || 'hobby';
accountPromise.then((fullData) => {
if (fullData?.account?.plan) state.accountPlan = fullData.account.plan;
if (fullData?.account?.tokenUsage) {
state.usageSummary = fullData.account.tokenUsage;
renderUsageFooter(state.usageSummary);
} else {
loadUsageSummary().catch(() => {});
}
}).catch((err) => {
setStatus('Account fetch failed');
console.warn('loadAccountPlan background fetch failed:', err);
});
return;
}
if (data?.account?.plan) {
state.accountPlan = data.account.plan;
if (data.account.tokenUsage) {
state.usageSummary = data.account.tokenUsage;
renderUsageFooter(state.usageSummary);
} else {
await loadUsageSummary();
}
} else {
if (window.location.pathname === "/apps" || window.location.pathname === "/apps/") {
window.location.href = "/select-plan";
}
}
} catch (err) {
// Ignore failures but log for debugging
console.warn('loadAccountPlan failed:', err);
}
}
let modelPreviewOverlay = null;
let modelPreviewAttached = false;
function showBlurredModelPreview() {
if (!isFreePlan()) return;
if (modelPreviewOverlay) {
try { modelPreviewOverlay.remove(); } catch (_) { }
}
modelPreviewOverlay = document.createElement('div');
modelPreviewOverlay.style.position = 'fixed';
modelPreviewOverlay.style.inset = '0';
modelPreviewOverlay.style.background = 'rgba(0,0,0,0.35)';
modelPreviewOverlay.style.display = 'flex';
modelPreviewOverlay.style.alignItems = 'center';
modelPreviewOverlay.style.justifyContent = 'center';
modelPreviewOverlay.style.zIndex = '9999';
const panel = document.createElement('div');
panel.style.background = '#fff';
panel.style.borderRadius = '12px';
panel.style.padding = '20px';
panel.style.maxWidth = '420px';
panel.style.width = '90%';
panel.style.boxShadow = '0 12px 40px rgba(0,0,0,0.16)';
const title = document.createElement('div');
title.style.fontWeight = '700';
title.style.marginBottom = '6px';
title.textContent = 'Models are auto-selected on the hobby plan';
const subtitle = document.createElement('div');
subtitle.style.color = '#6b7280';
subtitle.style.fontSize = '13px';
subtitle.style.marginBottom = '12px';
subtitle.textContent = 'Upgrade to choose a specific model. Here are the available options:';
const list = document.createElement('div');
list.style.display = 'grid';
list.style.gap = '8px';
list.style.filter = 'blur(4px)';
list.style.pointerEvents = 'none';
(state.models || []).forEach((m) => {
const row = document.createElement('div');
row.style.padding = '10px 12px';
row.style.border = '1px solid #e5e7eb';
row.style.borderRadius = '10px';
row.textContent = m.label || m.name || m.id || 'Model';
list.appendChild(row);
});
if (!list.children.length) {
const placeholder = document.createElement('div');
placeholder.style.padding = '10px 12px';
placeholder.style.border = '1px dashed #e5e7eb';
placeholder.style.borderRadius = '10px';
placeholder.textContent = 'Admin has not published models yet';
list.appendChild(placeholder);
}
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Close';
closeBtn.style.marginTop = '14px';
closeBtn.className = 'ghost';
closeBtn.addEventListener('click', () => {
if (modelPreviewOverlay) {
modelPreviewOverlay.remove();
modelPreviewOverlay = null;
}
});
panel.appendChild(title);
panel.appendChild(subtitle);
panel.appendChild(list);
panel.appendChild(closeBtn);
modelPreviewOverlay.appendChild(panel);
modelPreviewOverlay.addEventListener('click', (e) => {
if (e.target === modelPreviewOverlay) {
modelPreviewOverlay.remove();
modelPreviewOverlay = null;
}
});
document.body.appendChild(modelPreviewOverlay);
}
function applyPlanModelLock() {
if (!el.modelSelect) return;
syncUploadButtonState();
if (!isFreePlan()) {
const hasUsableOptions = Array.from(el.modelSelect.options || []).some((opt) => !opt.disabled);
if (!hasUsableOptions) return;
if (modelPreviewAttached) {
el.modelSelect.removeEventListener('click', showBlurredModelPreview);
modelPreviewAttached = false;
}
el.modelSelect.disabled = false;
el.modelSelect.dataset.locked = '';
renderModelIcon(el.modelSelect.value);
return;
}
el.modelSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = 'auto';
opt.textContent = 'Auto (admin managed)';
el.modelSelect.appendChild(opt);
el.modelSelect.value = 'auto';
el.modelSelect.dataset.locked = 'true';
el.modelSelect.disabled = false;
if (!modelPreviewAttached) {
el.modelSelect.addEventListener('click', showBlurredModelPreview);
modelPreviewAttached = true;
}
renderModelIcon(null);
}
function setStatus(msg) {
el.statusLine.textContent = msg || '';
}
async function api(path, options = {}) {
// Check for session token in localStorage
let sessionToken = null;
try {
const storedUser = localStorage.getItem('wordpress_plugin_ai_user');
if (storedUser) {
const parsed = JSON.parse(storedUser);
if (parsed && parsed.sessionToken) {
sessionToken = parsed.sessionToken;
}
}
} catch (_) { /* ignore */ }
const headers = {
'Content-Type': 'application/json',
...(sessionToken ? { 'Authorization': `Bearer ${sessionToken}` } : {}),
...(options.headers || {}),
};
// Fallback to old user ID header if no session token
if (!sessionToken && state.userId) {
headers['X-User-Id'] = state.userId;
}
const res = await fetch(path, {
headers,
...options,
});
// Handle authentication errors
if (res.status === 401) {
// Clear invalid session and redirect to login
try {
localStorage.removeItem('wordpress_plugin_ai_user');
} catch (_) { /* ignore */ }
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?next=${next}`;
return;
}
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (!res.ok) {
const err = new Error(json.error || res.statusText);
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 = '<div class="muted">No sessions yet.</div>';
return;
}
state.sessions.forEach((session) => {
const div = document.createElement('div');
div.className = `session-item ${session.id === state.currentSessionId ? 'active' : ''}`;
div.innerHTML = `
<div class="session-title">${session.title || 'Chat'}</div>
<div class="session-meta">
<span class="badge">${session.cli || 'opencode'}</span>
<span class="badge">${session.model}</span>
<span class="badge ${session.pending ? 'accent' : ''}">${session.pending || 0} queued</span>
</div>
<div class="session-delete">
<button title="Delete" class="delete-btn">🗑</button>
<div class="delete-menu" role="dialog" aria-label="Confirm delete chat">
<div class="title">Delete this chat?</div>
<div class="actions">
<button class="cancel-delete ghost">Cancel</button>
<button class="confirm-delete">Delete</button>
</div>
</div>
</div>
`;
div.addEventListener('click', () => selectSession(session.id));
// Stop clicks from the delete button bubbling up and opening the session
const deleteBtn = div.querySelector('.delete-btn');
const deleteMenu = div.querySelector('.delete-menu');
const cancelBtn = div.querySelector('.cancel-delete');
const confirmBtn = div.querySelector('.confirm-delete');
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle the inline menu
const isOpen = div.classList.toggle('show-delete-menu');
if (isOpen) {
// close any other menus
document.querySelectorAll('.session-item.show-delete-menu').forEach((item) => { if (item !== div) item.classList.remove('show-delete-menu'); });
// attach an outside click handler to close
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' });
// Remove the session from state and re-render
state.sessions = state.sessions.filter((s) => s.id !== session.id);
if (state.currentSessionId === session.id) {
// Pick another session or create new
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 = '<div class="muted empty-message">Send a message to start the conversation.</div>';
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 = `
<span>You</span>
<span class="badge">${msg.model || session.model}</span>
<span class="status-chip ${status}">${status}</span>
`;
const userBody = document.createElement('div');
userBody.className = 'body';
userBody.appendChild(renderContentWithTodos(msg.displayContent || 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 = `<span>${(session.cli || 'opencode').toUpperCase()}</span>`;
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);
}
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;
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;
}
function renderModelIcon(selectedValue) {
if (!el.modelIcon) return;
if (!selectedValue) { el.modelIcon.src = ''; el.modelIcon.style.display = 'none'; el.modelIcon.title = ''; return; }
const model = state.models.find((m) => (m.name || m.id || m) === selectedValue);
if (!model || !model.icon) { el.modelIcon.src = ''; el.modelIcon.style.display = 'none'; el.modelIcon.title = model ? (model.label || model.name || selectedValue) : ''; return; }
el.modelIcon.src = model.icon;
el.modelIcon.alt = model.label || model.name || selectedValue;
el.modelIcon.title = model.label || model.name || selectedValue;
el.modelIcon.style.display = 'inline-block';
}
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 = '';
if (!state.models.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No models configured (ask admin)';
opt.disabled = true;
opt.selected = true;
el.modelSelect.appendChild(opt);
el.modelSelect.disabled = true;
renderModelIcon(null);
setStatus('No models configured. Ask an admin to add models in the admin panel.');
return;
}
el.modelSelect.disabled = false;
state.models.forEach((m) => {
const option = document.createElement('option');
option.value = m.name || m.id || m;
const multiplierLabel = m.multiplier ? ` (${m.multiplier}x)` : '';
option.textContent = `${m.label || m.name || m.id || m}${multiplierLabel}`;
if (m.icon) option.dataset.icon = m.icon;
if (m.multiplier) option.dataset.multiplier = m.multiplier;
el.modelSelect.appendChild(option);
});
if (el.modelSelect.value === '' && state.models.length > 0) {
el.modelSelect.value = state.models[0].name || state.models[0].id || 'default';
}
renderModelIcon(el.modelSelect.value);
applyPlanModelLock();
} catch (error) {
setStatus(`Model load failed: ${error.message}`);
state.models = [];
el.modelSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No models available';
opt.disabled = true;
opt.selected = true;
el.modelSelect.appendChild(opt);
el.modelSelect.disabled = true;
renderModelIcon(null);
applyPlanModelLock();
}
}
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) {
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);
}
renderSessions();
await refreshCurrentSession();
setPollingInterval(2500);
}
// 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 === 'server-restart') {
message.status = 'queued';
setStatus('Server restarting, your session will be restored...');
eventSource.close();
state.activeStreams.delete(messageId);
renderMessages(session);
return;
} else 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 === 'health') {
// Sync status from server heartbeat
if (data.status && message.status !== data.status) {
console.log('Syncing message status from health event', {
messageId,
oldStatus: message.status,
newStatus: data.status
});
message.status = data.status;
renderMessages(session);
}
} 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();
// Update usage summary to show token count
loadUsageSummary().catch(() => {});
} 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();
// Update usage summary even on error
loadUsageSummary().catch(() => {});
}
} catch (err) {
console.error('Failed to parse SSE message', err);
}
};
eventSource.onerror = (err) => {
console.error('SSE error', err);
eventSource.close();
state.activeStreams.delete(messageId);
// Check if server is restarting by attempting to reconnect
const session = state.sessions.find(s => s.id === sessionId);
const message = session?.messages.find(m => m.id === messageId);
if (message && message.status === 'queued') {
// Server restart was signaled, poll for reconnection
let reconnectAttempts = 0;
const maxReconnectAttempts = 30; // 30 seconds max
const reconnectInterval = setInterval(() => {
reconnectAttempts++;
refreshCurrentSession().then(() => {
// Successfully reconnected and refreshed
const updatedSession = state.sessions.find(s => s.id === sessionId);
const updatedMessage = updatedSession?.messages.find(m => m.id === messageId);
if (updatedMessage && updatedMessage.status !== 'queued') {
clearInterval(reconnectInterval);
setStatus('Reconnected to server');
}
}).catch(() => {
// Server still down
if (reconnectAttempts >= maxReconnectAttempts) {
clearInterval(reconnectInterval);
if (message) {
message.status = 'error';
message.error = 'Server restart took too long. Please try again.';
renderMessages(session);
}
setStatus('Server reconnection failed');
}
});
}, 1000);
} else {
// 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}`);
// Preserve optimistic "temp-" messages that may have been added locally
const old = state.sessions.find((s) => s.id === session.id);
const tempMsgs = (old && Array.isArray(old.messages)) ? old.messages.filter(m => String(m.id).startsWith('temp-')) : [];
if (tempMsgs.length) {
session.messages = session.messages || [];
const existingIds = new Set((session.messages || []).map((m) => m.id));
tempMsgs.forEach((m) => {
if (!existingIds.has(m.id)) session.messages.push(m);
});
// De-duplicate if server returned a real message with same content
const realContents = new Set((session.messages || []).filter(m => !String(m.id).startsWith('temp-')).map(m => (m.displayContent || m.content || '').trim()));
session.messages = (session.messages || []).filter(m => {
if (String(m.id).startsWith('temp-')) {
return !realContents.has((m.displayContent || m.content || '').trim());
}
return true;
});
session.messages.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
}
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;
if (!model) {
setStatus('No models available. Ask an admin to add models.');
return;
}
let session;
try {
const payload = { model, cli };
// When creating a new chat within an existing app, preserve the app title
if (options.appId && options.reuseAppId) {
const currentSession = state.sessions.find(s => s.id === state.currentSessionId);
if (currentSession && currentSession.title) {
payload.title = currentSession.title;
}
}
if (options.appId) {
payload.appId = options.appId;
payload.reuseAppId = true;
}
const data = await api('/api/sessions', {
method: 'POST',
body: JSON.stringify(payload),
});
session = data.session;
} catch (err) {
setStatus(err.message || 'Unable to create app');
throw err;
}
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 && !pendingAttachments.length) return;
if (!state.currentSessionId) {
try {
await createSession();
} catch (_) {
return;
}
}
const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode';
state.currentCli = cli;
const model = el.modelSelect.value;
if (!model) {
setStatus('Select a model configured by your admin');
return;
}
el.sendBtn.disabled = true;
setStatus('Sending...');
try {
const attachments = pendingAttachments.map((a) => ({ name: a.name, type: a.type, data: a.data }));
const payload = { content, displayContent: content, model, cli, attachments: attachments.length ? attachments : undefined };
// Preserve opencodeSessionId to continue in the same session
const currentSession = state.sessions.find(s => s.id === state.currentSessionId);
if (currentSession && currentSession.opencodeSessionId) {
payload.opencodeSessionId = currentSession.opencodeSessionId;
console.log('[APP] Preserving opencodeSessionId:', currentSession.opencodeSessionId);
}
const response = await api(`/api/sessions/${state.currentSessionId}/messages`, {
method: 'POST',
body: JSON.stringify(payload),
});
el.messageInput.value = '';
// Clear pending attachments after send
while (pendingAttachments.length) {
const removed = pendingAttachments.pop();
if (removed && removed.previewUrl && removed.previewUrl.startsWith('blob:')) {
try { URL.revokeObjectURL(removed.previewUrl); } catch (_) { }
}
}
renderAttachmentPreview();
// Start streaming immediately for the new message
if (response.message && response.message.id) {
streamMessage(state.currentSessionId, response.message.id);
}
await refreshCurrentSession();
await loadUsageSummary();
} catch (error) {
setStatus(error.message);
} finally {
el.sendBtn.disabled = false;
}
}
function hookEvents() {
el.newChat.addEventListener('click', async () => {
const currentSession = state.sessions.find(s => s.id === state.currentSessionId);
const currentAppId = currentSession?.appId;
if (currentAppId) {
await createSession({ appId: currentAppId, reuseAppId: true });
} else {
await createSession({});
}
});
el.sendBtn.addEventListener('click', sendMessage);
if (el.uploadMediaBtn && el.uploadMediaInput) {
console.log('Upload media elements found, attaching event listeners');
el.uploadMediaBtn.addEventListener('click', (e) => {
console.log('Upload media button clicked, isPaidPlanClient:', isPaidPlanClient());
e.preventDefault();
e.stopPropagation();
// Check if user is on free plan
if (!isPaidPlanClient()) {
// Show upgrade modal instead of redirecting to pricing
if (typeof window.showUpgradeModal === 'function' && !isEnterprisePlan()) {
console.log('Showing upgrade modal');
window.showUpgradeModal();
} else if (isEnterprisePlan()) {
setStatus('You are already on the Enterprise plan with full access.');
} else {
window.location.href = '/upgrade';
}
return;
}
// Check if model supports media
if (!currentModelSupportsMedia()) {
setStatus('This model does not support image uploads. Please select a different model that supports media.');
return;
}
// For paid users with media-supporting models, trigger file input click
console.log('Triggering file input click');
el.uploadMediaInput.value = '';
el.uploadMediaInput.click();
});
el.uploadMediaInput.addEventListener('change', async () => {
console.log('File input changed, files:', el.uploadMediaInput.files);
const files = el.uploadMediaInput.files ? Array.from(el.uploadMediaInput.files) : [];
// Reset input immediately to allow same file selection again
el.uploadMediaInput.value = '';
if (!files.length) return;
if (!isPaidPlanClient()) {
// Show upgrade modal instead of just showing status
if (typeof window.showUpgradeModal === 'function' && !isEnterprisePlan()) {
window.showUpgradeModal();
} else if (isEnterprisePlan()) {
setStatus('Upload media is available on your Enterprise plan.');
} else {
setStatus('Upload media is available on Professional/Enterprise plans');
}
return;
}
setStatus('Preparing images...');
el.sendBtn.disabled = true;
try {
for (const file of files.slice(0, 6)) {
if (!file || !(file.type || '').startsWith('image/')) continue;
const att = await fileToCompressedWebpAttachment(file);
pendingAttachments.push(att);
}
renderAttachmentPreview();
// Show visual feedback
const attachedCount = pendingAttachments.length;
setStatus(`${attachedCount} image${attachedCount > 1 ? 's' : ''} attached successfully!`);
// Briefly highlight the upload button to show feedback
el.uploadMediaBtn.style.color = '#4ade80';
el.uploadMediaBtn.style.fontWeight = 'bold';
setTimeout(() => {
el.uploadMediaBtn.style.color = '';
el.uploadMediaBtn.style.fontWeight = '';
}, 2000);
} catch (err) {
setStatus(err.message || 'Failed to attach image');
} finally {
el.sendBtn.disabled = false;
}
});
} else {
console.log('Upload media elements NOT found. el.uploadMediaBtn:', el.uploadMediaBtn, 'el.uploadMediaInput:', el.uploadMediaInput);
}
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 images
el.messageInput.addEventListener('paste', async (e) => {
try {
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;
e.preventDefault();
if (!isPaidPlanClient()) {
setStatus('Paste image is available on Business/Enterprise plans');
return;
}
const blob = imageItem.getAsFile();
if (!blob) return;
const att = await fileToCompressedWebpAttachment(blob);
pendingAttachments.push(att);
renderAttachmentPreview();
setStatus(`${pendingAttachments.length} image(s) attached`);
} catch (err) { console.error('Paste handler error', err); }
});
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 git buttons while running to prevent duplicates
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) {
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 git buttons regardless of outcome
el.gitButtons.forEach((b) => b.disabled = false);
});
});
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');
}
// Re-enable git buttons
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;
renderModelIcon(selected);
if (isFreePlan()) {
showBlurredModelPreview();
setStatus('Model selection is automatic on the hobby plan');
return;
}
if (!selected) return;
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}`); }
}
});
// Upgrade header button functionality (for builder page)
const upgradeHeaderBtn = document.getElementById('upgrade-header-btn');
if (upgradeHeaderBtn) {
upgradeHeaderBtn.addEventListener('click', () => {
if (typeof window.showUpgradeModal === 'function' && !isEnterprisePlan()) {
window.showUpgradeModal();
} else if (isEnterprisePlan()) {
alert('You are already on the Enterprise plan with full access.');
} else {
window.location.href = '/upgrade';
}
});
}
}
// 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 loadAccountPlan();
await loadModels(state.currentCli);
await loadSessions();
if (!state.sessions.length) {
await createSession();
}
// Periodically check opencode status (reduced frequency to reduce CPU usage)
setInterval(checkOpencodeStatus, 300000);
// Keep polling going even in background (for running processes)
// Start with reasonable interval that will be adjusted by refreshCurrentSession
setPollingInterval(5000);
})();