// 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,
);
}