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