// error-panel.js — 작업 실패 시 사용자 응답이 필요한 에러 패널 + WebSocket 패치. // extracted from app.js (v4.3.19-3 split). let _errTaskId = null; let _errTemplate = null; let _errSelectedAction = null; let _errCategory = null; let _errBackdrop = null; function _getOrCreateErrorBackdrop() { if (_errBackdrop) return _errBackdrop; _errBackdrop = document.createElement('div'); _errBackdrop.id = 'error-response-backdrop'; _errBackdrop.style.cssText = 'position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9997;display:none'; _errBackdrop.addEventListener('click', closeErrorResponse); document.body.appendChild(_errBackdrop); return _errBackdrop; } function showErrorResponsePanel(taskId, category, errorMessage, errorDetail, template) { _errTaskId = taskId; _errTemplate = template; _errSelectedAction = null; _errCategory = category; // Title & description const titleEl = document.getElementById('error-response-title'); const descEl = document.getElementById('error-response-desc'); if (titleEl) titleEl.textContent = template?.title || ('\u26a0\ufe0f ' + (category || 'Error').toUpperCase()); if (descEl) descEl.textContent = template?.description || errorMessage || 'The task needs your help to continue.'; // Render options const optionsEl = document.getElementById('error-response-options'); if (optionsEl) { optionsEl.innerHTML = ''; const options = template?.options || [ { id: 'retry_now', label: '\u27f3 Retry Now', icon: '\u27f3' }, { id: 'skip', label: '\u229a Skip Step', icon: '\u229a' }, { id: 'cancel', label: '\u2715 Cancel Task', icon: '\u2715', danger: true }, ]; options.forEach(opt => { const btn = document.createElement('button'); btn.className = 'error-option-btn' + (opt.danger ? ' danger' : ''); btn.dataset.actionId = opt.id; btn.innerHTML = `${escHtml(opt.icon || '')} ${escHtml(opt.label)}${opt.description ? `
${escHtml(opt.description)}` : ''}`; btn.addEventListener('click', () => selectErrorOption(opt)); optionsEl.appendChild(btn); }); } // Clear inputs const inputsEl = document.getElementById('error-response-inputs'); if (inputsEl) { inputsEl.innerHTML = ''; inputsEl.classList.remove('visible'); } // Reset submit button const submitBtn = document.getElementById('error-response-submit-btn'); if (submitBtn) submitBtn.disabled = true; // Show backdrop + panel const backdrop = _getOrCreateErrorBackdrop(); backdrop.style.display = 'block'; const panel = document.getElementById('error-response-panel'); if (panel) panel.classList.add('visible'); // Notification toast bgtToast('\u26a0\ufe0f Task needs help', (template?.title || category || 'Error') + ' — see panel'); } function selectErrorOption(opt) { _errSelectedAction = opt.id; // Highlight selected document.querySelectorAll('.error-option-btn').forEach(b => b.classList.remove('selected')); const btn = document.querySelector(`.error-option-btn[data-action-id="${opt.id}"]`); if (btn) btn.classList.add('selected'); // Show input fields if this option requires them const inputsEl = document.getElementById('error-response-inputs'); if (inputsEl) { inputsEl.innerHTML = ''; inputsEl.classList.remove('visible'); const triggerInputs = opt.triggerInputs || []; const allFields = _errTemplate?.requiredInputs || []; const fieldsToShow = triggerInputs.length > 0 ? allFields.filter(f => triggerInputs.includes(f.id)) : []; if (fieldsToShow.length > 0) { fieldsToShow.forEach(field => { const wrap = document.createElement('div'); wrap.className = 'input-field'; const label = document.createElement('label'); label.textContent = field.label; label.setAttribute('for', 'err-input-' + field.id); const input = document.createElement('input'); input.id = 'err-input-' + field.id; input.type = field.type || 'text'; input.placeholder = field.placeholder || ''; input.dataset.fieldId = field.id; if (field.validation === 'digits_only') { input.inputMode = 'numeric'; input.pattern = '[0-9]*'; input.maxLength = 8; } wrap.appendChild(label); wrap.appendChild(input); inputsEl.appendChild(wrap); }); inputsEl.classList.add('visible'); // Focus first input const firstInput = inputsEl.querySelector('input'); if (firstInput) setTimeout(() => firstInput.focus(), 80); } } // Enable submit unless it's cancel (which submits immediately) const submitBtn = document.getElementById('error-response-submit-btn'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = opt.danger ? '작업 취소' : '계속'; } } function closeErrorResponse() { _errTaskId = null; _errTemplate = null; _errSelectedAction = null; _errCategory = null; const backdrop = document.getElementById('error-response-backdrop'); if (backdrop) backdrop.style.display = 'none'; const panel = document.getElementById('error-response-panel'); if (panel) panel.classList.remove('visible'); } async function submitErrorResponse() { if (!_errTaskId || !_errSelectedAction) return; const action = _errSelectedAction; const category = _errCategory; // Collect input values const inputs = {}; document.querySelectorAll('#error-response-inputs input[data-field-id]').forEach(el => { if (el.value.trim()) inputs[el.dataset.fieldId] = el.value.trim(); }); // Validate required fields for credential action if (action === 'credentials') { if (!inputs.email) { const emailEl = document.getElementById('err-input-email'); if (emailEl) { emailEl.style.borderColor = 'var(--err)'; emailEl.focus(); } return; } if (!inputs.password) { const pwEl = document.getElementById('err-input-password'); if (pwEl) { pwEl.style.borderColor = 'var(--err)'; pwEl.focus(); } return; } } if (action === 'verification_code') { if (!inputs.code) { const codeEl = document.getElementById('err-input-code'); if (codeEl) { codeEl.style.borderColor = 'var(--err)'; codeEl.focus(); } return; } } // Disable submit to prevent double-send const submitBtn = document.getElementById('error-response-submit-btn'); if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '전송 중…'; } try { const resp = await fetch(`/api/bg-tasks/${_errTaskId}/error-response`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ action, category, inputs }), }); const data = await resp.json(); if (data.success) { closeErrorResponse(); if (data.resumed) { bgtToast('\u2705 Task resuming', 'Agent is continuing with your response'); // Refresh the kanban board to show updated status if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 800); } else { bgtToast('Task cancelled', 'Task has been stopped'); if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 400); } } else { bgtToast('\u274c Error', data.error || 'Failed to submit response'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; } } } catch (err) { console.error('[ErrorResponse] Submit failed:', err); bgtToast('\u274c Network error', 'Could not submit response'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; } } } // Hook into the existing WebSocket message handler // We extend the onmessage handler after the page loads (function patchWebSocketForErrorResponse() { function applyPatch() { // Find the ws variable — it’s declared in a closure, so we intercept via MutationObserver // Alternative: override addEventListener on WebSocket prototype to intercept messages const _origOnMessage = WebSocket.prototype.__defineGetter__ ? null : null; // not needed — use event listener approach below // We hook into the document-level custom event that server-v2’s WS handler // already dispatches. If that’s not available, we’ll patch the WS directly. document.addEventListener('ws:message', handleErrorResponseWsEvent); // Also directly patch any open WebSocket connections by overriding WebSocket prototype const _OrigWS = window.WebSocket; window.WebSocket = function(...args) { const ws = new _OrigWS(...args); const _origAddEventListener = ws.addEventListener.bind(ws); ws.addEventListener = function(type, listener, ...rest) { if (type === 'message') { const wrappedListener = function(e) { try { const msg = JSON.parse(e.data); if (msg.type === 'task_error_requires_response') { handleErrorResponseWsEvent({ detail: msg }); } } catch {} return listener.call(this, e); }; return _origAddEventListener('message', wrappedListener, ...rest); } return _origAddEventListener(type, listener, ...rest); }; // Also patch onmessage setter let _onmsgHandler = null; Object.defineProperty(ws, 'onmessage', { get: () => _onmsgHandler, set: (fn) => { _onmsgHandler = fn; _origAddEventListener('message', (e) => { try { const msg = JSON.parse(e.data); if (msg.type === 'task_error_requires_response') { handleErrorResponseWsEvent({ detail: msg }); } } catch {} if (_onmsgHandler) _onmsgHandler.call(ws, e); }); }, configurable: true, }); return ws; }; // Copy static properties Object.assign(window.WebSocket, _OrigWS); window.WebSocket.prototype = _OrigWS.prototype; window.WebSocket.CONNECTING = _OrigWS.CONNECTING; window.WebSocket.OPEN = _OrigWS.OPEN; window.WebSocket.CLOSING = _OrigWS.CLOSING; window.WebSocket.CLOSED = _OrigWS.CLOSED; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', applyPatch); } else { applyPatch(); } })(); function handleErrorResponseWsEvent(e) { const msg = e.detail || e; if (!msg || msg.type !== 'task_error_requires_response') return; showErrorResponsePanel( msg.taskId, msg.errorCategory, msg.errorMessage, msg.errorDetail, msg.template, ); }