// 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';
}
}
// Always show model selector above message input for both plan and build modes
if (el.modelSelectWrap) {
el.modelSelectWrap.style.display = 'inline-flex';
}
if (typeof window.updateUsageProgressBar === 'function') {
window.updateUsageProgressBar();
}
}
async function proceedWithBuild(planContent) {
if (!planContent) return;
pendingPlanContent = planContent;
// Always ensure model selector is visible
if (el.modelSelectWrap) {
el.modelSelectWrap.style.display = 'inline-flex';
}
// Show confirmation modal before proceeding
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) await 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...';
}
// 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) {
// Helper function to strip markdown formatting from user input
function stripMarkdownFormatting(text) {
if (!text || typeof text !== 'string') return text;
let cleaned = text;
cleaned = cleaned.replace(/\*\*([^*]+)\*\*/g, '$1');
cleaned = cleaned.replace(/\*\*/g, '');
cleaned = cleaned.replace(/^##\s+/gm, '');
cleaned = cleaned.replace(/##/g, '');
return cleaned;
}
// 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 and status for plan messages (OpenRouter messages)
const isPlanMessage = (msg.cli !== 'opencode') || (msg.phase === 'plan');
const modelBadge = '';
const statusChip = isPlanMessage ? '' : `${status}`;
userMeta.innerHTML = `
You
${modelBadge}
${statusChip}
`;
const userBody = document.createElement('div');
userBody.className = 'body';
const contentToRender = isPlanMessage ? stripMarkdownFormatting(msg.displayContent || msg.content || '') : (msg.displayContent || msg.content || '');
userBody.appendChild(renderContentWithTodos(contentToRender));
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);
const isOpencodeMsg = msg.cli === 'opencode' || msg.phase === 'build';
const isLatestMessage = latestMessage && latestMessage.id === msg.id;
const isServerRestartError = msg.error === 'Server restart took too long. Please refresh and try again.';
const shouldShowUndoRedo = isLatestMessage && (status === 'done' || status === 'error' || status === 'cancelled') && (isOpencodeMsg || isServerRestartError);
const shouldRenderAssistantCard = msg.reply || msg.error || (status === 'running' && msg.partialOutput) || shouldShowUndoRedo;
if (shouldRenderAssistantCard) {
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
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';
// Check if the plan message is asking for clarification (not ready to build yet)
const replyContent = msg.reply || msg.partialOutput || '';
const clarificationPatterns = [
/To create a complete plan[,.\s\w]*I need clarification/i,
/need clarification on a few points/i,
/before I can create a plan/i,
/I have a few questions/i,
/need more (?:details|information|context)/i,
/need some clarification/i,
/could you clarify/i,
/I need to ask/i
];
const needsClarification = clarificationPatterns.some(pattern => pattern.test(replyContent));
if (isOpenRouterMessage && hasReply && (isPlanPhase || isBuildPhase) && !needsClarification) {
const proceedBtn = document.createElement('button');
proceedBtn.className = 'primary';
proceedBtn.style.marginTop = '12px';
proceedBtn.style.width = '100%';
proceedBtn.textContent = 'Proceed with Build';
proceedBtn.onclick = () => proceedWithBuild(replyContent);
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
// Only restore if the latest message is not done or error
const latestMsg = session.messages?.length > 0
? [...session.messages].reverse().find(msg => !msg.isBackgroundContinuation)
: null;
const shouldShowLoading = latestMsg && latestMsg.status !== 'done' && latestMsg.status !== 'error' && latestMsg.status !== 'cancelled';
if (loadingIndicator && shouldShowLoading) {
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);
}
} else if (loadingIndicator && !shouldShowLoading) {
// Remove the loading indicator if message is done/error
loadingIndicator.remove();
}
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
// Update loading indicator text to show we're now building
const loadingIndicator = document.getElementById('loading-indicator');
if (loadingIndicator) {
const statusText = loadingIndicator.querySelector('.loading-status-text');
const detailText = loadingIndicator.querySelector('.loading-detail-text');
if (statusText && statusText.textContent === 'Starting build process') {
statusText.textContent = 'Building plugin';
}
if (detailText) {
detailText.textContent = 'Generating code and files...';
}
}
// 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();
if (message.todos && Array.isArray(message.todos)) {
message.todos = [];
}
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();
if (message.todos && Array.isArray(message.todos)) {
message.todos = [];
}
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') {
// Check if this is a plan message (don't set error for plan messages, just log)
const isPlanMessage = message.phase === 'plan' || message.cli !== 'opencode';
if (isPlanMessage) {
// For plan messages, just log and don't modify the message
console.log('[SSE] Plan message encountered server restart, preserving original content', { messageId, sessionId });
hideLoadingIndicator();
setStatus('Service temporarily unavailable. Please try again in a moment.');
return;
}
// For OpenCode messages, 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 = '