// 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(/)<[^<]*)*<\/script>/gi, '');
  html = html.replace(/]*>[\s\S]*?<\/iframe>/gi, '');
  html = html.replace(/]*>[\s\S]*?<\/object>/gi, '');
  html = html.replace(/]*\/?>/gi, '');
  html = html.replace(/]*>[\s\S]*?<\/form>/gi, '');
  html = html.replace(/]*>[\s\S]*?<\/style>/gi, '');
  html = html.replace(/]*\/?>/gi, '');
  html = html.replace(/]*\/?>/gi, '');
  html = html.replace(/]*\/?>/gi, '');
  // Remove event handler attributes (onclick, onerror, onload, etc.)
  html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, '');
  html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, '');
  html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
  // Remove javascript: and data: URLs in href/src attributes
  html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="');
  html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="');
  return html;
}

function copyCodeBlock(btn) {
  const pre = btn.parentElement;
  const code = pre.querySelector('code');
  const text = code ? code.textContent : pre.textContent;
  navigator.clipboard.writeText(text).then(() => {
    btn.textContent = 'Copied!';
    setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
  });
}

function addMessage(role, content) {
  const container = document.getElementById('chat-messages');
  const div = document.createElement('div');
  div.className = 'message ' + role;
  if (role === 'user') {
    div.textContent = content;
  } else {
    div.setAttribute('data-raw', content);
    div.innerHTML = renderMarkdown(content);
  }
  container.appendChild(div);
  container.scrollTop = container.scrollHeight;
}

function appendToLastAssistant(chunk) {
  const container = document.getElementById('chat-messages');
  const messages = container.querySelectorAll('.message.assistant');
  if (messages.length > 0) {
    const last = messages[messages.length - 1];
    const raw = (last.getAttribute('data-raw') || '') + chunk;
    last.setAttribute('data-raw', raw);
    last.innerHTML = renderMarkdown(raw);
    container.scrollTop = container.scrollHeight;
  } else {
    addMessage('assistant', chunk);
  }
}

