// IronClaw Web Gateway - Client let token = ''; let eventSource = null; let logEventSource = null; let currentTab = 'chat'; let currentThreadId = null; let assistantThreadId = null; let hasMore = false; let oldestTimestamp = null; let loadingOlder = false; let jobEvents = new Map(); // job_id -> Array of events let jobListRefreshTimer = null; const JOB_EVENTS_CAP = 500; // --- Auth --- function authenticate() { token = document.getElementById('token-input').value.trim(); if (!token) { document.getElementById('auth-error').textContent = 'Token required'; return; } // Test the token against the health-ish endpoint (chat/threads requires auth) apiFetch('/api/chat/threads') .then(() => { sessionStorage.setItem('ironclaw_token', token); document.getElementById('auth-screen').style.display = 'none'; document.getElementById('app').style.display = 'flex'; // Strip token from URL so it's not visible in the address bar const cleaned = new URL(window.location); cleaned.searchParams.delete('token'); window.history.replaceState({}, '', cleaned.pathname + cleaned.search); connectSSE(); connectLogSSE(); startGatewayStatusPolling(); loadThreads(); loadMemoryTree(); loadJobs(); }) .catch(() => { sessionStorage.removeItem('ironclaw_token'); document.getElementById('auth-screen').style.display = ''; document.getElementById('app').style.display = 'none'; document.getElementById('auth-error').textContent = 'Invalid token'; }); } document.getElementById('token-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') authenticate(); }); // Auto-authenticate from URL param or saved session (function autoAuth() { const params = new URLSearchParams(window.location.search); const urlToken = params.get('token'); if (urlToken) { document.getElementById('token-input').value = urlToken; authenticate(); return; } const saved = sessionStorage.getItem('ironclaw_token'); if (saved) { document.getElementById('token-input').value = saved; // Hide auth screen immediately to prevent flash, authenticate() will // restore it if the token turns out to be invalid. document.getElementById('auth-screen').style.display = 'none'; document.getElementById('app').style.display = 'flex'; authenticate(); } })(); // --- API helper --- function apiFetch(path, options) { const opts = options || {}; opts.headers = opts.headers || {}; opts.headers['Authorization'] = 'Bearer ' + token; if (opts.body && typeof opts.body === 'object') { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.body); } return fetch(path, opts).then((res) => { if (!res.ok) throw new Error(res.status + ' ' + res.statusText); return res.json(); }); } // --- SSE --- function connectSSE() { if (eventSource) eventSource.close(); eventSource = new EventSource('/api/chat/events?token=' + encodeURIComponent(token)); eventSource.onopen = () => { document.getElementById('sse-dot').classList.remove('disconnected'); document.getElementById('sse-status').textContent = 'Connected'; }; eventSource.onerror = () => { document.getElementById('sse-dot').classList.add('disconnected'); document.getElementById('sse-status').textContent = 'Reconnecting...'; }; eventSource.addEventListener('response', (e) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; addMessage('assistant', data.content); setStatus(''); enableChatInput(); // Refresh thread list so new titles appear after first message loadThreads(); }); eventSource.addEventListener('thinking', (e) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; setStatus(data.message, true); }); eventSource.addEventListener('tool_started', (e) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; setStatus('Running tool: ' + data.name, true); }); eventSource.addEventListener('tool_completed', (e) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; const icon = data.success ? '\u2713' : '\u2717'; setStatus('Tool ' + data.name + ' ' + icon); }); eventSource.addEventListener('stream_chunk', (e) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; appendToLastAssistant(data.content); }); eventSource.addEventListener('status', (e) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; setStatus(data.message); // "Done" and "Awaiting approval" are terminal signals from the agent: // the agentic loop finished, so re-enable input as a safety net in case // the response SSE event is empty or lost. if (data.message === 'Done' || data.message === 'Awaiting approval') { enableChatInput(); } }); eventSource.addEventListener('job_started', (e) => { const data = JSON.parse(e.data); showJobCard(data); }); eventSource.addEventListener('approval_needed', (e) => { const data = JSON.parse(e.data); showApproval(data); }); eventSource.addEventListener('auth_required', (e) => { const data = JSON.parse(e.data); showAuthCard(data); }); eventSource.addEventListener('auth_completed', (e) => { const data = JSON.parse(e.data); removeAuthCard(data.extension_name); showToast(data.message, 'success'); enableChatInput(); }); eventSource.addEventListener('error', (e) => { if (e.data) { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; addMessage('system', 'Error: ' + data.message); enableChatInput(); } }); // Job event listeners (activity stream for all sandbox jobs) const jobEventTypes = [ 'job_message', 'job_tool_use', 'job_tool_result', 'job_status', 'job_result' ]; for (const evtType of jobEventTypes) { eventSource.addEventListener(evtType, (e) => { const data = JSON.parse(e.data); const jobId = data.job_id; if (!jobId) return; if (!jobEvents.has(jobId)) jobEvents.set(jobId, []); const events = jobEvents.get(jobId); events.push({ type: evtType, data: data, ts: Date.now() }); // Cap per-job events to prevent memory leak while (events.length > JOB_EVENTS_CAP) events.shift(); // If the Activity tab is currently visible for this job, refresh it refreshActivityTab(jobId); // Auto-refresh job list when on jobs tab (debounced) if ((evtType === 'job_result' || evtType === 'job_status') && currentTab === 'jobs' && !currentJobId) { clearTimeout(jobListRefreshTimer); jobListRefreshTimer = setTimeout(loadJobs, 200); } // Clean up finished job events after a viewing window if (evtType === 'job_result') { setTimeout(() => jobEvents.delete(jobId), 60000); } }); } } // Check if an SSE event belongs to the currently viewed thread. // Events without a thread_id (legacy) are always shown. function isCurrentThread(threadId) { if (!threadId) return true; if (!currentThreadId) return true; return threadId === currentThreadId; } // --- Chat --- function sendMessage() { const input = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); const content = input.value.trim(); if (!content) return; addMessage('user', content); input.value = ''; autoResizeTextarea(input); setStatus('Sending...', true); sendBtn.disabled = true; input.disabled = true; apiFetch('/api/chat/send', { method: 'POST', body: { content, thread_id: currentThreadId || undefined }, }).catch((err) => { addMessage('system', 'Failed to send: ' + err.message); setStatus(''); enableChatInput(); }); } function enableChatInput() { const input = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); sendBtn.disabled = false; input.disabled = false; input.focus(); } function sendApprovalAction(requestId, action) { apiFetch('/api/chat/approval', { method: 'POST', body: { request_id: requestId, action: action, thread_id: currentThreadId }, }).catch((err) => { addMessage('system', 'Failed to send approval: ' + err.message); }); // Disable buttons and show confirmation on the card const card = document.querySelector('.approval-card[data-request-id="' + requestId + '"]'); if (card) { const buttons = card.querySelectorAll('.approval-actions button'); buttons.forEach((btn) => { btn.disabled = true; }); const actions = card.querySelector('.approval-actions'); const label = document.createElement('span'); label.className = 'approval-resolved'; const labelText = action === 'approve' ? 'Approved' : action === 'always' ? 'Always approved' : 'Denied'; label.textContent = labelText; actions.appendChild(label); } } function renderMarkdown(text) { if (typeof marked !== 'undefined') { let html = marked.parse(text); // Sanitize HTML output to prevent XSS from tool output or LLM responses. html = sanitizeRenderedHtml(html); // Inject copy buttons into
blocks
html = html.replace(//g, '');
return html;
}
return escapeHtml(text);
}
// Strip dangerous HTML elements and attributes from rendered markdown.
// This prevents XSS from tool output or prompt injection in LLM responses.
function sanitizeRenderedHtml(html) {
html = html.replace(/