// builder.js /* global setStatus */ // Builder state management - must be defined before any code uses it const BUILDER_STATE_KEY = 'builder_state'; function loadBuilderState() { try { const saved = localStorage.getItem(BUILDER_STATE_KEY); if (saved) { return JSON.parse(saved); } } catch (e) { console.warn('Failed to load builder state:', e); } return null; } function saveBuilderState(state) { try { localStorage.setItem(BUILDER_STATE_KEY, JSON.stringify(state)); } catch (e) { console.warn('Failed to save builder state:', e); } } // Initialize builderState with persisted data const savedState = loadBuilderState(); const builderState = savedState || { mode: 'plan', planApproved: false, lastUserRequest: '', lastPlanText: '', pluginPrompt: '', subsequentPrompt: '', externalTestingEnabled: false }; // Auto-save builderState changes to localStorage const builderStateProxy = new Proxy(builderState, { set(target, prop, value) { target[prop] = value; saveBuilderState(target); return true; } }); // Replace builderState reference in global scope with proxy window.builderState = builderStateProxy; // Function to clear builder state (for new sessions, logout, etc.) window.clearBuilderState = function() { const preservedPluginPrompt = builderState.pluginPrompt || ''; const preservedSubsequentPrompt = builderState.subsequentPrompt || ''; const resetState = { mode: 'plan', planApproved: false, lastUserRequest: '', lastPlanText: '', pluginPrompt: preservedPluginPrompt, subsequentPrompt: preservedSubsequentPrompt }; Object.assign(builderState, resetState); saveBuilderState(builderState); console.log('[BUILDER] Builder state cleared'); }; // Update build mode UI - switch to OpenCode immediately for uploaded apps function updateBuildModeUI(modeOverride) { const mode = modeOverride || (builderState && builderState.mode) || 'plan'; const modeIcon = document.getElementById('mode-icon'); const modeText = document.getElementById('mode-text'); const modeDescription = document.getElementById('mode-description'); if (modeIcon && modeText && modeDescription) { if (mode === 'build') { modeIcon.innerHTML = ` `; modeText.textContent = 'OpenCode'; modeDescription.textContent = 'Imported apps open directly in coding mode'; } else { modeIcon.innerHTML = ` `; modeText.textContent = 'Planning'; modeDescription.textContent = 'Discuss and refine your app plan'; } } // Hide provider box (model-select-wrap) for plan messages if (el.modelSelectWrap) { el.modelSelectWrap.style.display = mode === 'build' ? 'inline-flex' : 'none'; } if (typeof window.updateUsageProgressBar === 'function') { window.updateUsageProgressBar(); } } async function proceedWithBuild(planContent) { if (!planContent) return; pendingPlanContent = planContent; if (el.confirmBuildModal) { el.confirmBuildModal.style.display = 'flex'; } else { // Fallback to confirm if modal not found for some reason const confirmBuild = confirm("Are you sure you want to proceed with this plan? This will start the build process."); if (confirmBuild) executeBuild(planContent); } } async function executeBuild(planContent) { console.log('executeBuild called with planContent:', planContent ? planContent.substring(0, 100) + '...' : 'null'); // Ensure external testing is still allowed if enabled if (builderState.externalTestingEnabled) { await loadUsageSummary(); const summary = state.usageSummary?.externalTesting || null; const limit = summary ? summary.limit : null; const used = summary ? summary.used : 0; if (Number.isFinite(limit) && used >= limit) { // show modal and abort const modal = document.getElementById('external-testing-limit-modal'); if (modal) modal.style.display = 'flex'; setStatus('External testing limit reached. Disable external testing or upgrade your plan.'); return; } } builderState.mode = 'build'; builderState.planApproved = true; updateBuildModeUI(); // Get the user-selected model (preserved across model list refreshes) let selectedModel = state.selectedModelId || (el.modelSelect && el.modelSelect.value); if (!selectedModel) { setStatus('Select a model configured by your admin'); alert('Please select a model before proceeding with the build.'); return; } console.log('Selected model for build:', selectedModel); console.log('Current session ID:', state.currentSessionId); // Construct the build prompt - replace {{USER_REQUEST}} with actual user request let promptTemplate = builderState.pluginPrompt || ''; const userRequest = builderState.lastUserRequest || ''; const session = state.sessions.find(s => s.id === state.currentSessionId); const pluginSlug = (session && session.pluginSlug) || 'plugin-name'; const pluginName = (session && session.pluginName) || `Plugin Compass ${(session && session.title) || 'Plugin'}`; promptTemplate = promptTemplate.replace('{{USER_REQUEST}}', userRequest) .replace(/{{PLUGIN_SLUG}}/g, pluginSlug) .replace(/{{PLUGIN_NAME}}/g, pluginName); const buildPrompt = promptTemplate + '\n\nAPPROVED PLAN:\n' + planContent; // Send to opencode try { state.currentCli = 'opencode'; if (el.cliSelect) el.cliSelect.value = 'opencode'; // Show loading indicator with "building" text showLoadingIndicator('building'); console.log('Sending build message to opencode...'); console.log('Current session opencodeSessionId:', session?.opencodeSessionId); const buildPayload = { content: buildPrompt, displayContent: "**Starting Build Process...**", model: selectedModel, cli: 'opencode', isProceedWithBuild: true, planContent: planContent, externalTestingEnabled: !!builderState.externalTestingEnabled }; // Preserve opencodeSessionId for session continuity if (session && session.opencodeSessionId) { buildPayload.opencodeSessionId = session.opencodeSessionId; console.log('[BUILD] Preserving opencodeSessionId:', session.opencodeSessionId); } const response = await api(`/api/sessions/${state.currentSessionId}/messages`, { method: 'POST', body: JSON.stringify(buildPayload), }); console.log('Build message response:', response); // Start streaming if message was created if (response.message && response.message.id) { console.log('Starting stream for message:', response.message.id); streamMessage(state.currentSessionId, response.message.id); } else { console.warn('No message ID in response:', response); } await refreshCurrentSession(); await loadUsageSummary(); console.log('Build process initiated successfully'); } catch (e) { console.error('Failed to start build:', e); const msg = (e && e.message) ? e.message : 'Unknown error'; if (msg && msg.toLowerCase().includes('external wp cli testing')) { // Show upgrade modal const modal = document.getElementById('external-testing-limit-modal'); if (modal) modal.style.display = 'flex'; setStatus(msg, true); } else { alert('Failed to start build: ' + msg); } builderState.mode = 'plan'; // Revert updateBuildModeUI(); hideLoadingIndicator(); // Update usage on error loadUsageSummary().catch(err => { console.warn('[USAGE] Usage update after build error failed:', err.message); }); } } // Redo a proceed-with-build message - skips confirmation, rebuilds prompt properly async function redoProceedWithBuild(planContent, model) { if (!planContent) { setStatus('Cannot redo: no plan content available'); return; } setStatus('Redoing build process...'); builderState.mode = 'build'; builderState.planApproved = true; updateBuildModeUI(); // Use provided model or get from dropdown let selectedModel = model; if (!selectedModel || selectedModel === 'default') { selectedModel = state.selectedModelId || (el.modelSelect && el.modelSelect.value); } if (!selectedModel) { setStatus('Select a model configured by your admin'); return; } // Construct the build prompt - replace {{USER_REQUEST}} with actual user request let promptTemplate = builderState.pluginPrompt || ''; const userRequest = builderState.lastUserRequest || ''; const session = state.sessions.find(s => s.id === state.currentSessionId); const pluginSlug = (session && session.pluginSlug) || 'plugin-name'; const pluginName = (session && session.pluginName) || `Plugin Compass ${(session && session.title) || 'Plugin'}`; promptTemplate = promptTemplate.replace('{{USER_REQUEST}}', userRequest) .replace(/{{PLUGIN_SLUG}}/g, pluginSlug) .replace(/{{PLUGIN_NAME}}/g, pluginName); const buildPrompt = promptTemplate + '\n\nAPPROVED PLAN:\n' + planContent; try { state.currentCli = 'opencode'; if (el.cliSelect) el.cliSelect.value = 'opencode'; // Show loading indicator with "building" text showLoadingIndicator('building'); const buildPayload = { content: buildPrompt, displayContent: "**Retrying Build Process...**", model: selectedModel, cli: 'opencode', isProceedWithBuild: true, planContent: planContent }; // Preserve opencodeSessionId for session continuity if (session && session.opencodeSessionId) { buildPayload.opencodeSessionId = session.opencodeSessionId; console.log('[REDO] Preserving opencodeSessionId:', session.opencodeSessionId); } const response = await api(`/api/sessions/${state.currentSessionId}/messages`, { method: 'POST', body: JSON.stringify(buildPayload), }); // Start streaming for the new message if (response.message && response.message.id) { streamMessage(state.currentSessionId, response.message.id); } await refreshCurrentSession(); await loadUsageSummary(); setStatus('Build process restarted'); } catch (e) { setStatus('Failed to redo build: ' + e.message); builderState.mode = 'plan'; updateBuildModeUI(); hideLoadingIndicator(); // Update usage on error loadUsageSummary().catch(err => { console.warn('[USAGE] Usage update after redo error failed:', err.message); }); } } // Redo a message - handles OpenRouter, Opencode, and Proceed-with-Build cases async function redoMessage(msg, session) { const isOpencodeMessage = msg.cli === 'opencode'; const isProceedWithBuild = msg.isProceedWithBuild || (msg.displayContent && msg.displayContent.includes('Starting Build Process')); const isOpenRouterMessage = !isOpencodeMessage || msg.phase === 'plan'; setStatus('Preparing to redo...'); // Case 1: Proceed with Build - undo first, then rebuild with plan content if (isProceedWithBuild) { // First, send undo to revert any file changes try { await api(`/api/sessions/${session.id}/messages/${msg.id}/undo`, { method: 'POST', }); setStatus('Undo complete, rebuilding...'); } catch (err) { console.warn('Undo failed, continuing with redo:', err.message); } // Get the plan content - either stored on the message or try to extract from content let planContent = msg.planContent; if (!planContent && msg.content) { // Try to extract plan from the content (after "APPROVED PLAN:" marker) const planMatch = msg.content.match(/APPROVED PLAN:\s*([\s\S]*)/); if (planMatch) { planContent = planMatch[1].trim(); } } if (planContent) { await redoProceedWithBuild(planContent, msg.model); } else { setStatus('Cannot redo: plan content not available'); } return; } // Case 2: Regular Opencode message (not proceed-with-build) - use /redo command in the same session if (isOpencodeMessage) { setStatus('Redoing with OpenCode...'); // Show loading indicator with "building" text showLoadingIndicator('building'); try { // Use the /redo endpoint to redo in the same OpenCode session await api(`/api/sessions/${session.id}/messages/${msg.id}/redo`, { method: 'POST', }); setStatus('Redo command sent, waiting for response...'); // Refresh the session to get the updated state await refreshCurrentSession(); await loadUsageSummary(); setStatus('Redo complete'); } catch (err) { setStatus('Redo failed: ' + err.message); console.error('Redo failed:', err); hideLoadingIndicator(); // Update usage on error loadUsageSummary().catch(loadErr => { console.warn('[USAGE] Usage update after redo error failed:', loadErr.message); }); } return; } // Case 3: OpenRouter (plan) message - resend to /api/plan if (isOpenRouterMessage) { setStatus('Resending to OpenRouter...'); // Find the original user message (the content before it was processed) const userContent = msg.displayContent || msg.content || builderState.lastUserRequest || ''; await sendPlanMessage(userContent); setStatus('Plan message resent'); return; } setStatus('Unable to redo this message type'); } const state = { sessions: [], sessionsLoaded: false, currentSessionId: null, models: [], modelsSignature: null, selectedModelId: null, pollingTimer: null, cliOptions: ['opencode'], currentCli: 'opencode', activeStreams: new Map(), // Track active SSE connections opencodeStatus: null, userId: null, accountPlan: 'hobby', isAdmin: false, usageSummary: null, todos: [], // Current todos from OpenCode currentMessageId: null, // Track which message we're displaying todos for isSending: false, // Track if a message is currently being sent currentSendingMessageId: null, // Track the ID of the message being sent for cancellation }; // Expose state for builder.html window.state = state; const TOKENS_TO_WORD_RATIO = window.TOKENS_TO_WORD_RATIO || 0.75; // Message input caching constants const MESSAGE_INPUT_CACHE_KEY = 'builder_message_input'; const MESSAGE_INPUT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // Cache for 24 hours // Save message input to localStorage function cacheMessageInput(content) { if (!el?.messageInput) return; try { const data = { content: content || '', timestamp: Date.now() }; localStorage.setItem(MESSAGE_INPUT_CACHE_KEY, JSON.stringify(data)); } catch (e) { console.warn('Failed to cache message input:', e); } } // Restore message input from localStorage function restoreMessageInput() { if (!el?.messageInput) return false; try { const raw = localStorage.getItem(MESSAGE_INPUT_CACHE_KEY); if (!raw) return false; const data = JSON.parse(raw); if (!data || !data.content) return false; // Check if cache is expired (older than 24 hours) if (Date.now() - data.timestamp > MESSAGE_INPUT_CACHE_MAX_AGE_MS) { localStorage.removeItem(MESSAGE_INPUT_CACHE_KEY); return false; } // Restore the content el.messageInput.value = data.content; // Adjust textarea height for restored content el.messageInput.style.height = 'auto'; el.messageInput.style.height = (el.messageInput.scrollHeight) + 'px'; console.log('[BUILDER-CACHE] Restored cached message input:', { contentLength: data.content.length, age: Date.now() - data.timestamp }); return true; } catch (e) { console.warn('Failed to restore message input:', e); return false; } } // Clear cached message input function clearMessageInputCache() { try { localStorage.removeItem(MESSAGE_INPUT_CACHE_KEY); console.log('[BUILDER-CACHE] Cleared message input cache'); } catch (e) { console.warn('Failed to clear message input cache:', e); } } function syncAdminVisibility() { const adminOnly = document.querySelectorAll('[data-admin-only]'); adminOnly.forEach((node) => { node.style.display = state.isAdmin ? '' : 'none'; }); } async function detectAdminSession() { try { // Ensure credentials sent so admin session cookie is included const res = await fetch('/api/admin/me', { credentials: 'same-origin' }); state.isAdmin = res.ok; } catch (_) { state.isAdmin = false; } syncAdminVisibility(); } function isFreePlan() { return (state.accountPlan || '').toLowerCase() === 'hobby'; } function formatPlanLabel(plan) { const normalized = (plan || 'hobby').toLowerCase(); const pretty = normalized.charAt(0).toUpperCase() + normalized.slice(1); return `${pretty} plan`; } // Expose for builder.html window.formatPlanLabel = formatPlanLabel; function applyAccountPlan(plan) { const normalized = (plan || 'hobby').toLowerCase(); state.accountPlan = normalized; const badge = document.getElementById('user-badge'); if (badge) badge.dataset.plan = normalized; const planEl = document.getElementById('user-plan'); if (planEl) planEl.textContent = formatPlanLabel(normalized); // Hide upgrade header button for enterprise users const upgradeHeaderBtn = document.getElementById('upgrade-header-btn'); if (upgradeHeaderBtn) { upgradeHeaderBtn.style.display = normalized === 'enterprise' ? 'none' : 'flex'; } // Hide upgrade button in token limit modal for enterprise and professional users const tokenLimitUpgrade = document.getElementById('token-limit-upgrade'); if (tokenLimitUpgrade) { tokenLimitUpgrade.style.display = (normalized === 'enterprise' || normalized === 'professional') ? 'none' : 'flex'; } syncUploadButtonState(); applyPlanModelLock(); } // Expose for builder.html window.applyAccountPlan = applyAccountPlan; const tokensToFriendly = window.tokensToFriendly || function tokensToFriendlyLocal(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`; }; function updateUsageProgressBar(summary = state.usageSummary) { if (!el.usageMeterFill || !el.usageMeterTrack || !el.usageMeterPercent || !el.usageMeterTitle) { console.warn('[USAGE] Usage meter DOM elements not found'); return; } const used = summary?.used || 0; const limit = summary?.limit || 0; const remaining = summary?.remaining || (limit - used); const plan = summary?.plan || 'hobby'; const percent = limit > 0 ? Math.max(0, Math.min(100, parseFloat(((used / limit) * 100).toFixed(1)))) : null; const nearOut = percent !== null && percent >= 90; console.log('[USAGE] Updating progress bar:', { used, limit, remaining, plan, percent }); el.usageMeterTitle.textContent = 'Token Usage'; if (percent === null) { el.usageMeterPercent.textContent = '—'; el.usageMeterFill.style.width = '0%'; el.usageMeterTrack.setAttribute('aria-valuenow', '0'); el.usageMeterTrack.title = ''; el.usageMeterTrack.style.background = 'rgba(0, 128, 96, 0.12)'; return; } const percentText = `${percent}% used${nearOut ? ' • almost out' : ''}`; const remainingText = `${remaining.toLocaleString()} remaining`; el.usageMeterPercent.innerHTML = `${percentText}
${remainingText}`; el.usageMeterFill.style.width = `${percent}%`; if (nearOut) { el.usageMeterFill.style.background = 'linear-gradient(135deg, #fcd34d, #f59e0b)'; } else { el.usageMeterFill.style.background = 'linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark))'; } el.usageMeterTrack.setAttribute('aria-valuenow', String(percent)); el.usageMeterTrack.title = `${used.toLocaleString()} / ${limit.toLocaleString()} tokens`; const actionsDiv = el.usageMeterFill.closest('.usage-meter')?.querySelector('.usage-meter-actions'); const hintSpan = actionsDiv?.querySelector('.usage-hint'); if (hintSpan) { if (plan === 'enterprise') { hintSpan.textContent = 'You are on the top tier'; } else { hintSpan.textContent = 'Need more runway?'; } } // Hide upgrade link for enterprise users const upgradeLinks = actionsDiv?.querySelectorAll('a[href^="/upgrade"]'); upgradeLinks?.forEach(link => { link.style.display = plan === 'enterprise' ? 'none' : 'inline-flex'; }); console.log('[USAGE] Progress bar updated successfully'); } window.updateUsageProgressBar = updateUsageProgressBar; async function loadUsageSummary() { try { const data = await api(`/api/account/usage?_t=${Date.now()}`); console.log('[USAGE] API response:', data); if (data?.summary) { state.usageSummary = data.summary; console.log('[USAGE] Usage summary loaded:', { month: data.summary.month, plan: data.summary.plan, used: data.summary.used, limit: data.summary.limit, percent: data.summary.percent }); console.log('[USAGE] DOM elements found:', { usageMeterTitle: !!el.usageMeterTitle, usageMeterPercent: !!el.usageMeterPercent, usageMeterFill: !!el.usageMeterFill, usageMeterTrack: !!el.usageMeterTrack }); updateUsageProgressBar(state.usageSummary); if (typeof window.updateExternalTestingUI === 'function') { try { updateExternalTestingUI(); } catch (e) { console.warn('external testing UI update failed', e); } } if (typeof window.checkTokenLimitAndShowModal === 'function') { setTimeout(() => window.checkTokenLimitAndShowModal(), 500); } } else { console.warn('[USAGE] No summary in usage response:', data); } } catch (err) { setAdminStatus(`usage fetch failed: ${err?.message || String(err)}`); console.error('[USAGE] Failed to fetch usage summary:', err); } } // Expose for builder.html window.loadUsageSummary = loadUsageSummary; // --- External testing UI helpers --- function updateExternalTestingUI() { const elToggle = document.getElementById('external-testing-toggle'); const elUsage = document.getElementById('external-testing-usage'); const infoBtn = document.getElementById('external-testing-info'); if (!elToggle || !elUsage) return; const et = state.usageSummary?.externalTesting || null; if (!et) { elUsage.textContent = 'Not configured'; elToggle.disabled = true; elToggle.checked = false; builderState.externalTestingEnabled = false; saveBuilderState(builderState); return; } const used = Number(et.used || 0); const limit = Number.isFinite(Number(et.limit)) ? et.limit : 'unlimited'; elUsage.textContent = Number.isFinite(Number(et.limit)) ? `${used} / ${limit}` : `${used} / ∞`; if (typeof builderState.externalTestingEnabled === 'boolean') { elToggle.checked = !!builderState.externalTestingEnabled; } else { elToggle.checked = false; builderState.externalTestingEnabled = false; saveBuilderState(builderState); } if (infoBtn) { infoBtn.onclick = (e) => { e.preventDefault(); alert('External WP tests run a series of WP-CLI checks on an external WordPress site. Tests are counted against your monthly allowance.'); }; } elToggle.addEventListener('change', async (e) => { const wantOn = e.target.checked === true; if (!wantOn) { builderState.externalTestingEnabled = false; saveBuilderState(builderState); elToggle.checked = false; return; } // Re-check usage before enabling await loadUsageSummary(); const summary = state.usageSummary?.externalTesting || null; const limit = summary ? summary.limit : null; const used = summary ? summary.used : 0; if (Number.isFinite(limit) && used >= limit) { // show modal suggesting upgrade const modal = document.getElementById('external-testing-limit-modal'); if (modal) modal.style.display = 'flex'; elToggle.checked = false; return; } builderState.externalTestingEnabled = true; saveBuilderState(builderState); elToggle.checked = true; }); } (function wireExternalTestingModal() { const modal = document.getElementById('external-testing-limit-modal'); if (!modal) return; const closeBtn = document.getElementById('external-testing-limit-close'); const cancelBtn = document.getElementById('external-testing-limit-cancel'); const upgradeBtn = document.getElementById('external-testing-limit-upgrade'); const upgradeHeaderBtn = document.getElementById('upgrade-header-btn'); const closeModal = () => { modal.style.display = 'none'; }; if (closeBtn) closeBtn.addEventListener('click', closeModal); if (cancelBtn) cancelBtn.addEventListener('click', closeModal); if (upgradeBtn) { upgradeBtn.addEventListener('click', () => { closeModal(); if (upgradeHeaderBtn) upgradeHeaderBtn.click(); else window.location.href = '/topup'; }); } })(); // Expose to global scope window.updateExternalTestingUI = updateExternalTestingUI; function checkTokenLimitAndShowModal() { const remaining = state.usageSummary?.remaining || 0; if (remaining <= 5000) { const modal = document.getElementById('token-limit-modal'); if (modal && modal.style.display !== 'flex') { modal.style.display = 'flex'; } } } window.checkTokenLimitAndShowModal = checkTokenLimitAndShowModal; // Poll usage more aggressively when OpenCode is running let usagePollingInterval = null; function startUsagePolling() { if (usagePollingInterval) clearInterval(usagePollingInterval); usagePollingInterval = setInterval(() => { loadUsageSummary().catch(err => { console.warn('[USAGE] Polling failed:', err); }); }, 60000); // Poll every 60 seconds console.log('[USAGE] Started usage polling'); } function stopUsagePolling() { if (usagePollingInterval) { clearInterval(usagePollingInterval); usagePollingInterval = null; console.log('[USAGE] Stopped usage polling'); } } // Expose for builder.html window.startUsagePolling = startUsagePolling; window.stopUsagePolling = stopUsagePolling; async function buyBoost(tier) { const res = await api('/api/account/boost', { method: 'POST', body: JSON.stringify(tier ? { tier } : {}) }); if (res?.summary) { state.usageSummary = res.summary; updateUsageProgressBar(state.usageSummary); setStatus('Extra AI energy added to your plan'); } } // Cached account fetch to avoid duplicate/competing requests let _cachedAccountPromise = null; function getAccountInfo(forceRefresh = false) { if (!forceRefresh && _cachedAccountPromise) return _cachedAccountPromise; _cachedAccountPromise = api('/api/account').catch((err) => { // Clear cache on error so future attempts can retry _cachedAccountPromise = null; throw err; }); return _cachedAccountPromise; } // Expose for legacy inline scripts window.getAccountInfo = getAccountInfo; async function loadAccountPlanWithRetry(attempt = 1) { const maxRetries = 3; const baseTimeoutMs = 10000; // 10 seconds timeout console.log(`[BUILDER-PLAN] loadAccountPlanWithRetry called, attempt ${attempt}/${maxRetries}`); try { // Start fetching account info with longer timeout const accountPromise = getAccountInfo(); // Race against a configurable timeout const timeoutPromise = new Promise((resolve) => { setTimeout(() => { console.log(`[BUILDER-PLAN] Timeout reached after ${baseTimeoutMs}ms (attempt ${attempt})`); resolve(null); }, baseTimeoutMs); }); const data = await Promise.race([accountPromise, timeoutPromise]); if (!data) { // Timed out - apply a safe default so the UI moves on, and continue fetching in background console.log(`[BUILDER-PLAN] Account fetch timed out, applying default plan (attempt ${attempt})`); applyAccountPlan(state.accountPlan || 'hobby'); syncUploadButtonState(); if (attempt < maxRetries) { // Retry with exponential backoff const delayMs = 2000 * Math.pow(2, attempt - 1); console.log(`[BUILDER-PLAN] Scheduling retry in ${delayMs}ms...`); setTimeout(() => { loadAccountPlanWithRetry(attempt + 1).catch(err => { console.error(`[BUILDER-PLAN] Retry ${attempt} failed:`, err.message); }); }, delayMs); } else { console.warn(`[BUILDER-PLAN] All ${maxRetries} retries exhausted, using default plan`); } accountPromise.then((fullData) => { if (fullData?.account?.plan) { console.log(`[BUILDER-PLAN] Background fetch completed, applying plan:`, fullData.account.plan); applyAccountPlan(fullData.account.plan); } if (fullData?.account?.tokenUsage) { state.usageSummary = fullData.account.tokenUsage; updateUsageProgressBar(state.usageSummary); console.log(`[BUILDER-PLAN] Usage summary updated from background fetch`); } else { loadUsageSummary().catch(() => {}); } }).catch((err) => { setAdminStatus(`account fetch failed: ${err?.message || String(err)}`); console.error(`[BUILDER-PLAN] Background account fetch failed:`, err.message); }); return; } if (data?.account?.plan) { console.log(`[BUILDER-PLAN] Account plan loaded successfully:`, data.account.plan); applyAccountPlan(data.account.plan); syncUploadButtonState(); if (data.account.tokenUsage) { state.usageSummary = data.account.tokenUsage; updateUsageProgressBar(state.usageSummary); } else { await loadUsageSummary(); } } else { console.warn(`[BUILDER-PLAN] No account plan in response, using default`); applyAccountPlan(state.accountPlan || 'hobby'); } } catch (err) { setAdminStatus(`account fetch failed: ${err?.message || String(err)}`); console.error(`[BUILDER-PLAN] Account fetch error (attempt ${attempt}):`, { error: err.message, stack: err.stack }); if (attempt < maxRetries) { const delayMs = 2000 * Math.pow(2, attempt - 1); console.log(`[BUILDER-PLAN] Scheduling retry in ${delayMs}ms...`); await new Promise(resolve => setTimeout(resolve, delayMs)); return loadAccountPlanWithRetry(attempt + 1); } console.error(`[BUILDER-PLAN] All ${maxRetries} retries exhausted, using default plan`); applyAccountPlan(state.accountPlan || 'hobby'); syncUploadButtonState(); } } // Legacy wrapper for backward compatibility async function loadAccountPlan() { return loadAccountPlanWithRetry(1); } let modelPreviewDropdown = null; let modelPreviewAttached = false; let modelPreviewHandler = null; let customDropdownOpen = false; function toggleCustomDropdown() { if (!el.modelSelectDropdown) return; customDropdownOpen = !customDropdownOpen; el.modelSelectDropdown.style.display = customDropdownOpen ? 'block' : 'none'; if (el.modelSelectBtn) { el.modelSelectBtn.setAttribute('aria-expanded', customDropdownOpen ? 'true' : 'false'); } } function closeCustomDropdown() { if (!el.modelSelectDropdown) return; customDropdownOpen = false; el.modelSelectDropdown.style.display = 'none'; if (el.modelSelectBtn) { el.modelSelectBtn.setAttribute('aria-expanded', 'false'); } } function showBlurredModelPreviewInline() { if (!isFreePlan()) return; if (modelPreviewDropdown) { try { modelPreviewDropdown.remove(); } catch (_) { } modelPreviewDropdown = null; } const wrap = document.getElementById('model-select-wrap') || (el.modelSelectBtn && el.modelSelectBtn.parentElement); if (!wrap) return; // Ensure the wrapper is positioned for absolute dropdown wrap.style.position = wrap.style.position || 'relative'; const dropdown = document.createElement('div'); dropdown.className = 'model-preview-dropdown'; dropdown.setAttribute('role', 'menu'); dropdown.setAttribute('aria-label', 'Available models (blurred)'); 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'; (state.models || []).forEach((m) => { const row = document.createElement('div'); row.className = 'model-preview-item'; row.textContent = m.label || m.name || m.id || 'Model'; list.appendChild(row); }); if (!list.children.length) { const placeholder = document.createElement('div'); placeholder.className = 'model-preview-item'; placeholder.textContent = 'Admin has not published models yet'; list.appendChild(placeholder); } // Add upgrade link at bottom const upgradeContainer = document.createElement('div'); upgradeContainer.style.marginTop = '16px'; upgradeContainer.style.paddingTop = '12px'; upgradeContainer.style.borderTop = '1px solid #e5e7eb'; const upgradeLink = document.createElement('a'); upgradeLink.href = '/upgrade?source=builder_model'; upgradeLink.style.color = '#008060'; upgradeLink.style.textDecoration = 'none'; upgradeLink.style.fontWeight = '700'; upgradeLink.style.fontSize = '14px'; upgradeLink.style.display = 'flex'; upgradeLink.style.alignItems = 'center'; upgradeLink.style.gap = '6px'; upgradeLink.textContent = 'Upgrade Plan to Access All Models'; const upgradeIcon = document.createElement('svg'); upgradeIcon.setAttribute('width', '16'); upgradeIcon.setAttribute('height', '16'); upgradeIcon.setAttribute('viewBox', '0 0 24 24'); upgradeIcon.setAttribute('fill', 'none'); upgradeIcon.setAttribute('stroke', 'currentColor'); upgradeIcon.setAttribute('stroke-width', '2'); upgradeIcon.setAttribute('stroke-linecap', 'round'); upgradeIcon.setAttribute('stroke-linejoin', 'round'); const upgradePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); upgradePath.setAttribute('d', 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5zM2 12l10 5 10-5'); upgradeIcon.appendChild(upgradePath); upgradeLink.appendChild(upgradeIcon); upgradeContainer.appendChild(upgradeLink); dropdown.appendChild(title); dropdown.appendChild(subtitle); dropdown.appendChild(list); dropdown.appendChild(upgradeContainer); wrap.appendChild(dropdown); modelPreviewDropdown = dropdown; // Close dropdown when clicking outside const onDocClick = (e) => { if (!dropdown.contains(e.target) && !wrap.contains(e.target)) { try { dropdown.remove(); } catch (_) { } modelPreviewDropdown = null; document.removeEventListener('click', onDocClick); } }; // Delay attaching the listener to avoid immediately closing when opening setTimeout(() => document.addEventListener('click', onDocClick), 0); } function applyPlanModelLock() { if (!el.modelSelect) return; if (!isFreePlan()) { // Paid plan: enable normal model selection if (modelPreviewAttached && modelPreviewHandler) { if (el.modelSelectBtn) { el.modelSelectBtn.removeEventListener('click', modelPreviewHandler); } modelPreviewHandler = null; modelPreviewAttached = false; } el.modelSelect.dataset.locked = ''; state.selectedModelId = el.modelSelect.value || state.selectedModelId; renderModelIcon(el.modelSelect.value); updateModelSelectDisplay(el.modelSelect.value); return; } // Hobby (free) plan: allow model selection but ensure 'auto' is available // Only add 'auto' if it doesn't exist - don't clear existing options const hasAutoOption = Array.from(el.modelSelect.options).some(opt => opt.value === 'auto'); if (!hasAutoOption) { const opt = document.createElement('option'); opt.value = 'auto'; opt.textContent = 'Auto (admin managed)'; el.modelSelect.appendChild(opt); } // Only set to 'auto' if current value is empty or invalid to avoid overwriting user selection let valueToDisplay = el.modelSelect.value; if (!valueToDisplay || valueToDisplay === '') { el.modelSelect.value = 'auto'; el.modelSelect.selectedIndex = 0; valueToDisplay = 'auto'; } state.selectedModelId = valueToDisplay; updateModelSelectDisplay(valueToDisplay); el.modelSelect.dataset.locked = 'true'; // Attach click handler to show blurred preview (but NOT by default) if (!modelPreviewAttached && el.modelSelectBtn) { modelPreviewHandler = (e) => { e.preventDefault(); e.stopPropagation(); // Show the blurred inline preview on click, not by default try { showBlurredModelPreviewInline(); } catch (_) { } }; el.modelSelectBtn.addEventListener('click', modelPreviewHandler); modelPreviewAttached = true; } renderModelIcon(valueToDisplay !== 'auto' ? valueToDisplay : null); } const el = { sessionList: null, // Not used in builder 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'), modelSelectBtn: document.getElementById('model-select-btn'), modelSelectDropdown: document.getElementById('model-select-dropdown'), modelSelectOptions: document.getElementById('model-select-options'), modelSelectText: document.getElementById('model-select-text'), modelSelectMultiplier: document.getElementById('model-select-multiplier'), modelIcon: document.getElementById('model-icon'), modelSelectWrap: document.getElementById('model-select-wrap'), customModelLabel: document.getElementById('custom-model-label'), customModelInput: document.getElementById('custom-model-input'), newChat: document.getElementById('new-chat'), historyBtn: document.getElementById('history-btn'), historyModal: document.getElementById('history-modal'), historyList: document.getElementById('history-list'), historyEmpty: document.getElementById('history-empty'), historyClose: document.getElementById('history-close'), messageInput: document.getElementById('message-input'), // sendBtn: document.getElementById('send-btn'), // Removed miniSendBtn: document.getElementById('mini-send-btn'), uploadMediaBtn: document.getElementById('upload-media-btn'), uploadMediaInput: document.getElementById('upload-media-input'), attachmentPreview: document.getElementById('attachment-preview'), statusLine: document.getElementById('status-line'), statusLineAdmin: document.getElementById('status-line-admin'), usageMeterTitle: document.getElementById('usage-meter-title'), usageMeterPercent: document.getElementById('usage-meter-percent'), usageMeterFill: document.getElementById('usage-meter-fill'), usageMeterTrack: document.getElementById('usage-meter-track'), quickButtons: document.querySelectorAll('[data-quick]'), confirmBuildModal: document.getElementById('confirm-build-modal'), confirmBuildProceed: document.getElementById('confirm-build-proceed'), confirmBuildCancel: document.getElementById('confirm-build-cancel'), confirmBuildClose: document.getElementById('confirm-build-close'), }; console.log('Builder DOM elements initialized:', { uploadMediaBtn: el.uploadMediaBtn, uploadMediaInput: el.uploadMediaInput, messageInput: el.messageInput }); const pendingAttachments = []; function isPaidPlanClient() { return !isFreePlan(); } function currentModelSupportsMedia() { const selectedModelId = state.selectedModelId || el.modelSelect?.value; if (!selectedModelId) return false; // When model is 'auto', check if any available model supports media if (selectedModelId === 'auto') { // Check if any model in the list supports media return state.models.some((m) => m.supportsMedia === true); } const model = state.models.find((m) => (m.id || m.name || m) === selectedModelId); return model?.supportsMedia === true; } 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) { 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 (_) { 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 isPaid = isPaidPlanClient(); const modelsLoaded = state.models && state.models.length > 0; const modelSupportsMedia = modelsLoaded ? currentModelSupportsMedia() : true; // If models not loaded yet, assume supported for paid plans const allowed = isPaid && modelSupportsMedia; el.uploadMediaBtn.style.display = isPaid ? 'flex' : 'none'; el.uploadMediaBtn.style.opacity = modelsLoaded ? (modelSupportsMedia ? '1' : '0.5') : '1'; el.uploadMediaBtn.style.cursor = modelsLoaded ? (modelSupportsMedia ? 'pointer' : 'not-allowed') : 'pointer'; // Update title based on why button is disabled if (!isPaid) { el.uploadMediaBtn.title = 'Upload media (available on Professional/Enterprise plans)'; } else if (!modelsLoaded) { el.uploadMediaBtn.title = 'Attach images'; } else if (!modelSupportsMedia) { el.uploadMediaBtn.title = 'Current model does not support media. Select a different model.'; } else { el.uploadMediaBtn.title = 'Attach images'; } } // Consolidated with change listener at the bottom of the file // Set up custom dropdown button click handler if (el.modelSelectBtn) { el.modelSelectBtn.addEventListener('click', (e) => { if (isFreePlan()) { // For free plans, check if user has manually selected a non-auto model // If yes, allow them to change it via dropdown // If no, show blurred preview if (el.modelSelect.value !== 'auto') { toggleCustomDropdown(); } else { // Let the modelPreviewHandler handle it (shows blurred preview) if (modelPreviewHandler) { modelPreviewHandler(e); } } return; } // Toggle dropdown for paid plans toggleCustomDropdown(); }); } // Close dropdown when clicking outside document.addEventListener('click', (e) => { if (customDropdownOpen && el.modelSelectDropdown && !el.modelSelectDropdown.contains(e.target) && !el.modelSelectBtn.contains(e.target)) { closeCustomDropdown(); } }); let pendingPlanContent = null; 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('wordpress_plugin_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('wordpress_plugin_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 { const keys = ['wordpress_plugin_ai_user', 'shopify_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(); async function hydrateUserIdFromServerSession() { if (state.userId) return state.userId; try { const res = await fetch('/api/me', { credentials: 'same-origin' }); if (!res.ok) return ''; const data = await res.json().catch(() => null); const serverUserId = data?.user?.id || ''; if (serverUserId) { state.userId = serverUserId; return serverUserId; } } catch (_) { // ignore } return ''; } (async () => { if (!state.userId) { await hydrateUserIdFromServerSession(); } // We keep the legacy chat_user cookie in sync when we can, but we no longer // hard-require localStorage identity for authenticated sessions. if (state.userId) { try { document.cookie = `chat_user=${encodeURIComponent(state.userId)}; path=/; SameSite=Lax`; } catch (_) { /* ignore */ } } // If neither localStorage nor server-session auth is present, send them to login. if (!state.userId) { const next = encodeURIComponent(window.location.pathname + window.location.search); window.location.href = `/login?next=${next}`; return; } detectAdminSession(); // Load usage summary on page load loadUsageSummary().catch(err => { console.warn('[USAGE] Initial loadUsageSummary failed:', err.message); }).then(() => { try { if (typeof window.updateExternalTestingUI === 'function') window.updateExternalTestingUI(); } catch (e) { console.warn('updateExternalTestingUI failed on init', e); } }); })(); function setAdminStatus(msg) { if (!el.statusLineAdmin) return; el.statusLineAdmin.textContent = msg || ''; } function classifyStatusMessage(msg) { const text = (msg || '').toString(); const lower = text.toLowerCase(); if (!text) return { userText: '', adminText: '' }; if (lower.startsWith('no models configured')) { return { userText: 'No models are configured. Please contact support.', adminText: text, }; } if (lower.startsWith('model load failed:')) { return { userText: 'Models are currently unavailable. Please contact support.', adminText: text, }; } if (lower.startsWith('planning failed:')) { return { userText: 'Planning is currently unavailable. Please contact support.', adminText: text, }; } // Surface missing provider API keys to the user with actionable text if (lower.includes('missing provider api keys') || lower.includes('no configured planning providers')) { // Try to extract provider list from the message if present const m = text.match(/Missing provider API keys:\s*([^\n\r]+)/i); const providers = m ? m[1].trim() : null; return { userText: providers ? `Planning unavailable: missing API keys for ${providers}. Please set the environment variables (e.g. GROQ_API_KEY) or configure providers in Admin.` : 'Planning unavailable: missing provider API keys. Please check server configuration.', adminText: text, }; } if (lower.includes('openrouter api key') || lower.includes('openrouter request failed')) { return { userText: 'Planning is currently unavailable. Please contact support.', adminText: text, }; } if (lower.startsWith('warning: opencode cli not available')) { return { userText: 'Builder service is currently unavailable. Please contact support.', adminText: text, }; } if (lower.includes('proper prefixing') || lower.includes('tool call format') || lower.includes('tool call prefix') || lower.includes('session terminated') || lower.includes('early termination')) { return { userText: 'Connection interrupted. Resuming...', adminText: `Early termination detected: ${text}`, type: 'warning' }; } return { userText: text, adminText: '' }; } function setStatus(msg) { const { userText, adminText } = classifyStatusMessage(msg); if (el.statusLine) el.statusLine.textContent = userText || ''; setAdminStatus(adminText); } // Expose for builder.html window.setStatus = setStatus; async function api(path, options = {}) { const headers = { 'Content-Type': 'application/json', ...(state.userId ? { 'X-User-Id': state.userId } : {}), ...(options.headers || {}), }; const res = await fetch(path, { headers, credentials: 'same-origin', ...options, }); const text = await res.text(); let json; try { json = text ? JSON.parse(text) : {}; } catch (parseErr) { console.error('[API] JSON parse error:', parseErr.message, 'Response:', text.substring(0, 300)); throw new Error(`Invalid JSON response from server: ${parseErr.message}`); } 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; } // Expose for builder.html window.api = api; 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; } // No-op for builder - session list not used function renderSessions() { // Builder doesn't render session list, but we still render history renderHistoryList(); } function formatSessionTime(value) { if (!value) return '—'; const dt = new Date(value); if (Number.isNaN(dt.getTime())) return '—'; return dt.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } function getSessionPreview(session) { const messages = Array.isArray(session.messages) ? session.messages : []; if (!messages.length) return 'No messages yet.'; const last = messages[messages.length - 1]; const preview = (last.reply || last.displayContent || last.content || '').trim(); return preview ? preview.slice(0, 140) : 'No messages yet.'; } function renderHistoryList() { if (!el.historyList || !el.historyEmpty) return; let sessions = Array.isArray(state.sessions) ? state.sessions.slice() : []; // Get current app ID from current session (not from URL since that's handled separately now) const currentSession = state.sessions.find(s => s.id === state.currentSessionId); let currentAppId = currentSession?.appId || null; // If we have a current appId, filter to show only chats for this app if (currentAppId) { sessions = sessions.filter(s => s.appId === currentAppId); } sessions.sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0)); el.historyList.innerHTML = ''; if (!sessions.length) { el.historyEmpty.style.display = 'block'; el.historyEmpty.textContent = 'No past chats'; return; } el.historyEmpty.style.display = 'none'; sessions.forEach((session) => { const item = document.createElement('div'); item.className = 'history-item'; if (session.id === state.currentSessionId) { item.classList.add('active'); } const content = document.createElement('div'); content.style.flex = '1'; const title = document.createElement('div'); title.className = 'history-title'; title.textContent = session.title || 'Untitled chat'; const preview = document.createElement('div'); preview.className = 'history-preview'; preview.textContent = getSessionPreview(session); content.appendChild(title); content.appendChild(preview); const meta = document.createElement('div'); meta.className = 'history-meta'; meta.textContent = formatSessionTime(session.updatedAt || session.createdAt); item.appendChild(content); item.appendChild(meta); item.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); if (session.id === state.currentSessionId) { console.log('[HISTORY] Clicked on current session, just closing modal'); closeHistoryModal(); return; } try { console.log('[HISTORY] Clicked on session:', { id: session.id, title: session.title }); console.log('[HISTORY] Current session ID before select:', state.currentSessionId); await selectSession(session.id); console.log('[HISTORY] Session selected successfully, new currentSessionId:', state.currentSessionId); console.log('[HISTORY] Closing modal'); closeHistoryModal(); } catch (err) { console.error('[HISTORY] Failed to select session:', err); setStatus('Failed to load chat: ' + err.message); } }); el.historyList.appendChild(item); }); } async function openHistoryModal() { // Refresh sessions from server to ensure we have the latest chats await loadSessions().catch(err => console.warn('Failed to refresh sessions for history:', err.message)); renderHistoryList(); if (el.historyModal) el.historyModal.style.display = 'flex'; } function closeHistoryModal() { if (el.historyModal) el.historyModal.style.display = 'none'; } function scrollChatToBottom(force = false) { if (!el.chatArea) return; const target = el.chatArea; const doScroll = () => { if (!target) return; target.scrollTop = target.scrollHeight; }; // Use multiple techniques to ensure scroll happens after content is rendered // 1. Immediate scroll attempt doScroll(); // 2. After short delays to allow DOM updates setTimeout(() => { doScroll(); }, 10); setTimeout(() => { doScroll(); }, 50); setTimeout(() => { doScroll(); }, 100); setTimeout(() => { doScroll(); }, 250); setTimeout(() => { doScroll(); }, 500); // 3. Longer delay for content to fully settle (especially on page load) setTimeout(() => { doScroll(); }, 1000); setTimeout(() => { doScroll(); }, 2000); // 4. Use requestAnimationFrame for smooth scrolling requestAnimationFrame(() => { doScroll(); requestAnimationFrame(() => { doScroll(); requestAnimationFrame(() => { doScroll(); }); }); }); // 5. Wait for ALL images to load before scrolling const images = target.querySelectorAll('img'); let imagesToLoad = 0; images.forEach((img) => { if (!img.complete) { imagesToLoad++; img.addEventListener('load', () => { imagesToLoad--; doScroll(); // Scroll again after a short delay to ensure layout is updated setTimeout(() => doScroll(), 100); }, { once: true }); img.addEventListener('error', () => { imagesToLoad--; doScroll(); }, { once: true }); } }); // If there are images loading, scroll again after they all complete if (imagesToLoad > 0) { const checkImagesLoaded = setInterval(() => { if (imagesToLoad === 0) { clearInterval(checkImagesLoaded); doScroll(); setTimeout(() => doScroll(), 100); setTimeout(() => doScroll(), 500); } }, 100); // Stop checking after 5 seconds to avoid infinite interval setTimeout(() => clearInterval(checkImagesLoaded), 5000); } // 6. Use ResizeObserver to detect when content height changes if (typeof ResizeObserver !== 'undefined') { const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.target === target) { doScroll(); } } }); resizeObserver.observe(target); // Stop observing after a while setTimeout(() => resizeObserver.disconnect(), 3000); } // 7. Also scroll when any content finishes rendering (using MutationObserver) if (typeof MutationObserver !== 'undefined') { const observer = new MutationObserver(() => { doScroll(); }); observer.observe(target, { childList: true, subtree: true }); // Stop observing after a while to avoid memory leaks setTimeout(() => observer.disconnect(), 3000); } } window.scrollChatToBottom = scrollChatToBottom; function renderSessionMeta(session) { if (!session) { console.error('renderSessionMeta called with null/undefined session'); return; } if (el.sessionId) el.sessionId.textContent = session.id ?? '-'; if (el.sessionModel) el.sessionModel.textContent = session.model ? `${session.cli ?? 'opencode'} / ${session.model}` : '-'; if (el.sessionPending) el.sessionPending.textContent = session.pending ?? 0; if (el.queueIndicator) { el.queueIndicator.textContent = session.pending ? `${session.pending} queued` : 'Idle'; el.queueIndicator.style.borderColor = session.pending ? 'var(--accent)' : 'var(--border)'; } // Update chat title while preserving the badge if (el.chatTitle) { const badge = el.chatTitle.querySelector('.shopify-badge'); const titleText = session.title || 'New Project'; if (badge) { // Clone badge to preserve it, clear title, add text, then re-add badge const badgeClone = badge.cloneNode(true); el.chatTitle.textContent = titleText; el.chatTitle.appendChild(badgeClone); } else { el.chatTitle.textContent = titleText; } } if (session.cli && el.cliSelect && el.cliSelect.value !== session.cli) { el.cliSelect.value = session.cli; state.currentCli = session.cli; } // Don't update model if user just changed it manually if (session.model && !userJustChangedModel) { console.log(`[renderSessionMeta] Server model: ${session.model}, Current UI model: ${el.modelSelect.value}, userJustChangedModel: false - syncing from server`); if (!isFreePlan()) { // For paid plans: only update if current value is empty/default, preserve user selection const currentValue = el.modelSelect.value; const isDefaultOrEmpty = !currentValue || currentValue === 'default' || currentValue === ''; const isDifferentModel = currentValue !== session.model; // Only overwrite if user hasn't selected something specific if ((isDefaultOrEmpty || !currentValue) && isDifferentModel) { programmaticModelChange = true; el.modelSelect.value = session.model; state.selectedModelId = session.model; updateModelSelectDisplay(session.model); setTimeout(() => { programmaticModelChange = false; }, 100); } } else { // Hobby plan shows Auto, but preserve user selection if they've manually selected a non-auto model // Only update if the current value is 'auto' to avoid overwriting user selections if (el.modelSelect.value === 'auto' && session.model !== 'auto') { programmaticModelChange = true; el.modelSelect.value = session.model; state.selectedModelId = session.model; updateModelSelectDisplay(session.model); setTimeout(() => { programmaticModelChange = false; }, 100); } else if (el.modelSelect.value !== 'auto' && session.model === 'auto') { // Server has 'auto', but user has selected a specific model - keep user selection // Don't overwrite it } } } } // Helper functions for loading indicator function showLoadingIndicator(type) { // Remove any existing loading indicator first hideLoadingIndicator(); const loadingDiv = document.createElement('div'); loadingDiv.className = 'loading-indicator animate-in'; loadingDiv.id = 'loading-indicator'; loadingDiv.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; padding: 16px; background: var(--shopify-bg); border-radius: 8px; margin: 8px 0;'; // Create status container const statusContainer = document.createElement('div'); statusContainer.style.cssText = 'display: flex; align-items: center; gap: 12px; margin-bottom: 8px;'; // Create animated dots const dotsContainer = document.createElement('div'); dotsContainer.style.cssText = 'display: flex; gap: 4px;'; for (let i = 0; i < 3; i++) { const dot = document.createElement('span'); dot.style.cssText = `width: 8px; height: 8px; background: var(--shopify-green); border-radius: 50%; animation: loadingDot 1.4s ease-in-out ${i * 0.2}s infinite;`; dotsContainer.appendChild(dot); } // Create status text const statusText = document.createElement('span'); statusText.className = 'loading-status-text'; statusText.style.cssText = 'font-weight: 600; color: var(--shopify-text); font-size: 14px;'; // Set appropriate text based on type if (type === 'planning') { statusText.textContent = 'Starting planning process'; } else { statusText.textContent = 'Starting build process'; } statusContainer.appendChild(dotsContainer); statusContainer.appendChild(statusText); // Create detailed info text const detailText = document.createElement('span'); detailText.className = 'loading-detail-text'; detailText.style.cssText = 'font-size: 12px; color: var(--shopify-text-secondary); margin-left: 32px;'; if (type === 'planning') { detailText.textContent = 'Analyzing your request and creating a development plan...'; } else { detailText.textContent = 'Building your plugin with AI-generated code...'; } // Add animation keyframes if not already added if (!document.getElementById('loading-animations')) { const style = document.createElement('style'); style.id = 'loading-animations'; style.textContent = ` @keyframes loadingDot { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } 40% { transform: scale(1); opacity: 1; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } `; document.head.appendChild(style); } loadingDiv.appendChild(statusContainer); loadingDiv.appendChild(detailText); el.chatArea.appendChild(loadingDiv); // Scroll to bottom to show the loading indicator el.chatArea.scrollTop = el.chatArea.scrollHeight; } // Expose for builder.html window.showLoadingIndicator = showLoadingIndicator; function hideLoadingIndicator() { const existing = document.getElementById('loading-indicator'); if (existing) { existing.remove(); } } // Expose for builder.html window.hideLoadingIndicator = hideLoadingIndicator; function renderMessages(session) { // Don't clear the loading indicator if it exists const loadingIndicator = document.getElementById('loading-indicator'); // Store reference before clearing, and remove animation class to prevent bounce on re-render if (loadingIndicator) { loadingIndicator.classList.remove('animate-in'); // Instead of removing from DOM, just hide it temporarily // This prevents layout shifts when re-adding the element loadingIndicator.style.visibility = 'hidden'; loadingIndicator.style.position = 'absolute'; } // Remove all message elements while preserving the loading indicator // This prevents the loading indicator from being removed from the DOM const messageElements = el.chatArea.querySelectorAll('.message'); messageElements.forEach(el => el.remove()); const emptyMessage = el.chatArea.querySelector('.empty-message'); if (emptyMessage) emptyMessage.remove(); if (!session.messages || !session.messages.length) { // Remove any existing empty message const existingEmpty = el.chatArea.querySelector('.empty-message'); if (existingEmpty) existingEmpty.remove(); if (!loadingIndicator) { const emptyDiv = document.createElement('div'); emptyDiv.className = 'muted empty-message'; emptyDiv.textContent = 'Send a message to start the conversation.'; el.chatArea.appendChild(emptyDiv); } else { // Restore loading indicator visibility if it was there loadingIndicator.style.visibility = ''; loadingIndicator.style.position = ''; } // Ensure loading indicator is at the end if it exists if (loadingIndicator && loadingIndicator.parentNode !== el.chatArea) { el.chatArea.appendChild(loadingIndicator); } scrollChatToBottom(); return; } // Find the latest message (excluding background continuations) const latestMessage = [...session.messages].reverse().find(msg => !msg.isBackgroundContinuation); session.messages.forEach((msg) => { if (msg.isBackgroundContinuation) return; const status = msg.status || 'done'; const userCard = document.createElement('div'); userCard.className = 'message user'; const userMeta = document.createElement('div'); userMeta.className = 'meta'; // Hide model badge for plan messages (OpenRouter messages) const isPlanMessage = (msg.cli !== 'opencode') || (msg.phase === 'plan'); const modelBadge = ''; userMeta.innerHTML = ` You ${modelBadge} ${status} `; 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 = '150px'; 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)) { console.log('[RenderMessages] Creating assistant card for message:', { id: msg.id, cli: msg.cli, phase: msg.phase, status: status, hasReply: !!msg.reply, hasError: !!msg.error, hasPartialOutput: !!msg.partialOutput }); // Don't hide loading indicator during streaming - only hide when completely done // This allows the spinner to continue until the OpenCode session is fully complete const assistantCard = document.createElement('div'); assistantCard.className = 'message assistant' + (status === 'cancelled' ? ' cancelled' : ''); const assistantMeta = document.createElement('div'); assistantMeta.className = 'meta'; const msgCliLabel = (msg.cli || session.cli || 'opencode'); // For OpenCode messages, don't show provider/model label in builder // Only show CLI label for non-OpenCode messages (e.g., OpenRouter) if (msgCliLabel !== 'opencode') { const cliSpan = document.createElement('span'); cliSpan.textContent = String(msgCliLabel).toUpperCase(); assistantMeta.appendChild(cliSpan); } const rawBtn = document.createElement('button'); rawBtn.className = 'ghost'; rawBtn.style.marginLeft = '8px'; rawBtn.textContent = 'Plugin Compass'; assistantMeta.appendChild(rawBtn); // Add Undo/Redo buttons - show for latest message when done, errored, or cancelled const isOpencodeMsg = msg.cli === 'opencode' || msg.phase === 'build'; const isLatestMessage = latestMessage && latestMessage.id === msg.id; const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error' || status === 'cancelled'); if (shouldShowUndoRedo) { const undoBtn = document.createElement('button'); undoBtn.className = 'ghost'; undoBtn.style.marginLeft = '8px'; undoBtn.textContent = '↺ Undo'; undoBtn.onclick = async () => { undoBtn.disabled = true; undoBtn.textContent = '↺ Undoing...'; try { // Clear todos before undoing clearTodos(); await api(`/api/sessions/${session.id}/messages/${msg.id}/undo`, { method: 'POST', }); await refreshCurrentSession(); setStatus('Undo complete'); } catch (err) { setStatus('Undo failed: ' + err.message); } finally { undoBtn.disabled = false; undoBtn.textContent = '↺ Undo'; } }; assistantMeta.appendChild(undoBtn); const redoBtn = document.createElement('button'); redoBtn.className = 'ghost'; redoBtn.style.marginLeft = '8px'; redoBtn.textContent = '↻ Redo'; redoBtn.onclick = async () => { redoBtn.disabled = true; redoBtn.textContent = '↻ Redoing...'; try { // Clear todos before redoing clearTodos(); await redoMessage(msg, session); } catch (err) { setStatus('Redo failed: ' + err.message); } finally { redoBtn.disabled = false; redoBtn.textContent = '↻ Redo'; } }; assistantMeta.appendChild(redoBtn); } const assistantBody = document.createElement('div'); assistantBody.className = 'body'; assistantBody.appendChild(renderContentWithTodos(msg.reply || msg.partialOutput || msg.opencodeSummary || '')); assistantCard.appendChild(assistantMeta); assistantCard.appendChild(assistantBody); // Check if this is an OpenRouter (plan) message - show "Proceed with Build" on ALL OpenRouter messages, not just the first const isOpenRouterMessage = (msg.cli !== 'opencode') || (msg.phase === 'plan') || (msg.model && !msg.model.includes('opencode') && msg.cli !== 'opencode'); const hasReply = msg.reply || msg.partialOutput; const isPlanPhase = msg.phase === 'plan'; const isBuildPhase = msg.phase === 'build'; if (isOpenRouterMessage && hasReply && (isPlanPhase || isBuildPhase)) { const proceedBtn = document.createElement('button'); proceedBtn.className = 'primary'; proceedBtn.style.marginTop = '12px'; proceedBtn.style.width = '100%'; proceedBtn.textContent = 'Proceed with Build'; proceedBtn.onclick = () => proceedWithBuild(msg.reply || msg.partialOutput); assistantBody.appendChild(proceedBtn); } // Add Download ZIP button for OpenCode messages that are finished and are the latest message const isOpenCodeMessage = (msg.cli === 'opencode') || (msg.phase === 'build'); if (isOpenCodeMessage && hasReply && status === 'done' && isLatestMessage) { const downloadBtn = document.createElement('button'); downloadBtn.className = 'primary'; downloadBtn.style.marginTop = '12px'; downloadBtn.style.width = '100%'; downloadBtn.textContent = 'Download ZIP'; downloadBtn.onclick = () => { const modal = document.getElementById('export-modal'); if (modal) modal.style.display = 'flex'; }; assistantBody.appendChild(downloadBtn); } 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); } // Render todos if they exist on the message if (msg.todos && Array.isArray(msg.todos) && msg.todos.length > 0) { const todoContainer = renderStructuredTodos(msg.todos); if (todoContainer) { assistantCard.appendChild(todoContainer); } } 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); // Approve & Build button removed - keeping users in plan mode only } }); // Restore loading indicator visibility after all messages are rendered if (loadingIndicator) { loadingIndicator.style.visibility = ''; loadingIndicator.style.position = ''; // Ensure loading indicator is at the end of chat area if (loadingIndicator.parentNode !== el.chatArea) { el.chatArea.appendChild(loadingIndicator); } else { // Move to end to ensure it's after all messages el.chatArea.appendChild(loadingIndicator); } } scrollChatToBottom(); updateExportButtonVisibility(session); } function updateExportButtonVisibility(session) { const exportZipBtn = document.getElementById('export-zip-btn'); if (!exportZipBtn) return; if (!session || !session.messages || !session.messages.length) { exportZipBtn.style.display = 'none'; return; } // Show the export button when the session contains at least one completed // OpenCode/build message with output. Relying on session.status caused // the button to disappear in some cases when the session-level status // wasn't set yet even though messages were complete. Inspect messages // directly for a more reliable check. const msgs = Array.isArray(session.messages) ? session.messages.slice().reverse() : []; const hasExportable = msgs.find((msg) => { if (msg.isBackgroundContinuation) return false; const isOpencode = (msg.cli === 'opencode') || (msg.phase === 'build'); const isFinished = msg.status === 'done' || msg.status === 'error'; const hasOutput = !!(msg.reply || msg.partialOutput); return isOpencode && isFinished && hasOutput; }); exportZipBtn.style.display = hasExportable ? '' : 'none'; } // Expose for builder.html window.renderMessages = renderMessages; // 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*(?=[A-Z])/g, ':\n\n') .replace(/([.])\s+(?=[A-Z])/g, '$1\n\n'); const lines = processedText.split(/\r?\n/); let currentList = null; let inCodeBlock = false; let codeBuffer = []; for (const line of lines) { if (line.trim().startsWith('```')) { if (inCodeBlock) { const pre = document.createElement('pre'); pre.className = 'code-block'; pre.style.background = '#1e1e1e'; pre.style.color = '#d4d4d4'; pre.style.padding = '12px'; pre.style.borderRadius = '8px'; pre.style.overflowX = 'auto'; pre.style.fontFamily = 'monospace'; pre.style.fontSize = '13px'; pre.style.margin = '10px 0'; pre.textContent = codeBuffer.join('\n'); wrapper.appendChild(pre); codeBuffer = []; inCodeBlock = false; } else { inCodeBlock = true; } continue; } if (inCodeBlock) { codeBuffer.push(line); continue; } 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.style.marginBottom = '8px'; p.textContent = line; wrapper.appendChild(p); } } return wrapper; } // Render structured todos with status and priority function renderStructuredTodos(todos) { if (!todos || !Array.isArray(todos) || todos.length === 0) { return null; } const container = document.createElement('div'); container.className = 'todo-container'; container.style.marginTop = '16px'; container.style.marginBottom = '16px'; container.style.padding = '16px'; container.style.background = '#f8fffc'; container.style.border = '1px solid rgba(0, 128, 96, 0.2)'; container.style.borderRadius = '12px'; const header = document.createElement('div'); header.style.display = 'flex'; header.style.alignItems = 'center'; header.style.gap = '8px'; header.style.marginBottom = '12px'; header.style.fontWeight = '600'; header.style.color = 'var(--shopify-green)'; header.style.fontSize = '14px'; header.innerHTML = ` Tasks (${todos.length}) `; container.appendChild(header); const list = document.createElement('div'); list.style.display = 'flex'; list.style.flexDirection = 'column'; list.style.gap = '8px'; todos.forEach((todo) => { const item = document.createElement('div'); item.className = `todo-item todo-status-${todo.status}`; item.style.display = 'flex'; item.style.alignItems = 'flex-start'; item.style.gap = '10px'; item.style.padding = '10px 12px'; item.style.borderRadius = '8px'; item.style.background = '#fff'; item.style.border = '1px solid var(--border)'; item.style.transition = 'all 0.2s ease'; // Status icon const statusIcon = document.createElement('span'); statusIcon.className = 'todo-status-icon'; statusIcon.style.flexShrink = '0'; statusIcon.style.width = '20px'; statusIcon.style.height = '20px'; statusIcon.style.display = 'flex'; statusIcon.style.alignItems = 'center'; statusIcon.style.justifyContent = 'center'; statusIcon.style.fontSize = '14px'; // Set icon and color based on status switch (todo.status) { case 'completed': statusIcon.innerHTML = '✓'; statusIcon.style.color = '#10b981'; item.style.borderColor = 'rgba(16, 185, 129, 0.3)'; item.style.background = 'rgba(16, 185, 129, 0.05)'; break; case 'in_progress': statusIcon.innerHTML = '●'; statusIcon.style.color = '#f59e0b'; item.style.borderColor = 'rgba(245, 158, 11, 0.3)'; item.style.background = 'rgba(245, 158, 11, 0.05)'; break; case 'cancelled': statusIcon.innerHTML = '✗'; statusIcon.style.color = '#6b7280'; item.style.opacity = '0.6'; item.style.textDecoration = 'line-through'; break; case 'pending': default: statusIcon.innerHTML = '○'; statusIcon.style.color = '#9ca3af'; break; } const content = document.createElement('span'); content.className = 'todo-content'; content.style.flex = '1'; content.style.fontSize = '14px'; content.style.lineHeight = '1.4'; content.style.color = 'var(--ink)'; content.textContent = todo.content || ''; // Priority badge if (todo.priority && todo.priority !== 'medium') { const priorityBadge = document.createElement('span'); priorityBadge.className = `todo-priority-${todo.priority}`; priorityBadge.style.fontSize = '10px'; priorityBadge.style.fontWeight = '700'; priorityBadge.style.textTransform = 'uppercase'; priorityBadge.style.letterSpacing = '0.02em'; priorityBadge.style.padding = '2px 6px'; priorityBadge.style.borderRadius = '4px'; priorityBadge.style.flexShrink = '0'; if (todo.priority === 'high') { priorityBadge.textContent = 'High'; priorityBadge.style.background = '#fee2e2'; priorityBadge.style.color = '#dc2626'; } else if (todo.priority === 'low') { priorityBadge.textContent = 'Low'; priorityBadge.style.background = '#dbeafe'; priorityBadge.style.color = '#2563eb'; } content.appendChild(document.createTextNode(' ')); content.appendChild(priorityBadge); } item.appendChild(statusIcon); item.appendChild(content); list.appendChild(item); }); container.appendChild(list); return container; } // Clear todos from the UI function clearTodos() { state.todos = []; state.currentMessageId = null; const existingContainer = document.querySelector('.todo-container'); if (existingContainer) { existingContainer.remove(); } console.log('[TODOS] Cleared todos from UI'); } // Update todos in the UI function updateTodos(todos, messageId) { if (!todos || !Array.isArray(todos) || todos.length === 0) { return; } // Only update if this is for the current message or a new message if (state.currentMessageId && state.currentMessageId !== messageId) { console.log('[TODOS] Ignoring todos for different message', { current: state.currentMessageId, received: messageId }); return; } state.todos = todos; state.currentMessageId = messageId; // Remove existing todo container const existingContainer = document.querySelector('.todo-container'); if (existingContainer) { existingContainer.remove(); } // Find the latest assistant message to append todos to const assistantMessages = document.querySelectorAll('.message.assistant'); if (assistantMessages.length === 0) { console.log('[TODOS] No assistant message found to attach todos to'); return; } const latestMessage = assistantMessages[assistantMessages.length - 1]; const todoContainer = renderStructuredTodos(todos); if (todoContainer) { latestMessage.appendChild(todoContainer); console.log('[TODOS] Updated todos in UI', { count: todos.length, messageId }); } } 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.id || m.name || 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'; } function updateModelSelectDisplay(selectedValue) { if (!el.modelSelectText) return; const model = state.models.find((m) => (m.id || m.name || m) === selectedValue); if (selectedValue === 'auto') { el.modelSelectText.textContent = 'Auto (admin managed)'; if (el.modelSelectMultiplier) { el.modelSelectMultiplier.textContent = ''; el.modelSelectMultiplier.style.display = 'none'; } renderModelIcon(null); } else if (model) { el.modelSelectText.textContent = model.label || model.name || selectedValue; if (el.modelSelectMultiplier) { el.modelSelectMultiplier.textContent = `${model.multiplier || 1}x`; el.modelSelectMultiplier.style.display = 'inline-block'; } renderModelIcon(selectedValue); } else { el.modelSelectText.textContent = 'Select model'; if (el.modelSelectMultiplier) { el.modelSelectMultiplier.textContent = ''; el.modelSelectMultiplier.style.display = 'none'; } renderModelIcon(null); } } function renderCustomDropdownOptions() { if (!el.modelSelectOptions) { console.warn('[renderCustomDropdownOptions] el.modelSelectOptions not found!'); return; } el.modelSelectOptions.innerHTML = ''; console.log(`[renderCustomDropdownOptions] Rendering ${state.models ? state.models.length : 0} models, current selection: ${state.selectedModelId || el.modelSelect.value}`); if (!state.models || state.models.length === 0) { const placeholder = document.createElement('div'); placeholder.className = 'model-option disabled'; placeholder.textContent = 'No models available'; el.modelSelectOptions.appendChild(placeholder); return; } const currentSelection = state.selectedModelId || el.modelSelect.value; state.models.forEach((model) => { const option = document.createElement('div'); option.className = 'model-option'; option.dataset.value = model.id || model.name || model; option.setAttribute('role', 'option'); // Add icon if available if (model.icon) { const icon = document.createElement('img'); icon.src = model.icon; icon.alt = model.label || model.name || model.name; option.appendChild(icon); } // Add text const text = document.createElement('span'); text.className = 'model-option-text'; text.textContent = model.label || model.name || model.id || model; option.appendChild(text); // Add multiplier badge (e.g., "1x", "2x") const mult = document.createElement('span'); mult.className = 'model-option-multiplier'; mult.textContent = `${model.multiplier || 1}x`; mult.title = `Usage rate: ${model.multiplier || 1}x`; option.appendChild(mult); // Mark as selected if matches current selection const modelId = model.id || model.name || model; if (currentSelection === modelId) { option.classList.add('selected'); console.log(`[renderCustomDropdownOptions] Marked model as selected: ${modelId}`); } // Click handler option.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); console.log(`[renderCustomDropdownOptions] Model clicked: ${modelId}`); selectModel(modelId); }); el.modelSelectOptions.appendChild(option); }); } // Flag to track if model selection is programmatic (not user-triggered) let programmaticModelChange = false; // Flag to track when user manually changed model (prevent server from overwriting) // This persists longer to survive polling cycles - cleared when user stops interacting let userJustChangedModel = false; let userModelChangeTimer = null; function markUserModelChange() { userJustChangedModel = true; // Clear any existing timer if (userModelChangeTimer) { clearTimeout(userModelChangeTimer); userModelChangeTimer = null; } // Set timer to clear the flag - this gives time for polling to complete userModelChangeTimer = setTimeout(() => { console.log(`[markUserModelChange] Timer expired, clearing userJustChangedModel (was true for 3s)`); userJustChangedModel = false; userModelChangeTimer = null; }, 3000); // 3 seconds to survive polling cycles } function selectModel(modelId) { // This is a user-triggered change via the custom dropdown (even though we dispatch a synthetic change event). console.log(`[selectModel] User selected model: ${modelId}, setting userJustChangedModel=true`); markUserModelChange(); state.selectedModelId = modelId; programmaticModelChange = true; el.modelSelect.value = modelId; updateModelSelectDisplay(modelId); closeCustomDropdown(); // Update selected state in options const options = el.modelSelectOptions.querySelectorAll('.model-option'); options.forEach((opt) => { if (opt.dataset.value === modelId) { opt.classList.add('selected'); } else { opt.classList.remove('selected'); } }); // Manually trigger change event to sync with server el.modelSelect.dispatchEvent(new Event('change')); // Reset flag after event is dispatched - but let the timer handle final clearing setTimeout(() => { programmaticModelChange = false; }, 100); } async function loadModels(cli = state.currentCli || 'opencode') { try { const previousSelection = state.selectedModelId || el.modelSelect?.value || ''; const previousSignature = state.modelsSignature; state.currentCli = cli; if (el.cliSelect && el.cliSelect.value !== cli) el.cliSelect.value = cli; const data = await api(`/api/models?cli=${encodeURIComponent(cli)}`); const nextModels = data.models || []; const nextSignature = JSON.stringify(nextModels.map((m) => { if (!m) return { id: '', label: '', icon: '', mult: 1 }; if (typeof m === 'string') return { id: m, label: m, icon: '', mult: 1 }; const id = m.id || m.name || ''; return { id, label: m.label || m.name || id, icon: m.icon || '', mult: m.multiplier || 1, }; })); state.models = nextModels; state.modelsSignature = nextSignature; const selectionToRestore = state.selectedModelId || el.modelSelect?.value || previousSelection; console.log(`[loadModels] Loaded ${state.models.length} models for CLI "${cli}"`, { modelsChanged: previousSignature !== nextSignature, previousSelection, selectionToRestore, modelsCount: state.models.length, }); el.modelSelect.innerHTML = ''; const optionValues = new Set(); // For free plans, populate custom dropdown for preview but keep select as "auto" if (isFreePlan()) { el.modelSelect.disabled = false; renderModelIcon(null); setStatus(''); state.models.forEach((m) => { const option = document.createElement('option'); option.value = m.id || m.name || m; option.textContent = m.label || m.name || m.id || m; if (m.icon) option.dataset.icon = m.icon; el.modelSelect.appendChild(option); optionValues.add(option.value); }); // Try to keep whatever the user previously selected (e.g. during async reloads) // But don't overwrite if user just changed it manually if (selectionToRestore && optionValues.has(selectionToRestore) && !userJustChangedModel) { el.modelSelect.value = selectionToRestore; } if (state.models.length > 0) { renderCustomDropdownOptions(); } // For free plans, don't call applyPlanModelLock() here to avoid overwriting user selection // The lock will be applied later when needed // Only update state.selectedModelId if user hasn't just changed it if (!userJustChangedModel) { state.selectedModelId = el.modelSelect.value || state.selectedModelId; updateModelSelectDisplay(state.selectedModelId); } else { // User just changed model, ensure display is in sync updateModelSelectDisplay(el.modelSelect.value || state.selectedModelId); } updateUsageProgressBar(); return; } if (!state.models.length) { el.modelSelect.disabled = true; state.selectedModelId = null; renderModelIcon(null); setStatus('No models configured. Ask an admin to add models in the admin panel.'); applyPlanModelLock(); updateUsageProgressBar(); return; } el.modelSelect.disabled = false; state.models.forEach((m) => { const option = document.createElement('option'); option.value = m.id || m.name || m; option.textContent = m.label || m.name || m.id || m; if (m.icon) option.dataset.icon = m.icon; el.modelSelect.appendChild(option); optionValues.add(option.value); }); const activeSessionModel = state.currentSessionId ? (state.sessions.find(s => s.id === state.currentSessionId)?.model || '') : ''; const preferred = selectionToRestore || activeSessionModel; // Don't overwrite user's selection if they just changed it if (userJustChangedModel) { console.log(`[loadModels] User just changed model (userJustChangedModel=true), preserving current selection: ${el.modelSelect.value}`); // Still update state.selectedModelId to match current UI state.selectedModelId = el.modelSelect.value || state.selectedModelId; } else if (preferred && optionValues.has(preferred)) { el.modelSelect.value = preferred; } else if (activeSessionModel && optionValues.has(activeSessionModel)) { el.modelSelect.value = activeSessionModel; } else if (state.models.length > 0) { el.modelSelect.value = state.models[0].id || state.models[0].name || state.models[0] || 'default'; } // Preserve the user's model selection across reloads const finalSelection = el.modelSelect.value || state.selectedModelId; state.selectedModelId = finalSelection; // Update the display first, then render options to ensure synchronization updateModelSelectDisplay(finalSelection); renderCustomDropdownOptions(); setStatus(''); applyPlanModelLock(); updateUsageProgressBar(); } catch (error) { setStatus(`Model load failed: ${error.message}`); state.models = []; state.modelsSignature = null; state.selectedModelId = null; 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(); updateUsageProgressBar(); } } // Load sessions list (builder-specific) with retry logic and detailed logging const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; async function loadSessionsWithRetry(attempt = 1) { console.log(`[BUILDER-SESSIONS] loadSessionsWithRetry called, attempt ${attempt}/${MAX_RETRIES}`); try { console.log(`[BUILDER-SESSIONS] Loading sessions from API... (attempt ${attempt})`); const data = await api('/api/sessions'); console.log(`[BUILDER-SESSIONS] Sessions API response:`, { sessionCount: data.sessions?.length || 0, attempt, timestamp: new Date().toISOString() }); state.sessions = data.sessions || []; state.sessionsLoaded = true; // Adopt CLI from sessions if not set if (state.sessions.length && !state.currentCli) { state.currentCli = state.sessions[0].cli || 'opencode'; if (el.cliSelect) el.cliSelect.value = state.currentCli; console.log(`[BUILDER-SESSIONS] Adopted CLI from first session:`, state.currentCli); } renderSessions(); console.log(`[BUILDER-SESSIONS] Sessions rendered, count:`, state.sessions.length); // If no current session, select the first available session if (!state.currentSessionId && state.sessions.length) { console.log(`[BUILDER-SESSIONS] No current session, selecting first available:`, state.sessions[0].id); await selectSession(state.sessions[0].id); } else if (!state.currentSessionId && !state.sessions.length) { console.log(`[BUILDER-SESSIONS] No sessions available after loading`); // Don't create a session here - let the sendMessage function handle that } // If state.currentSessionId is already set, use it (this is the normal case for plugin builder) return { success: true, sessionCount: state.sessions.length }; } catch (err) { console.error(`[BUILDER-SESSIONS] Failed to load sessions (attempt ${attempt}/${MAX_RETRIES}):`, { error: err.message, stack: err.stack, timestamp: new Date().toISOString() }); if (attempt < MAX_RETRIES) { const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff console.log(`[BUILDER-SESSIONS] Retrying in ${delayMs}ms...`); await new Promise(resolve => setTimeout(resolve, delayMs)); return loadSessionsWithRetry(attempt + 1); } // All retries exhausted - don't block initialization, but log the failure console.error(`[BUILDER-SESSIONS] All ${MAX_RETRIES} retries exhausted, proceeding with empty sessions`); state.sessions = state.sessions || []; state.sessionsLoaded = true; return { success: false, sessionCount: 0, error: err.message }; } } // Legacy wrapper for backward compatibility async function loadSessions() { return loadSessionsWithRetry(1); } // Expose for builder.html window.loadSessions = loadSessions; // Load current session data (fetch only the single session to avoid redundant work) async function loadCurrentSession() { if (!state.currentSessionId) return; try { const { session } = await api(`/api/sessions/${state.currentSessionId}`); // Update sessions array with fresh session data const idx = state.sessions.findIndex((s) => s.id === session.id); if (idx >= 0) state.sessions[idx] = session; else state.sessions.push(session); renderSessionMeta(session); renderMessages(session); // Set up streaming for any running messages 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 if (running.length > 0) setPollingInterval(2000); else setPollingInterval(5000); return session; } catch (error) { setStatus(error.message); return null; } } function restorePlanStateFromSession(session) { if (!session) return; const planMessages = Array.isArray(session.messages) ? session.messages.filter((m) => m.phase === 'plan') : []; const latestPlan = planMessages[planMessages.length - 1]; // Only restore lastPlanText from session if not already set in persisted state if (!builderState.lastPlanText) { if (session.planSummary) { builderState.lastPlanText = session.planSummary; } else if (latestPlan) { builderState.lastPlanText = latestPlan.reply || latestPlan.partialOutput || latestPlan.content || ''; } } // Only restore lastUserRequest from session if not already set in persisted state if (!builderState.lastUserRequest) { if (session.planUserRequest) { builderState.lastUserRequest = session.planUserRequest; } else if (latestPlan?.content) { builderState.lastUserRequest = latestPlan.content; } } // Only restore planApproved from session if: // 1. Not already true in persisted state (don't downgrade) // 2. Session explicitly indicates plan is approved if (!builderState.planApproved && typeof session.planApproved !== 'undefined') { builderState.planApproved = !!session.planApproved; } } function applyEntryMode(session) { if (!session) return; const isUploaded = session.entryMode === 'opencode' || session.source === 'upload'; // For uploaded apps, always use build mode if (isUploaded) { builderState.mode = 'build'; builderState.planApproved = true; state.currentCli = 'opencode'; if (el.cliSelect) el.cliSelect.value = 'opencode'; updateBuildModeUI('build'); return; } // Check if app has EVER had build messages sent (plan was approved and executed) // This means any session with this appId has messages with cli='opencode' const appHasBeenBuilt = state.sessions.some(s => s.appId === session.appId && s.messages && s.messages.some(m => m.cli === 'opencode') ); // Set mode based on app build history to ensure correct behavior when switching // between apps (ignoring persisted state which may be stale from previous app) if (appHasBeenBuilt) { // App has been built before - all chats should use build mode builderState.mode = 'build'; builderState.planApproved = true; updateBuildModeUI('build'); } else { // App has never been built - start in plan mode builderState.mode = 'plan'; builderState.planApproved = false; updateBuildModeUI('plan'); } } async function selectSession(id) { console.log('[BUILDER] Selecting session:', id); state.currentSessionId = id; await selectSessionById(id); } // Expose for builder.html window.selectSessionById = async function(id) { console.log('[BUILDER] selectSessionById called with ID:', id); console.log('[BUILDER] Available sessions:', state.sessions.map(s => ({ id: s.id, title: s.title }))); console.log('[BUILDER] Current session ID before selection:', state.currentSessionId); console.log('[BUILDER] Chat area exists:', !!el.chatArea, 'in DOM:', el.chatArea && document.contains(el.chatArea)); const session = state.sessions.find(s => s.id === id); if (!session) { console.warn('[BUILDER] Session not found for ID:', id); throw new Error('Session not found'); } state.currentSessionId = session.id; console.log('[BUILDER] Session selected by ID:', { id: session.id, appId: session.appId, title: session.title }); state.currentCli = session.cli || 'opencode'; if (el.cliSelect) el.cliSelect.value = state.currentCli; try { await loadModels(state.currentCli); } catch (err) { console.warn('[BUILDER] Failed to load models:', err); } restorePlanStateFromSession(session); applyEntryMode(session); console.log('[BUILDER] About to refresh current session from server...'); // Refresh session data from server and get fresh data const freshSession = await refreshCurrentSession(); console.log('[BUILDER] Session refresh complete, freshSession:', freshSession ? 'yes' : 'no'); // Ensure that UI is updated with the new session if (freshSession) { console.log('[BUILDER] Rendering UI with fresh session data'); renderSessionMeta(freshSession); renderMessages(freshSession); // Wait for content to fully render before scrolling // Use multiple delays to catch content that loads asynchronously setTimeout(() => scrollChatToBottom(), 100); setTimeout(() => scrollChatToBottom(), 500); setTimeout(() => scrollChatToBottom(), 1000); } else { // If refresh failed, use the local session data instead console.warn('[BUILDER] Using local session data for UI render'); renderSessionMeta(session); renderMessages(session); // Wait for content to fully render before scrolling setTimeout(() => scrollChatToBottom(), 100); setTimeout(() => scrollChatToBottom(), 500); setTimeout(() => scrollChatToBottom(), 1000); } // Update URL to reflect the new session (but don't reload page) const url = new URL(window.location); url.searchParams.set('session', session.id); window.history.replaceState({}, '', url); console.log('[BUILDER] Updated URL to:', url.href); console.log('[BUILDER] Session selection complete, new currentSessionId:', state.currentSessionId); return session; }; // Expose for builder.html window.selectSession = selectSession; // 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 = async (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') { // Server is restarting, notify user and prepare for reconnection message.status = 'queued'; setStatus('Server restarting, your session will be restored...'); renderMessages(session); // Keep the connection open to allow automatic reconnection return; } else if (data.type === 'start') { message.status = 'running'; setStatus('OpenCode is responding...'); startUsagePolling(); // Start aggressive polling when OpenCode starts // Keep loading indicator spinning - don't hide when OpenCode starts // Clear todos when a new message starts clearTodos(); } 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'; // Keep loading indicator spinning during streaming - don't hide on first chunk // Re-render messages to show new content immediately renderMessages(session); setStatus('Streaming response...'); } else if (data.type === 'todos') { // Handle todo updates from OpenCode if (data.todos && Array.isArray(data.todos)) { message.todos = data.todos; updateTodos(data.todos, messageId); console.log('[TODOS] Received todo update via SSE', { count: data.todos.length, messageId }); } } 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); // Clear todos when message completes (to start fresh for next message) clearTodos(); renderMessages(session); setStatus('Complete'); // Update usage meter immediately when opencode completes stopUsagePolling(); // Stop aggressive polling // Immediate usage update for better UX await loadUsageSummary().catch(err => { console.warn('[USAGE] Immediate usage update failed, will retry:', err.message); }); // Final update after delay to ensure server persistence setTimeout(() => { loadUsageSummary().catch(err => { console.warn('[USAGE] Final usage update failed:', err.message); }); }, 2000); // Restart polling after message completes to keep usage meter updating startUsagePolling(); // Hide loading indicator only when message is confirmed as done if (message.status === 'done') { hideLoadingIndicator(); } // Auto-scroll to bottom after message completes scrollChatToBottom(); // Update session list (no-op in builder) renderSessions(); } else if (data.type === 'error') { message.error = data.error || 'Unknown error'; message.reply = data.content || message.partialOutput || ''; message.status = 'error'; message.finishedAt = data.timestamp; message.outputType = data.outputType; message.opencodeExitCode = data.exitCode; eventSource.close(); state.activeStreams.delete(messageId); // Clear todos on error clearTodos(); renderMessages(session); scrollChatToBottom(); if (!message.isBackgroundContinuation) { setStatus('Error: ' + (data.error || 'Unknown error')); } stopUsagePolling(); // Update usage on error too await loadUsageSummary().catch(err => { console.warn('[USAGE] Usage update after error failed:', err.message); }); setTimeout(() => { loadUsageSummary().catch(err => { console.warn('[USAGE] Final usage update after error failed:', err.message); }); }, 2000); // Restart polling after message completes/errors to keep usage meter updating startUsagePolling(); // Hide loading indicator only when message is confirmed as error if (message.status === 'error') { hideLoadingIndicator(); } renderSessions(); } } catch (err) { console.error('Failed to parse SSE message', err); } }; eventSource.onerror = async (err) => { console.error('SSE error', err); eventSource.close(); state.activeStreams.delete(messageId); // Check if server is restarting by examining message status 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 console.log('Server restart detected, attempting reconnection...', { messageId, sessionId }); let reconnectAttempts = 0; const maxReconnectAttempts = 30; // 30 seconds max const reconnectInterval = setInterval(async () => { reconnectAttempts++; try { const { session: freshSession } = await api(`/api/sessions/${sessionId}`); const freshMessage = freshSession?.messages?.find(m => m.id === messageId); if (freshMessage && freshMessage.status !== 'queued') { clearInterval(reconnectInterval); const idx = state.sessions.findIndex(s => s.id === sessionId); if (idx !== -1) state.sessions[idx] = freshSession; renderSessionMeta(freshSession); renderMessages(freshSession); scrollChatToBottom(); setStatus('Reconnected to server'); console.log('Successfully reconnected after server restart'); } } catch (e) { // Server still down if (reconnectAttempts >= maxReconnectAttempts) { clearInterval(reconnectInterval); if (message) { message.status = 'error'; message.error = 'Server restart took too long. Please refresh and try again.'; renderMessages(session); scrollChatToBottom(); } setStatus('Server reconnection failed'); } } }, 1000); return; } // When SSE connection fails, determine if it's a user action or server issue // Check if page is visible - if not, likely user left page const pageVisible = !document.hidden; const userLeftPage = !pageVisible; console.log('SSE connection failed', { messageId, sessionId, pageVisible, userLeftPage, messageStatus: message?.status }); // Stop usage polling and update usage for non-restart failures stopUsagePolling(); await loadUsageSummary().catch(err => { console.warn('[USAGE] Usage update after SSE error failed:', err.message); }); // Restart polling to keep usage meter updated startUsagePolling(); // First, refresh the session to get the latest state from the server // This handles the case where the message actually completed but the // client missed the final event (e.g., user slept their device) (async () => { try { const { session: freshSession } = await api(`/api/sessions/${sessionId}`); const freshMessage = freshSession?.messages?.find(m => m.id === messageId); if (freshMessage && (freshMessage.status === 'done' || freshMessage.status === 'error')) { console.log('Message completed on server, displaying final state', { messageId, status: freshMessage.status, exitCode: freshMessage.opencodeExitCode, tokenExtractionFailed: freshMessage.tokenExtractionFailed, potentiallyIncomplete: freshMessage.potentiallyIncomplete }); // Message is actually done, just refresh to show the final state const idx = state.sessions.findIndex(s => s.id === sessionId); if (idx !== -1) state.sessions[idx] = freshSession; renderSessionMeta(freshSession); renderMessages(freshSession); // Hide loading indicator only when message is confirmed as done or error if (freshMessage.status === 'done' || freshMessage.status === 'error') { hideLoadingIndicator(); } // Detect server-side errors that need recovery // This includes: // 1. Token extraction failure (indicates server-side issue) // 2. Message marked as potentially incomplete // 3. Actual OpenCode errors with non-zero exit codes const tokenExtractionFailed = freshMessage.tokenExtractionFailed || false; const potentiallyIncomplete = freshMessage.potentiallyIncomplete || false; const partialOutputLength = freshMessage.partialOutput?.length || 0; const hasSubstantialOutput = partialOutputLength > 500; const isTinyOutput = partialOutputLength > 0 && partialOutputLength <= 50; const connectionErrorCodes = [null, undefined, 'ECONNRESET', 'ETIMEDOUT', 'ENOTCONN']; const isConnectionError = connectionErrorCodes.includes(freshMessage.opencodeExitCode) || (typeof freshMessage.opencodeExitCode === 'string' && freshMessage.opencodeExitCode.includes('timeout')); // Check if this is a server-side error that needs continuation // Server-side errors (token extraction failure) should trigger continuation even if page is not visible // because server-side processing continues independently of client visibility const isServerError = tokenExtractionFailed || potentiallyIncomplete; const isOpencodeError = freshMessage.status === 'error' && freshMessage.opencodeExitCode && freshMessage.opencodeExitCode !== 0; if ((isServerError && !hasSubstantialOutput) || (pageVisible && isOpencodeError && !isConnectionError && !hasSubstantialOutput) || (pageVisible && isTinyOutput && !isConnectionError)) { console.log('Server-side error detected, triggering automatic continuation', { messageId, status: freshMessage.status, isServerError, tokenExtractionFailed, potentiallyIncomplete, isOpencodeError, exitCode: freshMessage.opencodeExitCode, partialOutputLength, isTinyOutput, hasSubstantialOutput, isConnectionError, pageVisible }); // Only trigger continuation if page is visible to avoid UI issues when user left if (pageVisible) { setTimeout(() => handleEarlyTermination(sessionId, messageId), 500); } } else if (!pageVisible) { console.log('Page not visible, skipping automatic UI actions (server-side processing continues)', { messageId, pageVisible, tokenExtractionFailed, potentiallyIncomplete }); } return; } // If message is still running on server, just poll - don't auto-retry console.log('Message still running on server, will continue polling', { messageId, status: freshMessage?.status }); setTimeout(() => refreshCurrentSession(), 1000); } catch (e) { console.error('Failed to check server state, will poll for updates', e); setTimeout(() => refreshCurrentSession(), 1000); } })(); }; state.activeStreams.set(messageId, eventSource); } async function handleEarlyTermination(sessionId, messageId) { const session = state.sessions.find(s => s.id === sessionId); const message = session?.messages?.find(m => m.id === messageId); if (!message) { console.log('handleEarlyTermination: message not found', { messageId }); return; } // Allow handling of messages marked as 'done' but potentially incomplete // This catches cases where token extraction failed but message was still marked done const isNormalCompletion = message.status === 'done' && !message.potentiallyIncomplete; const isError = message.status === 'error'; if (isNormalCompletion || isError) { console.log('handleEarlyTermination: skipping - message is already completed or in error state', { messageId, status: message.status, potentiallyIncomplete: message.potentiallyIncomplete }); return; } console.log('Handling early termination', { messageId, partialLength: message.partialOutput?.length, status: message.status, potentiallyIncomplete: message.potentiallyIncomplete, tokenExtractionFailed: message.tokenExtractionFailed }); // Don't trigger client-side model fallback - let the server handle fallback through its proper chain // The server fallback sequence: preferred model -> all configured models -> default -> ultimate backup model // Client-side fallback was bypassing this chain and jumping straight to the ultimate backup model console.log('Early termination detected, letting server handle fallback', { messageId, status: message.status }); setTimeout(() => refreshCurrentSession(), 1000); } function checkFallbackCriteria(message) { // Only trigger fallback on actual opencode errors with non-zero exit codes // Don't trigger on partial output or SSE disconnections const isActualError = message.status === 'error'; const hasErrorExitCode = message.opencodeExitCode && message.opencodeExitCode !== 0; // Check for token extraction failure (server-side error) // This indicates a problem with streaming/tracking, not with user leaving the page const tokenExtractionFailed = message.tokenExtractionFailed || false; const potentiallyIncomplete = message.potentiallyIncomplete || false; const isServerError = tokenExtractionFailed || potentiallyIncomplete; // Check if message has substantial output (indicates model was working fine) const partialOutputLength = message.partialOutput?.length || 0; const hasSubstantialOutput = partialOutputLength > 500; const isTinyOutput = partialOutputLength > 0 && partialOutputLength <= 50; // Don't trigger if message has substantial output - model was working fine if (hasSubstantialOutput) { console.log('Skipping fallback - message has substantial output', { messageId: message.id, partialOutputLength, status: message.status }); return false; } // Check for connection errors that shouldn't trigger fallback const connectionErrorCodes = [null, undefined, 'ECONNRESET', 'ETIMEDOUT', 'ENOTCONN']; const isConnectionError = connectionErrorCodes.includes(message.opencodeExitCode) || (typeof message.opencodeExitCode === 'string' && message.opencodeExitCode.includes('timeout')); // Don't trigger on connection errors (user left page, network issues) if (isConnectionError) { console.log('Skipping fallback - connection error detected', { messageId: message.id, exitCode: message.opencodeExitCode, isConnectionError }); return false; } console.log('Checking fallback criteria', { messageId: message.id, status: message.status, exitCode: message.opencodeExitCode, isActualError, hasErrorExitCode, tokenExtractionFailed, potentiallyIncomplete, isServerError, hasSubstantialOutput, partialOutputLength, isTinyOutput, isConnectionError }); // Trigger fallback for: // 1. Actual opencode errors with non-zero exit codes // 2. Server-side token extraction failures (indicates streaming issue) // 3. Tiny outputs (<= 50 chars) which commonly indicate incomplete responses return (isActualError && hasErrorExitCode) || isServerError || isTinyOutput; } async function triggerModelFallback(messageId, session) { const message = session.messages.find(m => m.id === messageId); if (!message) return; if (message.failoverAttempts?.length >= 2) { console.log('Max fallback attempts reached', { messageId }); setTimeout(() => refreshCurrentSession(), 1000); return; } const currentModel = message.model; const backupModel = getBackupModel(currentModel); if (!backupModel) { console.log('No backup model available', { currentModel }); setTimeout(() => refreshCurrentSession(), 1000); return; } console.log('Switching to backup model', { messageId, from: currentModel, to: backupModel }); const continuationContent = 'continue to ensure that the previous request is fully completed'; try { // Preserve the opencodeSessionId to continue in the same session // Priority order: session.opencodeSessionId > message.opencodeSessionId > session.initialOpencodeSessionId const preservedSessionId = session?.opencodeSessionId || message.opencodeSessionId || session?.initialOpencodeSessionId; const payload = { content: continuationContent, displayContent: '', model: backupModel, cli: 'opencode', isContinuation: true, isBackgroundContinuation: true, originalMessageId: messageId }; // Preserve the opencodeSessionId for session continuity if (preservedSessionId) { payload.opencodeSessionId = preservedSessionId; console.log('[FALLBACK] Preserving opencodeSessionId:', preservedSessionId); } else { console.warn('[FALLBACK] No opencodeSessionId available for continuation'); } // Preserve workspaceDir for file location continuity if (session.workspaceDir) { payload.workspaceDir = session.workspaceDir; } console.log('Triggering continuation with preserved context', { messageId, sessionId: session.id, opencodeSessionId: payload.opencodeSessionId, workspaceDir: payload.workspaceDir, model: backupModel }); const response = await api(`/api/sessions/${session.id}/messages`, { method: 'POST', body: JSON.stringify(payload) }); if (response.message) { streamMessage(session.id, response.message.id); message.status = 'superseded'; message.supersededBy = response.message.id; } } catch (error) { console.error('Failed to trigger fallback', error); setTimeout(() => refreshCurrentSession(), 1000); } } async function handleServerCutOff(session, message) { // This function is called when a message transitions from 'running' to 'queued' // indicating that the server-side OpenCode session was cut off // Don't handle if message is already done or has substantial output if (message.status === 'done' || message.status === 'error') { console.log('[SERVER_CUTOFF] Message already finished, skipping', message.id); return; } // Check if message has substantial partial output (likely near completion) const partialOutputLength = (message.partialOutput || '').length; if (partialOutputLength > 1000) { console.log('[SERVER_CUTOFF] Message has substantial output, likely near completion', { messageId: message.id, partialOutputLength }); return; } console.log('[SERVER_CUTOFF] Automatically continuing implementation after server cut off', { messageId: message.id, sessionId: session.id, partialOutputLength }); // Preserve the opencodeSessionId to continue in the same session // Priority order: session.opencodeSessionId > message.opencodeSessionId > session.initialOpencodeSessionId const preservedSessionId = session?.opencodeSessionId || message.opencodeSessionId || session?.initialOpencodeSessionId; const payload = { content: 'continue to ensure that the previous request is fully completed', displayContent: '', model: message.model || session.model, cli: 'opencode', isContinuation: true, isBackgroundContinuation: true, // Don't show this to user isServerCutOffContinuation: true, originalMessageId: message.id }; // Preserve the opencodeSessionId for session continuity if (preservedSessionId) { payload.opencodeSessionId = preservedSessionId; console.log('[SERVER_CUTOFF] Preserving opencodeSessionId:', preservedSessionId); } else { console.warn('[SERVER_CUTOFF] No opencodeSessionId available for continuation'); } // Preserve workspaceDir for file location continuity if (session.workspaceDir) { payload.workspaceDir = session.workspaceDir; } try { const response = await api(`/api/sessions/${session.id}/messages`, { method: 'POST', body: JSON.stringify(payload) }); if (response.message) { console.log('[SERVER_CUTOFF] Continuation message created', response.message.id); // Start streaming the continuation message streamMessage(session.id, response.message.id); // Mark original message as superseded message.status = 'superseded'; message.supersededBy = response.message.id; } } catch (error) { console.error('[SERVER_CUTOFF] Failed to create continuation message', error); } } function getBackupModel(currentModel) { const normalize = (m) => (m && (m.id || m.name || m)) ? String(m.id || m.name || m).trim() : ''; const configuredModels = (state.models || []).map(normalize).filter(Boolean); const current = (currentModel || '').trim(); const preferredBackup = normalize(window.providerLimits?.opencodeBackupModel || ''); // If we have a preferred backup model, use it if it's different from current // Don't require it to be in configured models since OpenCode CLI can access models directly if (preferredBackup && preferredBackup !== current) { return preferredBackup; } if (!configuredModels.length) return null; // If current model is auto/default or not in list, pick the first configured model if (!current || current === 'auto' || current === 'default' || !configuredModels.includes(current)) { return configuredModels[0]; } // Pick the first different model as backup const fallback = configuredModels.find((m) => m !== current); return fallback || configuredModels[0]; } 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 activeStatuses = new Set(['running', 'queued']); const completedStatuses = new Set(['done', 'error']); const old = state.sessions.find((s) => s.id === session.id); const oldStatuses = new Map(); if (old && Array.isArray(old.messages)) { old.messages.forEach((msg) => { if (activeStatuses.has(msg.status)) { oldStatuses.set(msg.id, msg.status); } }); } // Detect messages that transitioned from 'running' to 'queued' - this indicates server cut off const transitionedToQueued = []; if (old && Array.isArray(old.messages) && Array.isArray(session.messages)) { session.messages.forEach((newMsg) => { const oldMsg = old.messages.find(m => m.id === newMsg.id); if (oldMsg && oldMsg.status === 'running' && newMsg.status === 'queued') { console.log('[DETECTION] Message transitioned from running to queued - server cut off detected', { messageId: newMsg.id, oldStatus: oldMsg.status, newStatus: newMsg.status }); transitionedToQueued.push(newMsg); } }); } 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)); // Append any temp messages that the server hasn't returned yet tempMsgs.forEach((m) => { if (!existingIds.has(m.id)) session.messages.push(m); }); // De-duplicate: if server already returned a real (non-temp) message with the same content, // drop the temp message to avoid duplicates 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; }); // Ensure messages are time-ordered session.messages.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); } const idx = state.sessions.findIndex((s) => s.id === session.id); if (idx === -1) state.sessions.unshift(session); else state.sessions[idx] = session; renderSessions(); renderSessionMeta(session); renderMessages(session); const completedMessage = oldStatuses.size > 0 && (session.messages || []).some((msg) => ( oldStatuses.has(msg.id) && completedStatuses.has(msg.status) )); if (completedMessage) { loadUsageSummary().catch((err) => { console.warn('[USAGE] Usage update after completion failed:', err.message); }); } // Handle messages that transitioned to queued - indicates server cut off if (transitionedToQueued.length > 0) { for (const msg of transitionedToQueued) { console.log('[DETECTION] Handling server cut off for message', msg.id); await handleServerCutOff(session, msg); } } // 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); // Return the session so callers can access fresh data return session; } catch (error) { setStatus(error.message); console.error('Failed to refresh session:', error); return null; } } // Expose for builder.html window.refreshCurrentSession = refreshCurrentSession; 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; // Sessions are used for both planning (OpenRouter) and building (OpenCode). // Planning should work even before a model is selected/configured, so fall back // to a server-side default model instead of blocking session creation. const model = state.selectedModelId || (el.modelSelect && el.modelSelect.value) || (isFreePlan() ? 'auto' : 'default'); state.selectedModelId = model || state.selectedModelId; 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.title) payload.title = options.title; if (options.appId) { payload.appId = options.appId; payload.reuseAppId = true; } let session; try { 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; } // Clear builder state for new session (this also clears persisted state) if (typeof window.clearBuilderState === 'function') { window.clearBuilderState(); } else { // Fallback if clearBuilderState not yet defined builderState.mode = 'plan'; builderState.planApproved = false; builderState.lastUserRequest = ''; builderState.lastPlanText = ''; } state.sessions.unshift(session); renderSessions(); // Render session meta immediately with newly created session renderSessionMeta(session); // Set current session ID directly instead of calling selectSession // This avoids refreshing from server which might not have the new session yet state.currentSessionId = session.id; // Set up CLI and model from the session state.currentCli = session.cli || 'opencode'; if (el.cliSelect) el.cliSelect.value = state.currentCli; // Load models for the session's CLI await loadModels(state.currentCli); // Restore builder state from the new session restorePlanStateFromSession(session); applyEntryMode(session); // Clear the chat area for the new session if (el.chatArea) { el.chatArea.innerHTML = '
Send a message to start the conversation.
'; } // Scroll to bottom of chat scrollChatToBottom(); } // Expose for builder.html window.createSession = createSession; // Helper function to ensure we have a valid current session async function ensureCurrentSession() { console.log('[BUILDER] ensureCurrentSession called:', { currentSessionId: state.currentSessionId, sessionsCount: state.sessions?.length || 0 }); if (state.currentSessionId) { // Check if the current session still exists in our sessions array const existingSession = state.sessions?.find(s => s.id === state.currentSessionId); if (existingSession) { console.log('[BUILDER] Current session is valid:', state.currentSessionId); return existingSession; } else { console.log('[BUILDER] Current session ID exists but session not found in local array'); } } // Try to use an existing session from the sessions array if (state.sessions && state.sessions.length > 0) { console.log('[BUILDER] Selecting first available session:', state.sessions[0].id); await selectSession(state.sessions[0].id); return state.sessions[0]; } // No sessions exist, create a new one console.log('[BUILDER] No sessions available, creating new session'); await createSession(); return state.sessions[0]; } // Expose for builder.html window.ensureCurrentSession = ensureCurrentSession; 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; } } // Message sending prevention constants const LAST_SENT_MESSAGE_KEY = 'builder_last_sent_message'; const LAST_SENT_MAX_AGE_MS = 5000; // Prevent resending same message within 5 seconds function getLastSentMessage() { try { const raw = localStorage.getItem(LAST_SENT_MESSAGE_KEY); if (!raw) return null; const data = JSON.parse(raw); if (!data || !data.content) return null; // Check if cache is expired if (Date.now() - data.timestamp > LAST_SENT_MAX_AGE_MS) { localStorage.removeItem(LAST_SENT_MESSAGE_KEY); return null; } return data; } catch (e) { return null; } } function setLastSentMessage(content) { try { const data = { content: content, timestamp: Date.now() }; localStorage.setItem(LAST_SENT_MESSAGE_KEY, JSON.stringify(data)); } catch (e) { console.warn('Failed to set last sent message:', e); } } async function sendMessage() { const content = el.messageInput.value.trim(); if (!content && !pendingAttachments.length) return; // Prevent duplicate sends: check if this exact message was recently sent const lastSent = getLastSentMessage(); if (lastSent && lastSent.content === content) { console.log('[BUILDER] Ignoring duplicate message send attempt - message was recently sent'); el.messageInput.value = ''; clearMessageInputCache(); return; } const displayContent = content; const cli = el.cliSelect ? el.cliSelect.value : (state.currentCli || 'opencode'); state.currentCli = cli; el.messageInput.value = ''; el.messageInput.style.height = 'auto'; clearMessageInputCache(); // Track this message as sent to prevent duplicates setLastSentMessage(content); const model = state.selectedModelId || el.modelSelect?.value || (isFreePlan() ? 'auto' : 'default'); if (!model) { setStatus('Select a model configured by your admin'); return; } const remainingTokens = state.usageSummary?.remaining || 0; if (remainingTokens <= 5000) { const modal = document.getElementById('token-limit-modal'); if (modal) { modal.style.display = 'flex'; if (el.miniSendBtn) el.miniSendBtn.disabled = false; return; } } // Set sending state and update button state.isSending = true; updateSendButtonState(); // Ensure we have a valid current session before proceeding if (!state.currentSessionId) { try { if (state.sessions && state.sessions.length > 0) { console.log('[BUILDER] No current session ID, using existing session:', state.sessions[0].id); await selectSession(state.sessions[0].id); } else { console.log('[BUILDER] No sessions available, creating new session'); await createSession(); } } catch (err) { console.error('[BUILDER] Failed to establish session:', err); setStatus('Failed to establish session: ' + (err.message || 'Unknown error')); if (el.miniSendBtn) el.miniSendBtn.disabled = false; return; } } if (!state.currentSessionId) { console.error('[BUILDER] Still no session ID after session establishment attempts'); setStatus('Unable to establish session. Please refresh and try again.'); if (el.miniSendBtn) el.miniSendBtn.disabled = false; return; } builderState.lastUserRequest = builderState.lastUserRequest || content; const tempMessageId = 'temp-' + Date.now(); const currentSession = state.sessions.find(s => s.id === state.currentSessionId); if (currentSession) { currentSession.messages = currentSession.messages || []; currentSession.messages.push({ id: tempMessageId, content, displayContent, attachments: [...pendingAttachments], model, cli, status: 'queued', createdAt: new Date().toISOString() }); renderMessages(currentSession); } setStatus('Sending...'); showLoadingIndicator('building'); try { let messageContent = content; // if (builderState.mode === 'build' && cli === 'opencode' && !content.includes('proceed with build')) { // const subsequentPromptTemplate = builderState.subsequentPrompt || ''; // const userRequest = builderState.lastUserRequest || content; // const pluginSlug = (currentSession && currentSession.pluginSlug) || 'plugin-name'; // const pluginName = (currentSession && currentSession.pluginName) || `Plugin Compass ${(currentSession && currentSession.title) || 'Plugin'}`; // const promptWithRequest = subsequentPromptTemplate.replace('{{USER_REQUEST}}', userRequest); // const promptWithSlug = promptWithRequest.replace(/{{PLUGIN_SLUG}}/g, pluginSlug).replace(/{{PLUGIN_NAME}}/g, pluginName); // messageContent = promptWithSlug + '\n\n' + content; // } const payload = { content: messageContent, displayContent, model, cli, attachments: pendingAttachments.length ? pendingAttachments : undefined, }; // Preserve opencodeSessionId to continue in the same session if (currentSession && currentSession.opencodeSessionId) { payload.opencodeSessionId = currentSession.opencodeSessionId; console.log('[BUILDER] Preserving opencodeSessionId:', currentSession.opencodeSessionId); } const response = await api(`/api/sessions/${state.currentSessionId}/messages`, { method: 'POST', body: JSON.stringify(payload), }); // Track the message ID being sent for potential cancellation if (response?.message?.id) { state.currentSendingMessageId = response.message.id; console.log('[BUILDER] Tracking message for cancellation:', response.message.id); } // Clear attachments after successful send pendingAttachments.length = 0; renderAttachmentPreview(); // Start SSE streaming immediately so users see output without waiting for polling. if (response?.message?.id) { streamMessage(state.currentSessionId, response.message.id); } else { // No message id (unexpected) - check message status before hiding spinner setTimeout(() => { const session = state.sessions.find(s => s.id === state.currentSessionId); if (session) { const msg = session.messages.find(m => m.id === tempMessageId); if (msg && (msg.status === 'done' || msg.status === 'error')) { hideLoadingIndicator(); } else if (!msg) { hideLoadingIndicator(); } } else { hideLoadingIndicator(); } }, 100); // Update usage even in unexpected cases loadUsageSummary().catch(err => { console.warn('[USAGE] Usage update after unexpected case failed:', err.message); }); } await refreshCurrentSession(); } catch (error) { setStatus(error.message || 'Failed to send'); // Update usage on error loadUsageSummary().catch(err => { console.warn('[USAGE] Usage update after send error failed:', err.message); }); // Hide loading indicator after checking message status setTimeout(() => { const session = state.sessions.find(s => s.id === state.currentSessionId); if (session) { const msg = session.messages.find(m => m.id === tempMessageId); if (msg && (msg.status === 'done' || msg.status === 'error')) { hideLoadingIndicator(); } else if (!msg) { // Temp message was already removed, safe to hide hideLoadingIndicator(); } } else { // No session found, safe to hide hideLoadingIndicator(); } }, 100); // Remove the temp message on error if (currentSession) { currentSession.messages = (currentSession.messages || []).filter(m => m.id !== tempMessageId); renderMessages(currentSession); } } finally { // Reset sending state when message completes, is cancelled, or errors out // Only reset if we're not currently in a cancellation (cancelMessage handles that) if (state.isSending && state.currentSendingMessageId) { const session = state.sessions.find(s => s.id === state.currentSessionId); const messageId = state.currentSendingMessageId; const message = session?.messages.find(m => m.id === messageId); // Reset if message is done, errored, or cancelled if (!message || message.status === 'done' || message.status === 'error' || message.status === 'cancelled') { state.isSending = false; state.currentSendingMessageId = null; updateSendButtonState(); } } } } // Cancel the current message being sent async function cancelMessage() { if (!state.isSending || !state.currentSendingMessageId) { console.log('[CANCEL] No message to cancel'); return; } const messageId = state.currentSendingMessageId; const sessionId = state.currentSessionId; console.log('[CANCEL] Cancelling message:', messageId); setStatus('Cancelling...'); // Close any active SSE streams for this message if (state.activeStreams.has(messageId)) { const stream = state.activeStreams.get(messageId); stream.close(); state.activeStreams.delete(messageId); console.log('[CANCEL] Closed SSE stream for message:', messageId); } // Find and update the message status const session = state.sessions.find(s => s.id === sessionId); if (session) { const message = session.messages.find(m => m.id === messageId); if (message) { message.status = 'cancelled'; message.reply = message.reply || '(Cancelled by user)'; message.cancelled = true; // Show undo/redo buttons for the cancelled message message.showUndoRedo = true; console.log('[CANCEL] Message marked as cancelled:', messageId); } // Remove temp messages const tempMessages = session.messages.filter(m => m.id.startsWith('temp-') && m.status === 'queued'); tempMessages.forEach(tempMsg => { const idx = session.messages.indexOf(tempMsg); if (idx > -1) { session.messages.splice(idx, 1); } }); renderMessages(session); } // Reset sending state state.isSending = false; state.currentSendingMessageId = null; // Reset send button updateSendButtonState(); hideLoadingIndicator(); setStatus('Message cancelled'); // Refresh session to sync with server try { await refreshCurrentSession(); } catch (err) { console.warn('[CANCEL] Failed to refresh session:', err.message); } } // Update send button appearance based on state function updateSendButtonState() { const btn = el.miniSendBtn; if (!btn) return; if (state.isSending) { // Show cancel button (square icon) btn.innerHTML = ` `; btn.title = 'Cancel message'; btn.classList.add('cancel-mode'); btn.disabled = false; } else { // Show send button (paper plane icon) btn.innerHTML = ` `; btn.title = 'Send message'; btn.classList.remove('cancel-mode'); btn.disabled = false; } } // Handle send button click - either send or cancel function handleSendButtonClick(e) { if (state.isSending) { // Cancel the current message cancelMessage(); } else { // Check if we're in plan mode and call the appropriate handler if (builderState.mode === 'plan') { // Use handleSend from builder.html which handles plan mode if (typeof handleSend === 'function') { handleSend(e); } else { console.warn('[BUILDER] handleSend not available'); } } else { // Send a new message in build mode sendMessage(); } } } // Expose for builder.html window.sendMessage = sendMessage; window.cancelMessage = cancelMessage; window.handleSendButtonClick = handleSendButtonClick; function hookEvents() { if (el.newChat) { el.newChat.addEventListener('click', async () => { // Get current session's appId to create new chat within same app const currentSession = state.sessions.find(s => s.id === state.currentSessionId); let appIdToUse = currentSession?.appId || null; if (!appIdToUse) { // Fall back to any available appId from existing sessions for (const session of state.sessions) { if (session.appId) { appIdToUse = session.appId; break; } } } // Create new chat within the same app by passing appId with reuseAppId=true if (appIdToUse) { await createSession({ appId: appIdToUse, reuseAppId: true }); } else { await createSession({}); } }); } if (el.historyBtn) { el.historyBtn.addEventListener('click', openHistoryModal); } if (el.historyClose) { el.historyClose.addEventListener('click', closeHistoryModal); } if (el.historyModal) { el.historyModal.addEventListener('click', (e) => { if (e.target === el.historyModal) closeHistoryModal(); }); } // sendBtn removed - only miniSendBtn is used in builder window.showUpgradeModal = function () { if (state.accountPlan === 'enterprise') { alert('You are already on the Enterprise plan with full access.'); return; } const modal = document.getElementById('upgrade-modal'); if (modal) { modal.style.display = 'flex'; } }; // Upload media button functionality 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(); // Reset input to allow re-uploading the same file el.uploadMediaInput.value = ''; // Check if user is on free plan if (!isPaidPlanClient()) { showUpgradeModal(); 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.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()) { showUpgradeModal(); return; } if (!currentModelSupportsMedia()) { setStatus('This model does not support image uploads. Please select a different model that supports media.'); return; } setStatus('Preparing images...'); el.miniSendBtn.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.miniSendBtn.disabled = false; } }); } else { console.log('Upload media elements NOT found. el.uploadMediaBtn:', el.uploadMediaBtn, 'el.uploadMediaInput:', el.uploadMediaInput); } el.messageInput.addEventListener('input', () => { el.messageInput.style.height = 'auto'; el.messageInput.style.height = (el.messageInput.scrollHeight) + 'px'; // Cache the input content to localStorage for persistence across refreshes cacheMessageInput(el.messageInput.value); }); // 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()) { showUpgradeModal(); return; } if (!currentModelSupportsMedia()) { setStatus('This model does not support image uploads. Please select a different model that supports media.'); 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); } }); 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}`); } } }); } if (el.confirmBuildProceed) { el.confirmBuildProceed.onclick = async () => { if (el.confirmBuildModal) el.confirmBuildModal.style.display = 'none'; if (pendingPlanContent) { await executeBuild(pendingPlanContent); pendingPlanContent = null; } }; } if (el.confirmBuildCancel) { el.confirmBuildCancel.onclick = () => { if (el.confirmBuildModal) el.confirmBuildModal.style.display = 'none'; pendingPlanContent = null; }; } if (el.confirmBuildClose) { el.confirmBuildClose.onclick = () => { if (el.confirmBuildModal) el.confirmBuildModal.style.display = 'none'; pendingPlanContent = null; }; } // Export modal functionality const exportZipBtn = document.getElementById('export-zip-btn'); const exportModal = document.getElementById('export-modal'); const exportCloseBtn = document.getElementById('export-close'); const downloadZipBtn = document.getElementById('download-zip'); const exportStatus = document.getElementById('export-status'); const exportStatusText = document.getElementById('export-status-text'); if (exportZipBtn) { exportZipBtn.addEventListener('click', async () => { if (!state.currentSessionId) { setStatus('No active session to export'); return; } try { exportZipBtn.disabled = true; exportZipBtn.style.opacity = '0.7'; const headers = state.userId ? { 'X-User-Id': state.userId } : {}; const response = await fetch(`/api/export/zip?sessionId=${state.currentSessionId}`, { headers, }); if (!response.ok) { throw new Error('Export failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `wordpress-plugin-${state.currentSessionId}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); } catch (error) { console.error('Export failed:', error); setStatus('Export failed: ' + error.message); } finally { exportZipBtn.disabled = false; exportZipBtn.style.opacity = '1'; } }); } if (exportCloseBtn) { exportCloseBtn.addEventListener('click', () => { if (exportModal) exportModal.style.display = 'none'; }); } // Close modal when clicking outside if (exportModal) { exportModal.addEventListener('click', (e) => { if (e.target === exportModal) { exportModal.style.display = 'none'; } }); } if (downloadZipBtn) { downloadZipBtn.addEventListener('click', async () => { if (!state.currentSessionId) { setStatus('No active session to export'); return; } try { // Show loading state downloadZipBtn.disabled = true; downloadZipBtn.style.opacity = '0.7'; if (exportStatus) { exportStatus.style.display = 'block'; exportStatusText.innerHTML = '
Preparing export...'; } // Make the export request const headers = state.userId ? { 'X-User-Id': state.userId } : {}; const response = await fetch(`/api/export/zip?sessionId=${state.currentSessionId}`, { headers, }); if (!response.ok) { throw new Error('Export failed'); } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `wordpress-plugin-${state.currentSessionId}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); // Show success message if (exportStatus) { exportStatusText.innerHTML = '
Download started!
'; } // Close modal after short delay setTimeout(() => { if (exportModal) exportModal.style.display = 'none'; }, 2000); } catch (error) { console.error('Export failed:', error); setStatus('Export failed: ' + error.message); // Show error state if (exportStatus) { exportStatusText.innerHTML = '
Export failed. Please try again.
'; } } finally { // Reset button state if (downloadZipBtn) { downloadZipBtn.disabled = false; downloadZipBtn.style.opacity = '1'; } } }); } window.addEventListener('click', (e) => { if (e.target === el.confirmBuildModal) { el.confirmBuildModal.style.display = 'none'; pendingPlanContent = null; } }); el.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { if (state.isSending) { cancelMessage(); } else { sendMessage(); } } }); 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(); }); }); if (el.modelSelect) { el.modelSelect.addEventListener('change', async () => { const selected = el.modelSelect.value; // Handle Hobby (free) plan lock - but only if NOT a programmatic change and user didn't just change it if (isFreePlan() && selected !== 'auto' && !programmaticModelChange && !userJustChangedModel) { // For free plans, only reset to auto if the selection is invalid or empty const validModels = state.models.map(m => m.id || m.name || m); if (!selected || !validModels.includes(selected)) { el.modelSelect.value = 'auto'; state.selectedModelId = 'auto'; updateModelSelectDisplay('auto'); updateUsageProgressBar(); syncUploadButtonState(); showBlurredModelPreviewInline(); setStatus('Model selection is automatic on the hobby plan'); return; } } state.selectedModelId = selected; updateModelSelectDisplay(selected); updateUsageProgressBar(); syncUploadButtonState(); if (!selected) return; // Mark that user just changed model (prevent server from overwriting during refresh) if (!programmaticModelChange) { markUserModelChange(); } // Sync selection to current session on server if (state.currentSessionId) { try { await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ model: selected }) }); // Refresh session to confirm change was persisted await refreshCurrentSession(); } catch (e) { console.error('Failed to update model on server:', e); setStatus(`Failed to update model: ${e.message}`); } finally { // Clear the flag after refresh completes - let timer handle it // Don't manually clear here to allow polling to complete } } else { // Clear flag if no session to sync to // Let the timer handle it } }); } // Upgrade header button functionality (for builder page) const upgradeHeaderBtn = document.getElementById('upgrade-header-btn'); if (upgradeHeaderBtn) { upgradeHeaderBtn.addEventListener('click', () => { if (typeof window.showUpgradeModal === 'function' && state.accountPlan !== 'enterprise') { window.showUpgradeModal(); } else if (state.accountPlan === 'enterprise') { alert('You are already on the Enterprise plan with full access.'); } else { window.location.href = '/upgrade'; } }); } const tokenLimitClose = document.getElementById('token-limit-close'); const tokenLimitModal = document.getElementById('token-limit-modal'); if (tokenLimitClose && tokenLimitModal) { tokenLimitClose.addEventListener('click', () => { tokenLimitModal.style.display = 'none'; }); tokenLimitModal.addEventListener('click', (e) => { if (e.target === tokenLimitModal) { tokenLimitModal.style.display = 'none'; } }); const upgradeLink = document.getElementById('token-limit-upgrade'); if (upgradeLink) { if (state.accountPlan === 'enterprise' || state.accountPlan === 'professional') { upgradeLink.style.display = 'none'; } } } } // 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)); } }); // Builder-specific init - loads sessions but doesn't render session list (async function initBuilder() { populateCliSelect(); // Check opencode status on startup checkOpencodeStatus(); // Warm account info early to avoid duplicate slow fetches when page loads quickly try { getAccountInfo(); } catch (_) { /* ignore warm-up errors */ } // Load sessions data immediately ONLY if not already loaded by builder.html // The builder.html DOMContentLoaded handler will call loadSessions() if needed if (!state.sessionsLoaded) { try { const data = await api('/api/sessions'); state.sessions = data.sessions || []; // Ensure we have a valid current session after loading if (!state.currentSessionId && state.sessions.length > 0) { console.log('[BUILDER] Initializing current session from loaded sessions:', state.sessions[0].id); await selectSession(state.sessions[0].id); } } catch (error) { console.error('Failed to load sessions:', error); } finally { state.sessionsLoaded = true; try { window.dispatchEvent(new Event('sessionsLoaded')); } catch (_) { } } } else { // Sessions were already loaded, but we might still need to ensure current session if (!state.currentSessionId && state.sessions && state.sessions.length > 0) { console.log('[BUILDER] Sessions already loaded, ensuring current session:', state.sessions[0].id); await selectSession(state.sessions[0].id); } } // Load models and account plan AFTER sessions are loaded, so we can determine the correct CLI // Load plan FIRST before attaching event handlers to ensure plan checks work correctly try { await loadAccountPlan(); await loadModels(state.currentCli); console.log('Models loaded successfully'); syncUploadButtonState(); } catch (err) { console.warn('Model load failed:', err); } // Attach event handlers AFTER account plan is loaded to avoid race conditions hookEvents(); // Restore cached message input from localStorage (after event handlers are attached) restoreMessageInput(); // 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); // Load provider limits for fallback model selection loadProviderLimits(); // Scroll to bottom after initialization completes - use multiple delays // to catch content that renders asynchronously setTimeout(() => scrollChatToBottom(), 500); setTimeout(() => scrollChatToBottom(), 1000); setTimeout(() => scrollChatToBottom(), 2000); })(); async function loadProviderLimits() { try { const data = await api('/api/provider-limits'); if (data?.opencodeBackupModel) { window.providerLimits = { opencodeBackupModel: data.opencodeBackupModel }; console.log('[PROVIDER-LIMITS] Loaded backup model:', window.providerLimits.opencodeBackupModel); } } catch (err) { console.warn('[PROVIDER-LIMITS] Failed to load provider limits:', err); window.providerLimits = window.providerLimits || {}; } }