function setStatus(text, spinning) {
  const el = document.getElementById('chat-status');
  if (!text) {
    el.innerHTML = '';
    return;
  }
  el.innerHTML = (spinning ? '
' : '') + escapeHtml(text); } function showApproval(data) { const container = document.getElementById('chat-messages'); const card = document.createElement('div'); card.className = 'approval-card'; card.setAttribute('data-request-id', data.request_id); const header = document.createElement('div'); header.className = 'approval-header'; header.textContent = 'Tool requires approval'; card.appendChild(header); const toolName = document.createElement('div'); toolName.className = 'approval-tool-name'; toolName.textContent = data.tool_name; card.appendChild(toolName); if (data.description) { const desc = document.createElement('div'); desc.className = 'approval-description'; desc.textContent = data.description; card.appendChild(desc); } if (data.parameters) { const paramsToggle = document.createElement('button'); paramsToggle.className = 'approval-params-toggle'; paramsToggle.textContent = 'Show parameters'; const paramsBlock = document.createElement('pre'); paramsBlock.className = 'approval-params'; paramsBlock.textContent = data.parameters; paramsBlock.style.display = 'none'; paramsToggle.addEventListener('click', () => { const visible = paramsBlock.style.display !== 'none'; paramsBlock.style.display = visible ? 'none' : 'block'; paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters'; }); card.appendChild(paramsToggle); card.appendChild(paramsBlock); } const actions = document.createElement('div'); actions.className = 'approval-actions'; const approveBtn = document.createElement('button'); approveBtn.className = 'approve'; approveBtn.textContent = 'Approve'; approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve')); const alwaysBtn = document.createElement('button'); alwaysBtn.className = 'always'; alwaysBtn.textContent = 'Always'; alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always')); const denyBtn = document.createElement('button'); denyBtn.className = 'deny'; denyBtn.textContent = 'Deny'; denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny')); actions.appendChild(approveBtn); actions.appendChild(alwaysBtn); actions.appendChild(denyBtn); card.appendChild(actions); container.appendChild(card); container.scrollTop = container.scrollHeight; } function showJobCard(data) { const container = document.getElementById('chat-messages'); const card = document.createElement('div'); card.className = 'job-card'; const icon = document.createElement('span'); icon.className = 'job-card-icon'; icon.textContent = '\u2692'; card.appendChild(icon); const info = document.createElement('div'); info.className = 'job-card-info'; const title = document.createElement('div'); title.className = 'job-card-title'; title.textContent = data.title || 'Sandbox Job'; info.appendChild(title); const id = document.createElement('div'); id.className = 'job-card-id'; id.textContent = (data.job_id || '').substring(0, 8); info.appendChild(id); card.appendChild(info); const viewBtn = document.createElement('button'); viewBtn.className = 'job-card-view'; viewBtn.textContent = 'View Job'; viewBtn.addEventListener('click', () => { switchTab('jobs'); openJobDetail(data.job_id); }); card.appendChild(viewBtn); if (data.browse_url) { const browseBtn = document.createElement('a'); browseBtn.className = 'job-card-browse'; browseBtn.href = data.browse_url; browseBtn.target = '_blank'; browseBtn.textContent = 'Browse'; card.appendChild(browseBtn); } container.appendChild(card); container.scrollTop = container.scrollHeight; } // --- Auth card --- function showAuthCard(data) { // Remove any existing card for this extension first removeAuthCard(data.extension_name); const container = document.getElementById('chat-messages'); const card = document.createElement('div'); card.className = 'auth-card'; card.setAttribute('data-extension-name', data.extension_name); const header = document.createElement('div'); header.className = 'auth-header'; header.textContent = 'Authentication required for ' + data.extension_name; card.appendChild(header); if (data.instructions) { const instr = document.createElement('div'); instr.className = 'auth-instructions'; instr.textContent = data.instructions; card.appendChild(instr); } const links = document.createElement('div'); links.className = 'auth-links'; if (data.auth_url) { const oauthBtn = document.createElement('button'); oauthBtn.className = 'auth-oauth'; oauthBtn.textContent = 'Authenticate with ' + data.extension_name; oauthBtn.addEventListener('click', () => { window.open(data.auth_url, '_blank', 'width=600,height=700'); }); links.appendChild(oauthBtn); } if (data.setup_url) { const setupLink = document.createElement('a'); setupLink.href = data.setup_url; setupLink.target = '_blank'; setupLink.textContent = 'Get your token'; links.appendChild(setupLink); } if (links.children.length > 0) { card.appendChild(links); } // Token input const tokenRow = document.createElement('div'); tokenRow.className = 'auth-token-input'; const tokenInput = document.createElement('input'); tokenInput.type = 'password'; tokenInput.placeholder = 'Paste your API key or token'; tokenInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value); }); tokenRow.appendChild(tokenInput); card.appendChild(tokenRow); // Error display (hidden initially) const errorEl = document.createElement('div'); errorEl.className = 'auth-error'; errorEl.style.display = 'none'; card.appendChild(errorEl); // Action buttons const actions = document.createElement('div'); actions.className = 'auth-actions'; const submitBtn = document.createElement('button'); submitBtn.className = 'auth-submit'; submitBtn.textContent = 'Submit'; submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value)); const cancelBtn = document.createElement('button'); cancelBtn.className = 'auth-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name)); actions.appendChild(submitBtn); actions.appendChild(cancelBtn); card.appendChild(actions); container.appendChild(card); container.scrollTop = container.scrollHeight; tokenInput.focus(); } function removeAuthCard(extensionName) { const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]'); if (card) card.remove(); } function submitAuthToken(extensionName, tokenValue) { if (!tokenValue || !tokenValue.trim()) return; // Disable submit button while in flight const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]'); if (card) { const btns = card.querySelectorAll('button'); btns.forEach((b) => { b.disabled = true; }); } apiFetch('/api/chat/auth-token', { method: 'POST', body: { extension_name: extensionName, token: tokenValue.trim() }, }).then((result) => { if (result.success) { removeAuthCard(extensionName); addMessage('system', result.message); } else { showAuthCardError(extensionName, result.message); } }).catch((err) => { showAuthCardError(extensionName, 'Failed: ' + err.message); }); } function cancelAuth(extensionName) { apiFetch('/api/chat/auth-cancel', { method: 'POST', body: { extension_name: extensionName }, }).catch(() => {}); removeAuthCard(extensionName); enableChatInput(); } function showAuthCardError(extensionName, message) { const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]'); if (!card) return; // Re-enable buttons const btns = card.querySelectorAll('button'); btns.forEach((b) => { b.disabled = false; }); // Show error const errorEl = card.querySelector('.auth-error'); if (errorEl) { errorEl.textContent = message; errorEl.style.display = 'block'; } } function loadHistory(before) { let historyUrl = '/api/chat/history?limit=50'; if (currentThreadId) { historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId); } if (before) { historyUrl += '&before=' + encodeURIComponent(before); } const isPaginating = !!before; if (isPaginating) loadingOlder = true; apiFetch(historyUrl).then((data) => { const container = document.getElementById('chat-messages'); if (!isPaginating) { // Fresh load: clear and render container.innerHTML = ''; for (const turn of data.turns) { addMessage('user', turn.user_input); if (turn.response) { addMessage('assistant', turn.response); } } } else { // Pagination: prepend older messages const savedHeight = container.scrollHeight; const fragment = document.createDocumentFragment(); for (const turn of data.turns) { const userDiv = createMessageElement('user', turn.user_input); fragment.appendChild(userDiv); if (turn.response) { const assistantDiv = createMessageElement('assistant', turn.response); fragment.appendChild(assistantDiv); } } container.insertBefore(fragment, container.firstChild); // Restore scroll position so the user doesn't jump container.scrollTop = container.scrollHeight - savedHeight; } hasMore = data.has_more || false; oldestTimestamp = data.oldest_timestamp || null; }).catch(() => { // No history or no active thread }).finally(() => { loadingOlder = false; removeScrollSpinner(); }); } // Create a message DOM element without appending it (for prepend operations) function createMessageElement(role, content) { const div = document.createElement('div'); div.className = 'message ' + role; if (role === 'user') { div.textContent = content; } else { div.setAttribute('data-raw', content); div.innerHTML = renderMarkdown(content); } return div; } function removeScrollSpinner() { const spinner = document.getElementById('scroll-load-spinner'); if (spinner) spinner.remove(); } // --- Threads --- function loadThreads() { apiFetch('/api/chat/threads').then((data) => { // Pinned assistant thread if (data.assistant_thread) { assistantThreadId = data.assistant_thread.id; const el = document.getElementById('assistant-thread'); const isActive = currentThreadId === assistantThreadId; el.className = 'assistant-item' + (isActive ? ' active' : ''); const meta = document.getElementById('assistant-meta'); const count = data.assistant_thread.turn_count || 0; meta.textContent = count > 0 ? count + ' turns' : ''; } // Regular threads const list = document.getElementById('thread-list'); list.innerHTML = ''; const threads = data.threads || []; for (const thread of threads) { const item = document.createElement('div'); item.className = 'thread-item' + (thread.id === currentThreadId ? ' active' : ''); const label = document.createElement('span'); label.className = 'thread-label'; label.textContent = thread.title || thread.id.substring(0, 8); label.title = thread.title ? thread.title + ' (' + thread.id + ')' : thread.id; item.appendChild(label); const meta = document.createElement('span'); meta.className = 'thread-meta'; meta.textContent = (thread.turn_count || 0) + ' turns'; item.appendChild(meta); item.addEventListener('click', () => switchThread(thread.id)); list.appendChild(item); } // Default to assistant thread on first load if no thread selected if (!currentThreadId && assistantThreadId) { switchToAssistant(); } }).catch(() => {}); } function switchToAssistant() { if (!assistantThreadId) return; currentThreadId = assistantThreadId; hasMore = false; oldestTimestamp = null; loadHistory(); loadThreads(); } function switchThread(threadId) { currentThreadId = threadId; hasMore = false; oldestTimestamp = null; loadHistory(); loadThreads(); } function createNewThread() { apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => { currentThreadId = data.id || null; document.getElementById('chat-messages').innerHTML = ''; setStatus(''); loadThreads(); }).catch((err) => { showToast('Failed to create thread: ' + err.message, 'error'); }); } function toggleThreadSidebar() { const sidebar = document.getElementById('thread-sidebar'); sidebar.classList.toggle('collapsed'); const btn = document.getElementById('thread-toggle-btn'); btn.innerHTML = sidebar.classList.contains('collapsed') ? '»' : '«'; } // Chat input auto-resize and keyboard handling const chatInput = document.getElementById('chat-input'); chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); chatInput.addEventListener('input', () => autoResizeTextarea(chatInput)); // Infinite scroll: load older messages when scrolled near the top document.getElementById('chat-messages').addEventListener('scroll', function () { if (this.scrollTop < 100 && hasMore && !loadingOlder) { loadingOlder = true; // Show spinner at top const spinner = document.createElement('div'); spinner.id = 'scroll-load-spinner'; spinner.className = 'scroll-load-spinner'; spinner.innerHTML = '
Loading older messages...'; this.insertBefore(spinner, this.firstChild); loadHistory(oldestTimestamp); } }); function autoResizeTextarea(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; } // --- Tabs --- document.querySelectorAll('.tab-bar button[data-tab]').forEach((btn) => { btn.addEventListener('click', () => { const tab = btn.getAttribute('data-tab'); switchTab(tab); }); }); function switchTab(tab) { currentTab = tab; document.querySelectorAll('.tab-bar button[data-tab]').forEach((b) => { b.classList.toggle('active', b.getAttribute('data-tab') === tab); }); document.querySelectorAll('.tab-panel').forEach((p) => { p.classList.toggle('active', p.id === 'tab-' + tab); }); if (tab === 'memory') loadMemoryTree(); if (tab === 'jobs') loadJobs(); if (tab === 'routines') loadRoutines(); if (tab === 'logs') applyLogFilters(); if (tab === 'extensions') loadExtensions(); } // --- Memory (filesystem tree) --- let memorySearchTimeout = null; let currentMemoryPath = null; let currentMemoryContent = null; // Tree state: nested nodes persisted across renders // { name, path, is_dir, children: [] | null, expanded: bool, loaded: bool } let memoryTreeState = null; document.getElementById('memory-search').addEventListener('input', (e) => { clearTimeout(memorySearchTimeout); const query = e.target.value.trim(); if (!query) { loadMemoryTree(); return; } memorySearchTimeout = setTimeout(() => searchMemory(query), 300); }); function loadMemoryTree() { // Only load top-level on first load (or refresh) apiFetch('/api/memory/list?path=').then((data) => { memoryTreeState = data.entries.map((e) => ({ name: e.name, path: e.path, is_dir: e.is_dir, children: e.is_dir ? null : undefined, expanded: false, loaded: false, })); renderTree(); }).catch(() => {}); } function renderTree() { const container = document.getElementById('memory-tree'); container.innerHTML = ''; if (!memoryTreeState || memoryTreeState.length === 0) { container.innerHTML = '
No files in workspace
'; return; } renderNodes(memoryTreeState, container, 0); } function renderNodes(nodes, container, depth) { for (const node of nodes) { const row = document.createElement('div'); row.className = 'tree-row'; row.style.paddingLeft = (depth * 16 + 8) + 'px'; if (node.is_dir) { const arrow = document.createElement('span'); arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : ''); arrow.textContent = '\u25B6'; arrow.addEventListener('click', (e) => { e.stopPropagation(); toggleExpand(node); }); row.appendChild(arrow); const label = document.createElement('span'); label.className = 'tree-label dir'; label.textContent = node.name; label.addEventListener('click', () => toggleExpand(node)); row.appendChild(label); } else { const spacer = document.createElement('span'); spacer.className = 'expand-arrow-spacer'; row.appendChild(spacer); const label = document.createElement('span'); label.className = 'tree-label file'; label.textContent = node.name; label.addEventListener('click', () => readMemoryFile(node.path)); row.appendChild(label); } container.appendChild(row); if (node.is_dir && node.expanded && node.children) { const childContainer = document.createElement('div'); childContainer.className = 'tree-children'; renderNodes(node.children, childContainer, depth + 1); container.appendChild(childContainer); } } } function toggleExpand(node) { if (node.expanded) { node.expanded = false; renderTree(); return; } if (node.loaded) { node.expanded = true; renderTree(); return; } // Lazy-load children apiFetch('/api/memory/list?path=' + encodeURIComponent(node.path)).then((data) => { node.children = data.entries.map((e) => ({ name: e.name, path: e.path, is_dir: e.is_dir, children: e.is_dir ? null : undefined, expanded: false, loaded: false, })); node.loaded = true; node.expanded = true; renderTree(); }).catch(() => {}); } function readMemoryFile(path) { currentMemoryPath = path; // Update breadcrumb document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path); document.getElementById('memory-edit-btn').style.display = 'inline-block'; // Exit edit mode if active cancelMemoryEdit(); apiFetch('/api/memory/read?path=' + encodeURIComponent(path)).then((data) => { currentMemoryContent = data.content; const viewer = document.getElementById('memory-viewer'); // Render markdown if it's a .md file if (path.endsWith('.md')) { viewer.innerHTML = '
' + renderMarkdown(data.content) + '
'; viewer.classList.add('rendered'); } else { viewer.textContent = data.content; viewer.classList.remove('rendered'); } }).catch((err) => { currentMemoryContent = null; document.getElementById('memory-viewer').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; }); } function startMemoryEdit() { if (!currentMemoryPath || currentMemoryContent === null) return; document.getElementById('memory-viewer').style.display = 'none'; const editor = document.getElementById('memory-editor'); editor.style.display = 'flex'; const textarea = document.getElementById('memory-edit-textarea'); textarea.value = currentMemoryContent; textarea.focus(); } function cancelMemoryEdit() { document.getElementById('memory-viewer').style.display = ''; document.getElementById('memory-editor').style.display = 'none'; } function saveMemoryEdit() { if (!currentMemoryPath) return; const content = document.getElementById('memory-edit-textarea').value; apiFetch('/api/memory/write', { method: 'POST', body: { path: currentMemoryPath, content: content }, }).then(() => { showToast('Saved ' + currentMemoryPath, 'success'); cancelMemoryEdit(); readMemoryFile(currentMemoryPath); }).catch((err) => { showToast('Save failed: ' + err.message, 'error'); }); } function buildBreadcrumb(path) { const parts = path.split('/'); let html = 'workspace'; let current = ''; for (const part of parts) { current += (current ? '/' : '') + part; html += ' / ' + escapeHtml(part) + ''; } return html; } function searchMemory(query) { apiFetch('/api/memory/search', { method: 'POST', body: { query, limit: 20 }, }).then((data) => { const tree = document.getElementById('memory-tree'); tree.innerHTML = ''; if (data.results.length === 0) { tree.innerHTML = '
No results
'; return; } for (const result of data.results) { const item = document.createElement('div'); item.className = 'search-result'; const snippet = snippetAround(result.content, query, 120); item.innerHTML = '
' + escapeHtml(result.path) + '
' + '
' + highlightQuery(snippet, query) + '
'; item.addEventListener('click', () => readMemoryFile(result.path)); tree.appendChild(item); } }).catch(() => {}); } function snippetAround(text, query, len) { const lower = text.toLowerCase(); const idx = lower.indexOf(query.toLowerCase()); if (idx < 0) return text.substring(0, len); const start = Math.max(0, idx - Math.floor(len / 2)); const end = Math.min(text.length, start + len); let s = text.substring(start, end); if (start > 0) s = '...' + s; if (end < text.length) s = s + '...'; return s; } function highlightQuery(text, query) { if (!query) return escapeHtml(text); const escaped = escapeHtml(text); const queryEscaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp('(' + queryEscaped + ')', 'gi'); return escaped.replace(re, '$1'); } // --- Logs --- const LOG_MAX_ENTRIES = 2000; let logsPaused = false; let logBuffer = []; // buffer while paused function connectLogSSE() { if (logEventSource) logEventSource.close(); logEventSource = new EventSource('/api/logs/events?token=' + encodeURIComponent(token)); logEventSource.addEventListener('log', (e) => { const entry = JSON.parse(e.data); if (logsPaused) { logBuffer.push(entry); return; } appendLogEntry(entry); }); logEventSource.onerror = () => { // Silent reconnect }; } function appendLogEntry(entry) { const output = document.getElementById('logs-output'); // Level filter const levelFilter = document.getElementById('logs-level-filter').value; const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase(); const div = document.createElement('div'); div.className = 'log-entry level-' + entry.level; div.setAttribute('data-level', entry.level); div.setAttribute('data-target', entry.target); const ts = document.createElement('span'); ts.className = 'log-ts'; ts.textContent = entry.timestamp.substring(11, 23); div.appendChild(ts); const lvl = document.createElement('span'); lvl.className = 'log-level'; lvl.textContent = entry.level.padEnd(5); div.appendChild(lvl); const tgt = document.createElement('span'); tgt.className = 'log-target'; tgt.textContent = entry.target; div.appendChild(tgt); const msg = document.createElement('span'); msg.className = 'log-msg'; msg.textContent = entry.message; div.appendChild(msg); div.addEventListener('click', () => div.classList.toggle('expanded')); // Apply current filters as visibility const matchesLevel = levelFilter === 'all' || entry.level === levelFilter; const matchesTarget = !targetFilter || entry.target.toLowerCase().includes(targetFilter); if (!matchesLevel || !matchesTarget) { div.style.display = 'none'; } output.appendChild(div); // Cap entries while (output.children.length > LOG_MAX_ENTRIES) { output.removeChild(output.firstChild); } // Auto-scroll if (document.getElementById('logs-autoscroll').checked) { output.scrollTop = output.scrollHeight; } } function toggleLogsPause() { logsPaused = !logsPaused; const btn = document.getElementById('logs-pause-btn'); btn.textContent = logsPaused ? 'Resume' : 'Pause'; if (!logsPaused) { // Flush buffer for (const entry of logBuffer) { appendLogEntry(entry); } logBuffer = []; } } function clearLogs() { if (!confirm('Clear all logs?')) return; document.getElementById('logs-output').innerHTML = ''; logBuffer = []; } // Re-apply filters when level or target changes document.getElementById('logs-level-filter').addEventListener('change', applyLogFilters); document.getElementById('logs-target-filter').addEventListener('input', applyLogFilters); function applyLogFilters() { const levelFilter = document.getElementById('logs-level-filter').value; const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase(); const entries = document.querySelectorAll('#logs-output .log-entry'); for (const el of entries) { const matchesLevel = levelFilter === 'all' || el.getAttribute('data-level') === levelFilter; const matchesTarget = !targetFilter || el.getAttribute('data-target').toLowerCase().includes(targetFilter); el.style.display = (matchesLevel && matchesTarget) ? '' : 'none'; } } // --- Extensions --- function loadExtensions() { const extList = document.getElementById('extensions-list'); const toolsTbody = document.getElementById('tools-tbody'); const toolsEmpty = document.getElementById('tools-empty'); // Fetch both in parallel Promise.all([ apiFetch('/api/extensions').catch(() => ({ extensions: [] })), apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })), ]).then(([extData, toolData]) => { // Render extensions if (extData.extensions.length === 0) { extList.innerHTML = '
No extensions installed
'; } else { extList.innerHTML = ''; for (const ext of extData.extensions) { extList.appendChild(renderExtensionCard(ext)); } } // Render tools if (toolData.tools.length === 0) { toolsTbody.innerHTML = ''; toolsEmpty.style.display = 'block'; } else { toolsEmpty.style.display = 'none'; toolsTbody.innerHTML = toolData.tools.map((t) => '' + escapeHtml(t.name) + '' + escapeHtml(t.description) + '' ).join(''); } }); } function renderExtensionCard(ext) { const card = document.createElement('div'); card.className = 'ext-card'; const header = document.createElement('div'); header.className = 'ext-header'; const name = document.createElement('span'); name.className = 'ext-name'; name.textContent = ext.name; header.appendChild(name); const kind = document.createElement('span'); kind.className = 'ext-kind kind-' + ext.kind; kind.textContent = ext.kind; header.appendChild(kind); const authDot = document.createElement('span'); authDot.className = 'ext-auth-dot ' + (ext.authenticated ? 'authed' : 'unauthed'); authDot.title = ext.authenticated ? 'Authenticated' : 'Not authenticated'; header.appendChild(authDot); card.appendChild(header); if (ext.description) { const desc = document.createElement('div'); desc.className = 'ext-desc'; desc.textContent = ext.description; card.appendChild(desc); } if (ext.url) { const url = document.createElement('div'); url.className = 'ext-url'; url.textContent = ext.url; url.title = ext.url; card.appendChild(url); } if (ext.tools.length > 0) { const tools = document.createElement('div'); tools.className = 'ext-tools'; tools.textContent = 'Tools: ' + ext.tools.join(', '); card.appendChild(tools); } const actions = document.createElement('div'); actions.className = 'ext-actions'; if (!ext.active) { const activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; activateBtn.textContent = 'Activate'; activateBtn.addEventListener('click', () => activateExtension(ext.name)); actions.appendChild(activateBtn); } else { const activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; activeLabel.textContent = 'Active'; actions.appendChild(activeLabel); } const removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; removeBtn.textContent = 'Remove'; removeBtn.addEventListener('click', () => removeExtension(ext.name)); actions.appendChild(removeBtn); card.appendChild(actions); return card; } function activateExtension(name) { apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' }) .then((res) => { if (res.success) { loadExtensions(); return; } if (res.auth_url) { showToast('Opening authentication for ' + name, 'info'); window.open(res.auth_url, '_blank'); } else if (res.awaiting_token) { showToast(res.instructions || 'Please provide an API token for ' + name, 'info'); } else { showToast('Activate failed: ' + res.message, 'error'); } loadExtensions(); }) .catch((err) => showToast('Activate failed: ' + err.message, 'error')); } function removeExtension(name) { if (!confirm('Remove extension "' + name + '"?')) return; apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) .then((res) => { if (!res.success) { showToast('Remove failed: ' + res.message, 'error'); } else { showToast('Removed ' + name, 'success'); } loadExtensions(); }) .catch((err) => showToast('Remove failed: ' + err.message, 'error')); } // --- Jobs --- let currentJobId = null; let currentJobSubTab = 'overview'; let jobFilesTreeState = null; function loadJobs() { currentJobId = null; jobFilesTreeState = null; // Rebuild DOM if renderJobDetail() destroyed it (it wipes .jobs-container innerHTML). const container = document.querySelector('.jobs-container'); if (!document.getElementById('jobs-summary')) { container.innerHTML = '
' + '' + '' + '
IDTitleStatusCreatedActions
' + ''; } Promise.all([ apiFetch('/api/jobs/summary'), apiFetch('/api/jobs'), ]).then(([summary, jobList]) => { renderJobsSummary(summary); renderJobsList(jobList.jobs); }).catch(() => {}); } function renderJobsSummary(s) { document.getElementById('jobs-summary').innerHTML = '' + summaryCard('Total', s.total, '') + summaryCard('In Progress', s.in_progress, 'active') + summaryCard('Completed', s.completed, 'completed') + summaryCard('Failed', s.failed, 'failed') + summaryCard('Stuck', s.stuck, 'stuck'); } function summaryCard(label, count, cls) { return '
' + '
' + count + '
' + '
' + label + '
' + '
'; } function renderJobsList(jobs) { const tbody = document.getElementById('jobs-tbody'); const empty = document.getElementById('jobs-empty'); if (jobs.length === 0) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; tbody.innerHTML = jobs.map((job) => { const shortId = job.id.substring(0, 8); const stateClass = job.state.replace(' ', '_'); let actionBtns = ''; if (job.state === 'pending' || job.state === 'in_progress') { actionBtns = ''; } else if (job.state === 'failed' || job.state === 'interrupted') { actionBtns = ''; } return '' + '' + shortId + '' + '' + escapeHtml(job.title) + '' + '' + escapeHtml(job.state) + '' + '' + formatDate(job.created_at) + '' + '' + actionBtns + '' + ''; }).join(''); } function cancelJob(jobId) { if (!confirm('Cancel this job?')) return; apiFetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' }) .then(() => { showToast('Job cancelled', 'success'); if (currentJobId) openJobDetail(currentJobId); else loadJobs(); }) .catch((err) => { showToast('Failed to cancel job: ' + err.message, 'error'); }); } function restartJob(jobId) { apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' }) .then((res) => { showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success'); loadJobs(); }) .catch((err) => { showToast('Failed to restart job: ' + err.message, 'error'); }); } function openJobDetail(jobId) { currentJobId = jobId; currentJobSubTab = 'activity'; apiFetch('/api/jobs/' + jobId).then((job) => { renderJobDetail(job); }).catch((err) => { addMessage('system', 'Failed to load job: ' + err.message); closeJobDetail(); }); } function closeJobDetail() { currentJobId = null; jobFilesTreeState = null; loadJobs(); } function renderJobDetail(job) { const container = document.querySelector('.jobs-container'); const stateClass = job.state.replace(' ', '_'); container.innerHTML = ''; // Header const header = document.createElement('div'); header.className = 'job-detail-header'; let headerHtml = '' + '

' + escapeHtml(job.title) + '

' + '' + escapeHtml(job.state) + ''; if (job.state === 'failed' || job.state === 'interrupted') { headerHtml += ''; } if (job.browse_url) { headerHtml += 'Browse Files'; } header.innerHTML = headerHtml; container.appendChild(header); // Sub-tab bar const tabs = document.createElement('div'); tabs.className = 'job-detail-tabs'; const subtabs = ['overview', 'activity', 'files']; for (const st of subtabs) { const btn = document.createElement('button'); btn.textContent = st.charAt(0).toUpperCase() + st.slice(1); btn.className = st === currentJobSubTab ? 'active' : ''; btn.addEventListener('click', () => { currentJobSubTab = st; renderJobDetail(job); }); tabs.appendChild(btn); } container.appendChild(tabs); // Content const content = document.createElement('div'); content.className = 'job-detail-content'; container.appendChild(content); switch (currentJobSubTab) { case 'overview': renderJobOverview(content, job); break; case 'files': renderJobFiles(content, job); break; case 'activity': renderJobActivity(content, job); break; } } function metaItem(label, value) { return '
' + escapeHtml(label) + '
' + escapeHtml(String(value != null ? value : '-')) + '
'; } function formatDuration(secs) { if (secs == null) return '-'; if (secs < 60) return secs + 's'; const m = Math.floor(secs / 60); const s = secs % 60; if (m < 60) return m + 'm ' + s + 's'; const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } function renderJobOverview(container, job) { // Metadata grid const grid = document.createElement('div'); grid.className = 'job-meta-grid'; grid.innerHTML = metaItem('Job ID', job.id) + metaItem('State', job.state) + metaItem('Created', formatDate(job.created_at)) + metaItem('Started', formatDate(job.started_at)) + metaItem('Completed', formatDate(job.completed_at)) + metaItem('Duration', formatDuration(job.elapsed_secs)) + (job.job_mode ? metaItem('Mode', job.job_mode) : ''); container.appendChild(grid); // Description if (job.description) { const descSection = document.createElement('div'); descSection.className = 'job-description'; const descHeader = document.createElement('h3'); descHeader.textContent = 'Description'; descSection.appendChild(descHeader); const descBody = document.createElement('div'); descBody.className = 'job-description-body'; descBody.innerHTML = renderMarkdown(job.description); descSection.appendChild(descBody); container.appendChild(descSection); } // State transitions timeline if (job.transitions.length > 0) { const timelineSection = document.createElement('div'); timelineSection.className = 'job-timeline-section'; const tlHeader = document.createElement('h3'); tlHeader.textContent = 'State Transitions'; timelineSection.appendChild(tlHeader); const timeline = document.createElement('div'); timeline.className = 'timeline'; for (const t of job.transitions) { const entry = document.createElement('div'); entry.className = 'timeline-entry'; const dot = document.createElement('div'); dot.className = 'timeline-dot'; entry.appendChild(dot); const info = document.createElement('div'); info.className = 'timeline-info'; info.innerHTML = '' + escapeHtml(t.from) + '' + ' → ' + '' + escapeHtml(t.to) + '' + '' + formatDate(t.timestamp) + '' + (t.reason ? '
' + escapeHtml(t.reason) + '
' : ''); entry.appendChild(info); timeline.appendChild(entry); } timelineSection.appendChild(timeline); container.appendChild(timelineSection); } } function renderJobFiles(container, job) { container.innerHTML = '
' + '
' + '
Select a file to view
' + '
'; container._jobId = job ? job.id : null; apiFetch('/api/jobs/' + job.id + '/files/list?path=').then((data) => { jobFilesTreeState = data.entries.map((e) => ({ name: e.name, path: e.path, is_dir: e.is_dir, children: e.is_dir ? null : undefined, expanded: false, loaded: false, })); renderJobFilesTree(); }).catch(() => { const treeContainer = document.querySelector('.job-files-tree'); if (treeContainer) { treeContainer.innerHTML = '
No project files
'; } }); } function renderJobFilesTree() { const treeContainer = document.querySelector('.job-files-tree'); if (!treeContainer) return; treeContainer.innerHTML = ''; if (!jobFilesTreeState || jobFilesTreeState.length === 0) { treeContainer.innerHTML = '
No files in workspace
'; return; } renderJobFileNodes(jobFilesTreeState, treeContainer, 0); } function renderJobFileNodes(nodes, container, depth) { for (const node of nodes) { const row = document.createElement('div'); row.className = 'tree-row'; row.style.paddingLeft = (depth * 16 + 8) + 'px'; if (node.is_dir) { const arrow = document.createElement('span'); arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : ''); arrow.textContent = '\u25B6'; arrow.addEventListener('click', (e) => { e.stopPropagation(); toggleJobFileExpand(node); }); row.appendChild(arrow); const label = document.createElement('span'); label.className = 'tree-label dir'; label.textContent = node.name; label.addEventListener('click', () => toggleJobFileExpand(node)); row.appendChild(label); } else { const spacer = document.createElement('span'); spacer.className = 'expand-arrow-spacer'; row.appendChild(spacer); const label = document.createElement('span'); label.className = 'tree-label file'; label.textContent = node.name; label.addEventListener('click', () => readJobFile(node.path)); row.appendChild(label); } container.appendChild(row); if (node.is_dir && node.expanded && node.children) { const childContainer = document.createElement('div'); childContainer.className = 'tree-children'; renderJobFileNodes(node.children, childContainer, depth + 1); container.appendChild(childContainer); } } } function getJobId() { const container = document.querySelector('.job-detail-content'); return (container && container._jobId) || null; } function toggleJobFileExpand(node) { if (node.expanded) { node.expanded = false; renderJobFilesTree(); return; } if (node.loaded) { node.expanded = true; renderJobFilesTree(); return; } const jobId = getJobId(); apiFetch('/api/jobs/' + jobId + '/files/list?path=' + encodeURIComponent(node.path)).then((data) => { node.children = data.entries.map((e) => ({ name: e.name, path: e.path, is_dir: e.is_dir, children: e.is_dir ? null : undefined, expanded: false, loaded: false, })); node.loaded = true; node.expanded = true; renderJobFilesTree(); }).catch(() => {}); } function readJobFile(path) { const viewer = document.querySelector('.job-files-viewer'); if (!viewer) return; const jobId = getJobId(); apiFetch('/api/jobs/' + jobId + '/files/read?path=' + encodeURIComponent(path)).then((data) => { viewer.innerHTML = '
' + escapeHtml(path) + '
' + '
' + escapeHtml(data.content) + '
'; }).catch((err) => { viewer.innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; }); } // --- Activity tab (unified for all sandbox jobs) --- let activityCurrentJobId = null; // Track how many live SSE events we've already rendered so refreshActivityTab // only appends new ones (avoids duplicates on each SSE tick). let activityRenderedLiveIndex = 0; function renderJobActivity(container, job) { activityCurrentJobId = job ? job.id : null; activityRenderedLiveIndex = 0; container.innerHTML = '
' + '' + '' + '
' + '
' + '
' + '' + '' + '' + '
'; document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter); const terminal = document.getElementById('activity-terminal'); const input = document.getElementById('activity-prompt-input'); const sendBtn = document.getElementById('activity-send-btn'); const doneBtn = document.getElementById('activity-done-btn'); sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false)); doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendJobPrompt(job.id, false); }); // Load persisted events from DB, then catch up with any live SSE events apiFetch('/api/jobs/' + job.id + '/events').then((data) => { if (data.events && data.events.length > 0) { for (const evt of data.events) { appendActivityEvent(terminal, evt.event_type, evt.data); } } appendNewLiveEvents(terminal, job.id); }).catch(() => { appendNewLiveEvents(terminal, job.id); }); } function appendNewLiveEvents(terminal, jobId) { const live = jobEvents.get(jobId) || []; for (let i = activityRenderedLiveIndex; i < live.length; i++) { const evt = live[i]; appendActivityEvent(terminal, evt.type.replace('job_', ''), evt.data); } activityRenderedLiveIndex = live.length; const autoScroll = document.getElementById('activity-autoscroll'); if (!autoScroll || autoScroll.checked) { terminal.scrollTop = terminal.scrollHeight; } } function applyActivityFilter() { const filter = document.getElementById('activity-type-filter').value; const events = document.querySelectorAll('#activity-terminal .activity-event'); for (const el of events) { if (filter === 'all') { el.style.display = ''; } else { el.style.display = el.getAttribute('data-event-type') === filter ? '' : 'none'; } } } function appendActivityEvent(terminal, eventType, data) { if (!terminal) return; const el = document.createElement('div'); el.className = 'activity-event activity-event-' + eventType; el.setAttribute('data-event-type', eventType); // Respect current filter const filterEl = document.getElementById('activity-type-filter'); if (filterEl && filterEl.value !== 'all' && filterEl.value !== eventType) { el.style.display = 'none'; } switch (eventType) { case 'message': el.innerHTML = '' + escapeHtml(data.role || 'assistant') + ' ' + '' + escapeHtml(data.content || '') + ''; break; case 'tool_use': el.innerHTML = '
' + ' ' + escapeHtml(data.tool_name || 'tool') + '
'
        + escapeHtml(typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2))
        + '
'; break; case 'tool_result': el.innerHTML = '
' + ' ' + escapeHtml(data.tool_name || 'result') + '
'
        + escapeHtml(data.output || '')
        + '
'; break; case 'status': el.innerHTML = '' + escapeHtml(data.message || '') + ''; break; case 'result': el.className += ' activity-final'; const success = data.success !== false; el.innerHTML = '' + escapeHtml(data.message || data.status || 'done') + ''; if (data.session_id) { el.innerHTML += ' session: ' + escapeHtml(data.session_id) + ''; } break; default: el.innerHTML = '' + escapeHtml(JSON.stringify(data)) + ''; } terminal.appendChild(el); } function refreshActivityTab(jobId) { if (activityCurrentJobId !== jobId) return; if (currentJobSubTab !== 'activity') return; const terminal = document.getElementById('activity-terminal'); if (!terminal) return; appendNewLiveEvents(terminal, jobId); } function sendJobPrompt(jobId, done) { const input = document.getElementById('activity-prompt-input'); const content = input ? input.value.trim() : ''; if (!content && !done) return; apiFetch('/api/jobs/' + jobId + '/prompt', { method: 'POST', body: { content: content || '(done)', done: done }, }).then(() => { if (input) input.value = ''; if (done) { const bar = document.getElementById('activity-input-bar'); if (bar) bar.innerHTML = 'Done signal sent'; } }).catch((err) => { const terminal = document.getElementById('activity-terminal'); if (terminal) { appendActivityEvent(terminal, 'status', { message: 'Failed to send: ' + err.message }); } }); } // --- Routines --- let currentRoutineId = null; function loadRoutines() { currentRoutineId = null; // Restore list view if detail was open const detail = document.getElementById('routine-detail'); if (detail) detail.style.display = 'none'; const table = document.getElementById('routines-table'); if (table) table.style.display = ''; Promise.all([ apiFetch('/api/routines/summary'), apiFetch('/api/routines'), ]).then(([summary, listData]) => { renderRoutinesSummary(summary); renderRoutinesList(listData.routines); }).catch(() => {}); } function renderRoutinesSummary(s) { document.getElementById('routines-summary').innerHTML = '' + summaryCard('Total', s.total, '') + summaryCard('Enabled', s.enabled, 'active') + summaryCard('Disabled', s.disabled, '') + summaryCard('Failing', s.failing, 'failed') + summaryCard('Runs Today', s.runs_today, 'completed'); } function renderRoutinesList(routines) { const tbody = document.getElementById('routines-tbody'); const empty = document.getElementById('routines-empty'); if (!routines || routines.length === 0) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; tbody.innerHTML = routines.map((r) => { const statusClass = r.status === 'active' ? 'completed' : r.status === 'failing' ? 'failed' : 'pending'; const toggleLabel = r.enabled ? 'Disable' : 'Enable'; const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart'; return '' + '' + escapeHtml(r.name) + '' + '' + escapeHtml(r.trigger_summary) + '' + '' + escapeHtml(r.action_type) + '' + '' + formatRelativeTime(r.last_run_at) + '' + '' + formatRelativeTime(r.next_fire_at) + '' + '' + r.run_count + '' + '' + escapeHtml(r.status) + '' + '' + ' ' + ' ' + '' + '' + ''; }).join(''); } function openRoutineDetail(id) { currentRoutineId = id; apiFetch('/api/routines/' + id).then((routine) => { renderRoutineDetail(routine); }).catch((err) => { showToast('Failed to load routine: ' + err.message, 'error'); }); } function closeRoutineDetail() { currentRoutineId = null; loadRoutines(); } function renderRoutineDetail(routine) { const table = document.getElementById('routines-table'); if (table) table.style.display = 'none'; document.getElementById('routines-empty').style.display = 'none'; const detail = document.getElementById('routine-detail'); detail.style.display = 'block'; const statusClass = !routine.enabled ? 'pending' : routine.consecutive_failures > 0 ? 'failed' : 'completed'; const statusLabel = !routine.enabled ? 'disabled' : routine.consecutive_failures > 0 ? 'failing' : 'active'; let html = '
' + '' + '

' + escapeHtml(routine.name) + '

' + '' + escapeHtml(statusLabel) + '' + '
'; // Metadata grid html += '
' + metaItem('Routine ID', routine.id) + metaItem('Enabled', routine.enabled ? 'Yes' : 'No') + metaItem('Run Count', routine.run_count) + metaItem('Failures', routine.consecutive_failures) + metaItem('Last Run', formatDate(routine.last_run_at)) + metaItem('Next Fire', formatDate(routine.next_fire_at)) + metaItem('Created', formatDate(routine.created_at)) + '
'; // Description if (routine.description) { html += '

Description

' + '
' + escapeHtml(routine.description) + '
'; } // Trigger config html += '

Trigger

' + '
' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '
'; // Action config html += '

Action

' + '
' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '
'; // Recent runs if (routine.recent_runs && routine.recent_runs.length > 0) { html += '

Recent Runs

' + '' + '' + ''; for (const run of routine.recent_runs) { const runStatusClass = run.status === 'Ok' ? 'completed' : run.status === 'Failed' ? 'failed' : run.status === 'Attention' ? 'stuck' : 'in_progress'; html += '' + '' + '' + '' + '' + '' + '' + ''; } html += '
TriggerStartedCompletedStatusSummaryTokens
' + escapeHtml(run.trigger_type) + '' + formatDate(run.started_at) + '' + formatDate(run.completed_at) + '' + escapeHtml(run.status) + '' + escapeHtml(run.result_summary || '-') + '' + (run.tokens_used != null ? run.tokens_used : '-') + '
'; } detail.innerHTML = html; } function triggerRoutine(id) { apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' }) .then(() => showToast('Routine triggered', 'success')) .catch((err) => showToast('Trigger failed: ' + err.message, 'error')); } function toggleRoutine(id) { apiFetch('/api/routines/' + id + '/toggle', { method: 'POST' }) .then((res) => { showToast('Routine ' + (res.status || 'toggled'), 'success'); if (currentRoutineId) openRoutineDetail(currentRoutineId); else loadRoutines(); }) .catch((err) => showToast('Toggle failed: ' + err.message, 'error')); } function deleteRoutine(id, name) { if (!confirm('Delete routine "' + name + '"?')) return; apiFetch('/api/routines/' + id, { method: 'DELETE' }) .then(() => { showToast('Routine deleted', 'success'); if (currentRoutineId === id) closeRoutineDetail(); else loadRoutines(); }) .catch((err) => showToast('Delete failed: ' + err.message, 'error')); } function formatRelativeTime(isoString) { if (!isoString) return '-'; const d = new Date(isoString); const now = Date.now(); const diffMs = now - d.getTime(); const absDiff = Math.abs(diffMs); const future = diffMs < 0; if (absDiff < 60000) return future ? 'in <1m' : '<1m ago'; if (absDiff < 3600000) { const m = Math.floor(absDiff / 60000); return future ? 'in ' + m + 'm' : m + 'm ago'; } if (absDiff < 86400000) { const h = Math.floor(absDiff / 3600000); return future ? 'in ' + h + 'h' : h + 'h ago'; } const days = Math.floor(absDiff / 86400000); return future ? 'in ' + days + 'd' : days + 'd ago'; } // --- Gateway status widget --- let gatewayStatusInterval = null; function startGatewayStatusPolling() { fetchGatewayStatus(); gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000); } function fetchGatewayStatus() { apiFetch('/api/gateway/status').then((data) => { const popover = document.getElementById('gateway-popover'); popover.innerHTML = '
SSE clients' + (data.sse_clients || 0) + '
' + '
Log clients' + (data.log_clients || 0) + '
' + '
Uptime' + formatDuration(data.uptime_secs) + '
'; }).catch(() => {}); } // Show/hide popover on hover document.getElementById('gateway-status-trigger').addEventListener('mouseenter', () => { document.getElementById('gateway-popover').classList.add('visible'); }); document.getElementById('gateway-status-trigger').addEventListener('mouseleave', () => { document.getElementById('gateway-popover').classList.remove('visible'); }); // --- Extension install --- function installExtension() { const name = document.getElementById('ext-install-name').value.trim(); if (!name) { showToast('Extension name is required', 'error'); return; } const url = document.getElementById('ext-install-url').value.trim(); const kind = document.getElementById('ext-install-kind').value; apiFetch('/api/extensions/install', { method: 'POST', body: { name, url: url || undefined, kind }, }).then((res) => { if (res.success) { showToast('Installed ' + name, 'success'); document.getElementById('ext-install-name').value = ''; document.getElementById('ext-install-url').value = ''; loadExtensions(); } else { showToast('Install failed: ' + (res.message || 'unknown error'), 'error'); } }).catch((err) => { showToast('Install failed: ' + err.message, 'error'); }); } // --- Keyboard shortcuts --- document.addEventListener('keydown', (e) => { const mod = e.metaKey || e.ctrlKey; const tag = (e.target.tagName || '').toLowerCase(); const inInput = tag === 'input' || tag === 'textarea'; // Mod+1-6: switch tabs if (mod && e.key >= '1' && e.key <= '6') { e.preventDefault(); const tabs = ['chat', 'memory', 'jobs', 'routines', 'logs', 'extensions']; const idx = parseInt(e.key) - 1; if (tabs[idx]) switchTab(tabs[idx]); return; } // Mod+K: focus chat input or memory search if (mod && e.key === 'k') { e.preventDefault(); if (currentTab === 'memory') { document.getElementById('memory-search').focus(); } else { document.getElementById('chat-input').focus(); } return; } // Mod+N: new thread if (mod && e.key === 'n' && currentTab === 'chat') { e.preventDefault(); createNewThread(); return; } // Escape: close job detail or blur input if (e.key === 'Escape') { if (currentJobId) { closeJobDetail(); } else if (inInput) { e.target.blur(); } return; } }); // --- Toasts --- function showToast(message, type) { const container = document.getElementById('toasts'); const toast = document.createElement('div'); toast.className = 'toast toast-' + (type || 'info'); toast.textContent = message; container.appendChild(toast); // Trigger slide-in requestAnimationFrame(() => toast.classList.add('visible')); setTimeout(() => { toast.classList.remove('visible'); toast.addEventListener('transitionend', () => toast.remove()); }, 4000); } // --- Utilities --- function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function formatDate(isoString) { if (!isoString) return '-'; const d = new Date(isoString); return d.toLocaleString(); }