Files
optimclaw/src/channels/web/static/app.js
T
afb49597ac feat(extensions): add OAuth setup UI for WASM tools + display name labels (#437)
Add setup.required_secrets to tool capabilities.json files so users can
configure OAuth client credentials (Google, Slack, Okta, Telegram) through
the Extensions UI Setup modal instead of environment variables.

- Add ToolSetupSchema/ToolSecretSetupSchema types to capabilities_schema.rs
- Extend get_setup_schema(), save_setup_secrets(), list() to handle WasmTool
- Extract load_tool_capabilities() helper to reduce duplication
- Auto-activate tools after saving setup secrets
- Show display_name labels (Channel/Tool/MCP) in extension cards
- Update button labels: "Setup" when unconfigured, "Reconfigure" when set
- Replace "Set" badge with checkmark in configure modal
- Fix innerHTML XSS pattern in slash autocomplete (use textContent)
- Add tests for ToolSetupSchema parsing and resolve_nested promotion
- Update registry display names (e.g. "Telegram Channel" vs "Telegram Tool")

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-02-28 19:57:55 -08:00

3762 lines
124 KiB
JavaScript

// 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 sseHasConnectedBefore = false;
let jobEvents = new Map(); // job_id -> Array of events
let jobListRefreshTimer = null;
let pairingPollInterval = null;
const JOB_EVENTS_CAP = 500;
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
// --- Slash Commands ---
const SLASH_COMMANDS = [
{ cmd: '/status', desc: 'Show all jobs, or /status <id> for one job' },
{ cmd: '/list', desc: 'List all jobs' },
{ cmd: '/cancel', desc: '/cancel <job-id> — cancel a running job' },
{ cmd: '/undo', desc: 'Revert the last turn' },
{ cmd: '/redo', desc: 'Re-apply an undone turn' },
{ cmd: '/compact', desc: 'Compress the context window' },
{ cmd: '/clear', desc: 'Clear thread and start fresh' },
{ cmd: '/interrupt', desc: 'Stop the current turn' },
{ cmd: '/heartbeat', desc: 'Trigger manual heartbeat check' },
{ cmd: '/summarize', desc: 'Summarize the current thread' },
{ cmd: '/suggest', desc: 'Suggest next steps' },
{ cmd: '/help', desc: 'Show help' },
{ cmd: '/version', desc: 'Show version info' },
{ cmd: '/tools', desc: 'List available tools' },
{ cmd: '/skills', desc: 'List installed skills' },
{ cmd: '/model', desc: 'Show or switch the LLM model' },
{ cmd: '/thread new', desc: 'Create a new conversation thread' },
];
let _slashSelected = -1;
let _slashMatches = [];
// --- Tool Activity State ---
let _activeGroup = null;
let _activeToolCards = {};
let _activityThinking = null;
// --- 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 and log_level from URL so they're not visible in the address bar
const cleaned = new URL(window.location);
const urlLogLevel = cleaned.searchParams.get('log_level');
cleaned.searchParams.delete('token');
cleaned.searchParams.delete('log_level');
window.history.replaceState({}, '', cleaned.pathname + cleaned.search);
connectSSE();
connectLogSSE();
startGatewayStatusPolling();
checkTeeStatus();
loadThreads();
loadMemoryTree();
loadJobs();
// Apply URL log_level param if present, otherwise just sync the dropdown
if (urlLogLevel) {
setServerLogLevel(urlLogLevel);
} else {
loadServerLogLevel();
}
})
.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) {
return res.text().then(function(body) {
throw new Error(body || (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';
if (sseHasConnectedBefore && currentThreadId) {
finalizeActivityGroup();
loadHistory();
}
sseHasConnectedBefore = true;
};
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;
finalizeActivityGroup();
addMessage('assistant', data.content);
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;
showActivityThinking(data.message);
});
eventSource.addEventListener('tool_started', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
addToolCard(data.name);
});
eventSource.addEventListener('tool_completed', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
completeToolCard(data.name, data.success);
});
eventSource.addEventListener('tool_result', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
setToolCardOutput(data.name, data.preview);
});
eventSource.addEventListener('stream_chunk', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
finalizeActivityGroup();
appendToLastAssistant(data.content);
});
eventSource.addEventListener('status', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
// "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.
// Status text is not displayed — inline activity cards handle visual feedback.
if (data.message === 'Done' || data.message === 'Awaiting approval') {
finalizeActivityGroup();
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);
if (!isCurrentThread(data.thread_id)) return;
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('extension_status', (e) => {
if (currentTab === 'extensions') loadExtensions();
});
eventSource.addEventListener('error', (e) => {
if (e.data) {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
finalizeActivityGroup();
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');
if (!currentThreadId) {
console.warn('sendMessage: no thread selected, ignoring');
return;
}
const content = input.value.trim();
if (!content) return;
addMessage('user', content);
input.value = '';
autoResizeTextarea(input);
input.focus();
apiFetch('/api/chat/send', {
method: 'POST',
body: { content, thread_id: currentThreadId || undefined },
}).catch((err) => {
addMessage('system', 'Failed to send: ' + err.message);
});
}
function enableChatInput() {
// no-op: input and send button are always enabled
}
// --- Slash Autocomplete ---
function showSlashAutocomplete(matches) {
const el = document.getElementById('slash-autocomplete');
if (!el || matches.length === 0) { hideSlashAutocomplete(); return; }
_slashMatches = matches;
_slashSelected = -1;
el.innerHTML = '';
matches.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'slash-ac-item';
row.dataset.index = i;
var cmdSpan = document.createElement('span');
cmdSpan.className = 'slash-ac-cmd';
cmdSpan.textContent = item.cmd;
var descSpan = document.createElement('span');
descSpan.className = 'slash-ac-desc';
descSpan.textContent = item.desc;
row.appendChild(cmdSpan);
row.appendChild(descSpan);
row.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent blur
selectSlashItem(item.cmd);
});
el.appendChild(row);
});
el.style.display = 'block';
}
function hideSlashAutocomplete() {
const el = document.getElementById('slash-autocomplete');
if (el) el.style.display = 'none';
_slashSelected = -1;
_slashMatches = [];
}
function selectSlashItem(cmd) {
const input = document.getElementById('chat-input');
input.value = cmd + ' ';
input.focus();
hideSlashAutocomplete();
autoResizeTextarea(input);
}
function updateSlashHighlight() {
const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item');
items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected));
}
function filterSlashCommands(value) {
if (!value.startsWith('/')) { hideSlashAutocomplete(); return; }
// Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new)
const lower = value.toLowerCase();
const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower));
if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) {
hideSlashAutocomplete();
} else {
showSlashAutocomplete(matches);
}
}
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);
// Remove the card after showing the confirmation briefly
setTimeout(() => { card.remove(); }, 1500);
}
}
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 <pre> blocks
html = html.replace(/<pre>/g, '<pre class="code-block-wrapper"><button class="copy-btn" onclick="copyCodeBlock(this)">Copy</button>');
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\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
html = html.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '');
html = html.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '');
html = html.replace(/<embed\b[^>]*\/?>/gi, '');
html = html.replace(/<form\b[^>]*>[\s\S]*?<\/form>/gi, '');
html = html.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
html = html.replace(/<link\b[^>]*\/?>/gi, '');
html = html.replace(/<base\b[^>]*\/?>/gi, '');
html = html.replace(/<meta\b[^>]*\/?>/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);
}
}
// --- Inline Tool Activity Cards ---
function getOrCreateActivityGroup() {
if (_activeGroup) return _activeGroup;
const container = document.getElementById('chat-messages');
const group = document.createElement('div');
group.className = 'activity-group';
container.appendChild(group);
container.scrollTop = container.scrollHeight;
_activeGroup = group;
_activeToolCards = {};
return group;
}
function showActivityThinking(message) {
const group = getOrCreateActivityGroup();
if (_activityThinking) {
// Already exists — just update text and un-hide
_activityThinking.style.display = '';
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
} else {
_activityThinking = document.createElement('div');
_activityThinking.className = 'activity-thinking';
_activityThinking.innerHTML =
'<span class="activity-thinking-dots">'
+ '<span class="activity-thinking-dot"></span>'
+ '<span class="activity-thinking-dot"></span>'
+ '<span class="activity-thinking-dot"></span>'
+ '</span>'
+ '<span class="activity-thinking-text"></span>';
group.appendChild(_activityThinking);
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
}
const container = document.getElementById('chat-messages');
container.scrollTop = container.scrollHeight;
}
function removeActivityThinking() {
if (_activityThinking) {
_activityThinking.remove();
_activityThinking = null;
}
}
function addToolCard(name) {
// Hide thinking instead of destroying — it may reappear between tool rounds
if (_activityThinking) _activityThinking.style.display = 'none';
const group = getOrCreateActivityGroup();
const card = document.createElement('div');
card.className = 'activity-tool-card';
card.setAttribute('data-tool-name', name);
card.setAttribute('data-status', 'running');
const header = document.createElement('div');
header.className = 'activity-tool-header';
const icon = document.createElement('span');
icon.className = 'activity-tool-icon';
icon.innerHTML = '<div class="spinner"></div>';
const toolName = document.createElement('span');
toolName.className = 'activity-tool-name';
toolName.textContent = name;
const duration = document.createElement('span');
duration.className = 'activity-tool-duration';
duration.textContent = '';
const chevron = document.createElement('span');
chevron.className = 'activity-tool-chevron';
chevron.innerHTML = '&#9656;';
header.appendChild(icon);
header.appendChild(toolName);
header.appendChild(duration);
header.appendChild(chevron);
const body = document.createElement('div');
body.className = 'activity-tool-body';
body.style.display = 'none';
const output = document.createElement('pre');
output.className = 'activity-tool-output';
body.appendChild(output);
header.addEventListener('click', () => {
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
chevron.classList.toggle('expanded', !isOpen);
});
card.appendChild(header);
card.appendChild(body);
group.appendChild(card);
const startTime = Date.now();
const timerInterval = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000;
if (elapsed > 300) { clearInterval(timerInterval); return; }
duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
}, 100);
if (!_activeToolCards[name]) _activeToolCards[name] = [];
_activeToolCards[name].push({ card, startTime, timer: timerInterval, duration, icon, finalDuration: null });
const container = document.getElementById('chat-messages');
container.scrollTop = container.scrollHeight;
}
function completeToolCard(name, success) {
const entries = _activeToolCards[name];
if (!entries || entries.length === 0) return;
// Find first running card
let entry = null;
for (let i = 0; i < entries.length; i++) {
if (entries[i].card.getAttribute('data-status') === 'running') {
entry = entries[i];
break;
}
}
if (!entry) entry = entries[entries.length - 1];
clearInterval(entry.timer);
const elapsed = (Date.now() - entry.startTime) / 1000;
entry.finalDuration = elapsed;
entry.duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
entry.icon.innerHTML = success
? '<span class="activity-icon-success">&#10003;</span>'
: '<span class="activity-icon-fail">&#10007;</span>';
entry.card.setAttribute('data-status', success ? 'success' : 'fail');
}
function setToolCardOutput(name, preview) {
const entries = _activeToolCards[name];
if (!entries || entries.length === 0) return;
// Find first card with empty output
let entry = null;
for (let i = 0; i < entries.length; i++) {
const out = entries[i].card.querySelector('.activity-tool-output');
if (out && !out.textContent) {
entry = entries[i];
break;
}
}
if (!entry) entry = entries[entries.length - 1];
const output = entry.card.querySelector('.activity-tool-output');
if (output) {
const truncated = preview.length > 2000 ? preview.substring(0, 2000) + '\n... (truncated)' : preview;
output.textContent = truncated;
}
}
function finalizeActivityGroup() {
removeActivityThinking();
if (!_activeGroup) return;
// Stop all timers
for (const name in _activeToolCards) {
const entries = _activeToolCards[name];
for (let i = 0; i < entries.length; i++) {
clearInterval(entries[i].timer);
}
}
// Count tools and total duration
let toolCount = 0;
let totalDuration = 0;
for (const tname in _activeToolCards) {
const tentries = _activeToolCards[tname];
for (let j = 0; j < tentries.length; j++) {
const entry = tentries[j];
toolCount++;
if (entry.finalDuration !== null) {
totalDuration += entry.finalDuration;
} else {
// Tool was still running when finalized
totalDuration += (Date.now() - entry.startTime) / 1000;
}
}
}
if (toolCount === 0) {
// No tools were used — remove the empty group
_activeGroup.remove();
_activeGroup = null;
_activeToolCards = {};
return;
}
// Wrap existing cards into a hidden container
const cardsContainer = document.createElement('div');
cardsContainer.className = 'activity-cards-container';
cardsContainer.style.display = 'none';
const cards = _activeGroup.querySelectorAll('.activity-tool-card');
for (let k = 0; k < cards.length; k++) {
cardsContainer.appendChild(cards[k]);
}
// Build summary line
const durationStr = totalDuration < 10 ? totalDuration.toFixed(1) + 's' : Math.floor(totalDuration) + 's';
const toolWord = toolCount === 1 ? 'tool' : 'tools';
const summary = document.createElement('div');
summary.className = 'activity-summary';
summary.innerHTML = '<span class="activity-summary-chevron">&#9656;</span>'
+ '<span class="activity-summary-text">Used ' + toolCount + ' ' + toolWord + '</span>'
+ '<span class="activity-summary-duration">(' + durationStr + ')</span>';
summary.addEventListener('click', () => {
const isOpen = cardsContainer.style.display !== 'none';
cardsContainer.style.display = isOpen ? 'none' : 'block';
summary.querySelector('.activity-summary-chevron').classList.toggle('expanded', !isOpen);
});
// Clear group and add summary + hidden cards
_activeGroup.innerHTML = '';
_activeGroup.classList.add('collapsed');
_activeGroup.appendChild(summary);
_activeGroup.appendChild(cardsContainer);
_activeGroup = null;
_activeToolCards = {};
}
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.tool_calls && turn.tool_calls.length > 0) {
addToolCallsSummary(turn.tool_calls);
}
if (turn.response) {
addMessage('assistant', turn.response);
}
}
// Show processing indicator if the last turn is still in-progress
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
showActivityThinking('Processing...');
}
// Re-render pending approval card if the thread is awaiting approval
if (data.pending_approval) {
showApproval(data.pending_approval);
}
} 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.tool_calls && turn.tool_calls.length > 0) {
fragment.appendChild(createToolCallsSummaryElement(turn.tool_calls));
}
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 addToolCallsSummary(toolCalls) {
const container = document.getElementById('chat-messages');
container.appendChild(createToolCallsSummaryElement(toolCalls));
container.scrollTop = container.scrollHeight;
}
function createToolCallsSummaryElement(toolCalls) {
const div = document.createElement('div');
div.className = 'tool-calls-summary';
const header = document.createElement('div');
header.className = 'tool-calls-header';
header.textContent = toolCalls.length + ' tool' + (toolCalls.length !== 1 ? 's' : '') + ' used';
div.appendChild(header);
const list = document.createElement('div');
list.className = 'tool-calls-list';
for (const tc of toolCalls) {
const item = document.createElement('div');
item.className = 'tool-call-item' + (tc.has_error ? ' tool-error' : '');
const icon = tc.has_error ? '\u2717' : '\u2713';
const nameSpan = document.createElement('span');
nameSpan.className = 'tool-call-name';
nameSpan.textContent = icon + ' ' + tc.name;
item.appendChild(nameSpan);
if (tc.result_preview) {
const preview = document.createElement('div');
preview.className = 'tool-call-preview';
preview.textContent = tc.result_preview;
item.appendChild(preview);
}
if (tc.error) {
const errDiv = document.createElement('div');
errDiv.className = 'tool-call-error-text';
errDiv.textContent = tc.error;
item.appendChild(errDiv);
}
list.appendChild(item);
}
div.appendChild(list);
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
list.classList.toggle('expanded');
header.classList.toggle('expanded');
});
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();
}
// Enable chat input once a thread is available
if (currentThreadId) {
enableChatInput();
}
}).catch(() => {});
}
function switchToAssistant() {
if (!assistantThreadId) return;
finalizeActivityGroup();
currentThreadId = assistantThreadId;
hasMore = false;
oldestTimestamp = null;
loadHistory();
loadThreads();
}
function switchThread(threadId) {
finalizeActivityGroup();
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 = '';
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') ? '&raquo;' : '&laquo;';
}
// Chat input auto-resize and keyboard handling
const chatInput = document.getElementById('chat-input');
chatInput.addEventListener('keydown', (e) => {
const acEl = document.getElementById('slash-autocomplete');
const acVisible = acEl && acEl.style.display !== 'none';
if (acVisible) {
const items = acEl.querySelectorAll('.slash-ac-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
_slashSelected = Math.min(_slashSelected + 1, items.length - 1);
updateSlashHighlight();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
_slashSelected = Math.max(_slashSelected - 1, -1);
updateSlashHighlight();
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && _slashSelected >= 0)) {
e.preventDefault();
const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0];
if (pick) selectSlashItem(pick.cmd);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideSlashAutocomplete();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
hideSlashAutocomplete();
sendMessage();
}
});
chatInput.addEventListener('input', () => {
autoResizeTextarea(chatInput);
filterSlashCommands(chatInput.value);
});
chatInput.addEventListener('blur', () => {
// Small delay so mousedown on autocomplete item fires first
setTimeout(hideSlashAutocomplete, 150);
});
// 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 = '<div class="spinner"></div> 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();
startPairingPoll();
} else {
stopPairingPoll();
}
if (tab === 'skills') loadSkills();
}
// --- 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 = '<div class="tree-item" style="color:var(--text-secondary)">No files in workspace</div>';
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 = '<div class="memory-rendered">' + renderMarkdown(data.content) + '</div>';
viewer.classList.add('rendered');
} else {
viewer.textContent = data.content;
viewer.classList.remove('rendered');
}
}).catch((err) => {
currentMemoryContent = null;
document.getElementById('memory-viewer').innerHTML = '<div class="empty">Error: ' + escapeHtml(err.message) + '</div>';
});
}
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 = '<a onclick="loadMemoryTree()">workspace</a>';
let current = '';
for (const part of parts) {
current += (current ? '/' : '') + part;
html += ' / <a onclick="readMemoryFile(\'' + escapeHtml(current) + '\')">' + escapeHtml(part) + '</a>';
}
return html;
}
function searchMemory(query) {
const normalizedQuery = normalizeSearchQuery(query);
if (!normalizedQuery) return;
apiFetch('/api/memory/search', {
method: 'POST',
body: { query: normalizedQuery, limit: 20 },
}).then((data) => {
const tree = document.getElementById('memory-tree');
tree.innerHTML = '';
if (data.results.length === 0) {
tree.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No results</div>';
return;
}
for (const result of data.results) {
const item = document.createElement('div');
item.className = 'search-result';
const snippet = snippetAround(result.content, normalizedQuery, 120);
item.innerHTML = '<div class="path">' + escapeHtml(result.path) + '</div>'
+ '<div class="snippet">' + highlightQuery(snippet, normalizedQuery) + '</div>';
item.addEventListener('click', () => readMemoryFile(result.path));
tree.appendChild(item);
}
}).catch(() => {});
}
function normalizeSearchQuery(query) {
return (typeof query === 'string' ? query : '').slice(0, MEMORY_SEARCH_QUERY_MAX_LENGTH);
}
function snippetAround(text, query, len) {
const normalizedQuery = normalizeSearchQuery(query);
const lower = text.toLowerCase();
const idx = lower.indexOf(normalizedQuery.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 normalizedQuery = normalizeSearchQuery(query);
const queryEscaped = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp('(' + queryEscaped + ')', 'gi');
return escaped.replace(re, '<mark>$1</mark>');
}
// --- 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;
}
prependLogEntry(entry);
});
logEventSource.onerror = () => {
// Silent reconnect
};
}
function prependLogEntry(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.prepend(div);
// Cap entries (remove oldest at the bottom)
while (output.children.length > LOG_MAX_ENTRIES) {
output.removeChild(output.lastChild);
}
// Auto-scroll to top (newest entries are at the top)
if (document.getElementById('logs-autoscroll').checked) {
output.scrollTop = 0;
}
}
function toggleLogsPause() {
logsPaused = !logsPaused;
const btn = document.getElementById('logs-pause-btn');
btn.textContent = logsPaused ? 'Resume' : 'Pause';
if (!logsPaused) {
// Flush buffer: oldest-first + prepend naturally puts newest at top
for (const entry of logBuffer) {
prependLogEntry(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';
}
}
// --- Server-side log level control ---
function setServerLogLevel(level) {
apiFetch('/api/logs/level', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level: level }),
})
.then(r => r.json())
.then(data => {
document.getElementById('logs-server-level').value = data.level;
})
.catch(err => console.error('Failed to set server log level:', err));
}
function loadServerLogLevel() {
apiFetch('/api/logs/level')
.then(r => r.json())
.then(data => {
document.getElementById('logs-server-level').value = data.level;
})
.catch(() => {}); // ignore if not available
}
// --- Extensions ---
var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': 'MCP' };
function loadExtensions() {
const extList = document.getElementById('extensions-list');
const wasmList = document.getElementById('available-wasm-list');
const mcpList = document.getElementById('mcp-servers-list');
const toolsTbody = document.getElementById('tools-tbody');
const toolsEmpty = document.getElementById('tools-empty');
// Fetch all three in parallel
Promise.all([
apiFetch('/api/extensions').catch(() => ({ extensions: [] })),
apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })),
apiFetch('/api/extensions/registry').catch(function(err) { console.warn('registry fetch failed:', err); return { entries: [] }; }),
]).then(([extData, toolData, registryData]) => {
// Render installed extensions
if (extData.extensions.length === 0) {
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
} else {
extList.innerHTML = '';
for (const ext of extData.extensions) {
extList.appendChild(renderExtensionCard(ext));
}
}
// Split registry entries by kind
var wasmEntries = registryData.entries.filter(function(e) { return e.kind !== 'mcp_server' && !e.installed; });
var mcpEntries = registryData.entries.filter(function(e) { return e.kind === 'mcp_server'; });
// Available WASM extensions
if (wasmEntries.length === 0) {
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
} else {
wasmList.innerHTML = '';
for (const entry of wasmEntries) {
wasmList.appendChild(renderAvailableExtensionCard(entry));
}
}
// MCP servers (show both installed and uninstalled)
if (mcpEntries.length === 0) {
mcpList.innerHTML = '<div class="empty-state">No MCP servers available</div>';
} else {
mcpList.innerHTML = '';
for (const entry of mcpEntries) {
var installedExt = extData.extensions.find(function(e) { return e.name === entry.name; });
mcpList.appendChild(renderMcpServerCard(entry, installedExt));
}
}
// Render tools
if (toolData.tools.length === 0) {
toolsTbody.innerHTML = '';
toolsEmpty.style.display = 'block';
} else {
toolsEmpty.style.display = 'none';
toolsTbody.innerHTML = toolData.tools.map((t) =>
'<tr><td>' + escapeHtml(t.name) + '</td><td>' + escapeHtml(t.description) + '</td></tr>'
).join('');
}
});
}
function renderAvailableExtensionCard(entry) {
const card = document.createElement('div');
card.className = 'ext-card ext-available';
const header = document.createElement('div');
header.className = 'ext-header';
const name = document.createElement('span');
name.className = 'ext-name';
name.textContent = entry.display_name;
header.appendChild(name);
const kind = document.createElement('span');
kind.className = 'ext-kind kind-' + entry.kind;
kind.textContent = kindLabels[entry.kind] || entry.kind;
header.appendChild(kind);
card.appendChild(header);
const desc = document.createElement('div');
desc.className = 'ext-desc';
desc.textContent = entry.description;
card.appendChild(desc);
if (entry.keywords && entry.keywords.length > 0) {
const kw = document.createElement('div');
kw.className = 'ext-keywords';
kw.textContent = entry.keywords.join(', ');
card.appendChild(kw);
}
const actions = document.createElement('div');
actions.className = 'ext-actions';
const installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = 'Install';
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = 'Installing...';
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast('Installed ' + entry.display_name, 'success');
loadExtensions();
// Auto-open configure for WASM channels
if (entry.kind === 'wasm_channel') {
showConfigureModal(entry.name);
}
} else {
showToast('Install: ' + (res.message || 'unknown error'), 'error');
loadExtensions();
}
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
loadExtensions();
});
});
actions.appendChild(installBtn);
card.appendChild(actions);
return card;
}
function renderMcpServerCard(entry, installedExt) {
var card = document.createElement('div');
card.className = 'ext-card' + (installedExt ? '' : ' ext-available');
var header = document.createElement('div');
header.className = 'ext-header';
var name = document.createElement('span');
name.className = 'ext-name';
name.textContent = entry.display_name;
header.appendChild(name);
var kind = document.createElement('span');
kind.className = 'ext-kind kind-mcp_server';
kind.textContent = kindLabels['mcp_server'] || 'mcp_server';
header.appendChild(kind);
if (installedExt) {
var authDot = document.createElement('span');
authDot.className = 'ext-auth-dot ' + (installedExt.authenticated ? 'authed' : 'unauthed');
authDot.title = installedExt.authenticated ? 'Authenticated' : 'Not authenticated';
header.appendChild(authDot);
}
card.appendChild(header);
var desc = document.createElement('div');
desc.className = 'ext-desc';
desc.textContent = entry.description;
card.appendChild(desc);
var actions = document.createElement('div');
actions.className = 'ext-actions';
if (installedExt) {
if (!installedExt.active) {
var activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
actions.appendChild(activateBtn);
} else {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = 'Active';
actions.appendChild(activeLabel);
}
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
actions.appendChild(removeBtn);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = 'Install';
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = 'Installing...';
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast('Installed ' + entry.display_name, 'success');
} else {
showToast('Install: ' + (res.message || 'unknown error'), 'error');
}
loadExtensions();
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
loadExtensions();
});
});
actions.appendChild(installBtn);
}
card.appendChild(actions);
return card;
}
function createReconfigureButton(extName) {
var btn = document.createElement('button');
btn.className = 'btn-ext configure';
btn.textContent = 'Reconfigure';
btn.addEventListener('click', function() { showConfigureModal(extName); });
return btn;
}
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.display_name || ext.name;
header.appendChild(name);
const kind = document.createElement('span');
kind.className = 'ext-kind kind-' + ext.kind;
kind.textContent = kindLabels[ext.kind] || ext.kind;
header.appendChild(kind);
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
if (ext.kind !== 'wasm_channel') {
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);
// WASM channels get a progress stepper
if (ext.kind === 'wasm_channel') {
card.appendChild(renderWasmChannelStepper(ext));
}
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);
}
// Show activation error for WASM channels
if (ext.kind === 'wasm_channel' && ext.activation_error) {
const errorDiv = document.createElement('div');
errorDiv.className = 'ext-error';
errorDiv.textContent = ext.activation_error;
card.appendChild(errorDiv);
}
// Show "coming soon" note for non-Telegram channels that are configured but not fully supported yet
if (ext.kind === 'wasm_channel' && ext.name !== 'telegram'
&& (ext.activation_status === 'configured' || ext.active)) {
const noteDiv = document.createElement('div');
noteDiv.className = 'ext-note';
noteDiv.textContent = 'Full integration coming soon. Use the CLI to complete setup.';
card.appendChild(noteDiv);
}
const actions = document.createElement('div');
actions.className = 'ext-actions';
if (ext.kind === 'wasm_channel') {
// WASM channels: state-based buttons (no generic Activate)
var status = ext.activation_status || 'installed';
if (status === 'active') {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = 'Active';
actions.appendChild(activeLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'pairing') {
var pairingLabel = document.createElement('span');
pairingLabel.className = 'ext-pairing-label';
pairingLabel.textContent = 'Awaiting Pairing';
actions.appendChild(pairingLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'failed') {
var restartBtn = document.createElement('button');
restartBtn.className = 'btn-ext activate';
restartBtn.textContent = 'Restart';
restartBtn.addEventListener('click', restartGateway);
actions.appendChild(restartBtn);
actions.appendChild(createReconfigureButton(ext.name));
} else {
// installed or configured: show Setup button
var setupBtn = document.createElement('button');
setupBtn.className = 'btn-ext configure';
setupBtn.textContent = 'Setup';
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
actions.appendChild(setupBtn);
}
} else {
// Non-WASM-channel extensions: original behavior
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);
}
if (ext.needs_setup) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Setup';
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
actions.appendChild(configBtn);
}
}
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);
// For WASM channels, check for pending pairing requests.
if (ext.kind === 'wasm_channel') {
const pairingSection = document.createElement('div');
pairingSection.className = 'ext-pairing';
pairingSection.setAttribute('data-channel', ext.name);
card.appendChild(pairingSection);
loadPairingRequests(ext.name, pairingSection);
}
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) {
showConfigureModal(name);
} 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'));
}
function showConfigureModal(name) {
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup')
.then((setup) => {
if (!setup.secrets || setup.secrets.length === 0) {
showToast('No configuration needed for ' + name, 'info');
return;
}
renderConfigureModal(name, setup.secrets);
})
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
}
function renderConfigureModal(name, secrets) {
closeConfigureModal();
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeConfigureModal();
});
const modal = document.createElement('div');
modal.className = 'configure-modal';
const header = document.createElement('h3');
header.textContent = 'Configure ' + name;
modal.appendChild(header);
const form = document.createElement('div');
form.className = 'configure-form';
const fields = [];
for (const secret of secrets) {
const field = document.createElement('div');
field.className = 'configure-field';
const label = document.createElement('label');
label.textContent = secret.prompt;
if (secret.optional) {
const opt = document.createElement('span');
opt.className = 'field-optional';
opt.textContent = ' (optional)';
label.appendChild(opt);
}
field.appendChild(label);
const inputRow = document.createElement('div');
inputRow.className = 'configure-input-row';
const input = document.createElement('input');
input.type = 'password';
input.name = secret.name;
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitConfigureModal(name, fields);
});
inputRow.appendChild(input);
if (secret.provided) {
const badge = document.createElement('span');
badge.className = 'field-provided';
badge.textContent = '\u2713';
badge.title = 'Already configured';
inputRow.appendChild(badge);
}
if (secret.auto_generate && !secret.provided) {
const hint = document.createElement('span');
hint.className = 'field-autogen';
hint.textContent = 'Auto-generated if empty';
inputRow.appendChild(hint);
}
field.appendChild(inputRow);
form.appendChild(field);
fields.push({ name: secret.name, input: input });
}
modal.appendChild(form);
const actions = document.createElement('div');
actions.className = 'configure-actions';
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = 'Save';
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', closeConfigureModal);
actions.appendChild(cancelBtn);
modal.appendChild(actions);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (fields.length > 0) fields[0].input.focus();
}
function submitConfigureModal(name, fields) {
const secrets = {};
for (const f of fields) {
if (f.input.value.trim()) {
secrets[f.name] = f.input.value.trim();
}
}
// Disable buttons to prevent double-submit
var btns = document.querySelectorAll('.configure-actions button');
btns.forEach(function(b) { b.disabled = true; });
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
method: 'POST',
body: { secrets },
})
.then((res) => {
closeConfigureModal();
if (res.success) {
if (res.activated && name === 'telegram') {
showToast('Configured and activated ' + name, 'success');
} else if (res.activated) {
showToast('Configured ' + name + ' successfully', 'success');
} else if (res.needs_restart) {
showToast('Configured ' + name + '. Restart required to activate.', 'info');
} else {
showToast(res.message, 'success');
}
} else {
showToast(res.message || 'Configuration failed', 'error');
}
loadExtensions();
})
.catch((err) => {
btns.forEach(function(b) { b.disabled = false; });
showToast('Configuration failed: ' + err.message, 'error');
});
}
function closeConfigureModal() {
const existing = document.querySelector('.configure-overlay');
if (existing) existing.remove();
}
// --- Pairing ---
function loadPairingRequests(channel, container) {
apiFetch('/api/pairing/' + encodeURIComponent(channel))
.then(data => {
container.innerHTML = '';
if (!data.requests || data.requests.length === 0) return;
const heading = document.createElement('div');
heading.className = 'pairing-heading';
heading.textContent = 'Pending pairing requests';
container.appendChild(heading);
data.requests.forEach(req => {
const row = document.createElement('div');
row.className = 'pairing-row';
const code = document.createElement('span');
code.className = 'pairing-code';
code.textContent = req.code;
row.appendChild(code);
const sender = document.createElement('span');
sender.className = 'pairing-sender';
sender.textContent = 'from ' + req.sender_id;
row.appendChild(sender);
const btn = document.createElement('button');
btn.className = 'btn-ext activate';
btn.textContent = 'Approve';
btn.addEventListener('click', () => approvePairing(channel, req.code, container));
row.appendChild(btn);
container.appendChild(row);
});
})
.catch(() => {});
}
function approvePairing(channel, code, container) {
apiFetch('/api/pairing/' + encodeURIComponent(channel) + '/approve', {
method: 'POST',
body: { code },
}).then(res => {
if (res.success) {
showToast('Pairing approved', 'success');
loadPairingRequests(channel, container);
} else {
showToast(res.message || 'Approve failed', 'error');
}
}).catch(err => showToast('Error: ' + err.message, 'error'));
}
function startPairingPoll() {
stopPairingPoll();
pairingPollInterval = setInterval(function() {
document.querySelectorAll('.ext-pairing[data-channel]').forEach(function(el) {
loadPairingRequests(el.getAttribute('data-channel'), el);
});
}, 10000);
}
function stopPairingPoll() {
if (pairingPollInterval) {
clearInterval(pairingPollInterval);
pairingPollInterval = null;
}
}
// --- Gateway restart ---
function restartGateway() {
if (!confirm('Restart IronClaw gateway? Active connections will be dropped.')) return;
apiFetch('/api/gateway/restart', { method: 'POST' })
.then(function() {
showRestartOverlay();
})
.catch(function() {
showRestartOverlay();
});
}
function showRestartOverlay() {
var overlay = document.createElement('div');
overlay.className = 'restart-overlay';
overlay.innerHTML = '<div class="restart-message">'
+ '<div class="restart-spinner"></div>'
+ '<h2>Restarting IronClaw...</h2>'
+ '<p>Waiting for server to come back online</p>'
+ '</div>';
document.body.appendChild(overlay);
var pollCount = 0;
var pollTimer = setInterval(function() {
pollCount++;
if (pollCount > 30) { // 60 seconds
clearInterval(pollTimer);
overlay.querySelector('h2').textContent = 'Restart timed out';
overlay.querySelector('p').textContent = 'Server did not come back within 60 seconds. Check logs.';
overlay.querySelector('.restart-spinner').style.display = 'none';
return;
}
fetch('/api/gateway/status', {
headers: { 'Authorization': 'Bearer ' + token },
})
.then(function(r) {
if (r.ok) {
clearInterval(pollTimer);
window.location.reload();
}
})
.catch(function() { /* still restarting */ });
}, 2000);
}
// --- WASM channel stepper ---
function renderWasmChannelStepper(ext) {
var stepper = document.createElement('div');
stepper.className = 'ext-stepper';
var status = ext.activation_status || 'installed';
var isTelegram = ext.name === 'telegram';
// Telegram gets a 3-step stepper (Installed → Configured → Active/Pairing).
// Other channels only get 2 steps (Installed → Configured) since full
// integration isn't available in the web UI yet.
var steps = [
{ label: 'Installed', key: 'installed' },
{ label: 'Configured', key: 'configured' },
];
if (isTelegram) {
steps.push({ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' });
}
var reachedIdx;
if (status === 'active') reachedIdx = isTelegram ? 2 : 1;
else if (status === 'pairing') reachedIdx = 2;
else if (status === 'failed') reachedIdx = isTelegram ? 2 : 1;
else if (status === 'configured') reachedIdx = 1;
else reachedIdx = 0;
for (var i = 0; i < steps.length; i++) {
if (i > 0) {
var connector = document.createElement('div');
connector.className = 'stepper-connector' + (i <= reachedIdx ? ' completed' : '');
stepper.appendChild(connector);
}
var step = document.createElement('div');
var stepState;
if (i < reachedIdx) {
stepState = 'completed';
} else if (i === reachedIdx) {
if (status === 'failed') {
stepState = 'failed';
} else if (status === 'pairing') {
stepState = 'in-progress';
} else if (status === 'active' || status === 'configured' || status === 'installed') {
stepState = 'completed';
} else {
stepState = 'pending';
}
} else {
stepState = 'pending';
}
step.className = 'stepper-step ' + stepState;
var circle = document.createElement('span');
circle.className = 'stepper-circle';
if (stepState === 'completed') circle.textContent = '\u2713';
else if (stepState === 'failed') circle.textContent = '\u2717';
step.appendChild(circle);
var label = document.createElement('span');
label.className = 'stepper-label';
label.textContent = steps[i].label;
step.appendChild(label);
stepper.appendChild(step);
}
return stepper;
}
// --- 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 =
'<div class="jobs-summary" id="jobs-summary"></div>'
+ '<table class="jobs-table" id="jobs-table"><thead><tr>'
+ '<th>ID</th><th>Title</th><th>Status</th><th>Created</th><th>Actions</th>'
+ '</tr></thead><tbody id="jobs-tbody"></tbody></table>'
+ '<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>';
}
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 '<div class="summary-card ' + cls + '">'
+ '<div class="count">' + count + '</div>'
+ '<div class="label">' + label + '</div>'
+ '</div>';
}
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 = '<button class="btn-cancel" onclick="event.stopPropagation(); cancelJob(\'' + job.id + '\')">Cancel</button>';
} else if (job.state === 'failed' || job.state === 'interrupted') {
actionBtns = '<button class="btn-restart" onclick="event.stopPropagation(); restartJob(\'' + job.id + '\')">Restart</button>';
}
return '<tr class="job-row" onclick="openJobDetail(\'' + job.id + '\')">'
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
+ '<td>' + escapeHtml(job.title) + '</td>'
+ '<td><span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span></td>'
+ '<td>' + formatDate(job.created_at) + '</td>'
+ '<td>' + actionBtns + '</td>'
+ '</tr>';
}).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 = '<button class="btn-back" onclick="closeJobDetail()">&larr; Back</button>'
+ '<h2>' + escapeHtml(job.title) + '</h2>'
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
if (job.state === 'failed' || job.state === 'interrupted') {
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Restart</button>';
}
if (job.browse_url) {
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
}
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 '<div class="meta-item"><div class="meta-label">' + escapeHtml(label)
+ '</div><div class="meta-value">' + escapeHtml(String(value != null ? value : '-'))
+ '</div></div>';
}
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 = '<span class="badge ' + t.from.replace(' ', '_') + '">' + escapeHtml(t.from) + '</span>'
+ ' &rarr; '
+ '<span class="badge ' + t.to.replace(' ', '_') + '">' + escapeHtml(t.to) + '</span>'
+ '<span class="timeline-time">' + formatDate(t.timestamp) + '</span>'
+ (t.reason ? '<div class="timeline-reason">' + escapeHtml(t.reason) + '</div>' : '');
entry.appendChild(info);
timeline.appendChild(entry);
}
timelineSection.appendChild(timeline);
container.appendChild(timelineSection);
}
}
function renderJobFiles(container, job) {
container.innerHTML = '<div class="job-files">'
+ '<div class="job-files-sidebar"><div class="job-files-tree"></div></div>'
+ '<div class="job-files-viewer"><div class="empty-state">Select a file to view</div></div>'
+ '</div>';
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 = '<div class="tree-item" style="color:var(--text-secondary)">No project files</div>';
}
});
}
function renderJobFilesTree() {
const treeContainer = document.querySelector('.job-files-tree');
if (!treeContainer) return;
treeContainer.innerHTML = '';
if (!jobFilesTreeState || jobFilesTreeState.length === 0) {
treeContainer.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No files in workspace</div>';
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 = '<div class="job-files-path">' + escapeHtml(path) + '</div>'
+ '<pre class="job-files-content">' + escapeHtml(data.content) + '</pre>';
}).catch((err) => {
viewer.innerHTML = '<div class="empty-state">Error: ' + escapeHtml(err.message) + '</div>';
});
}
// --- 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 = '<div class="activity-toolbar">'
+ '<select id="activity-type-filter">'
+ '<option value="all">All Events</option>'
+ '<option value="message">Messages</option>'
+ '<option value="tool_use">Tool Calls</option>'
+ '<option value="tool_result">Results</option>'
+ '</select>'
+ '<label class="logs-checkbox"><input type="checkbox" id="activity-autoscroll" checked> Auto-scroll</label>'
+ '</div>'
+ '<div class="activity-terminal" id="activity-terminal"></div>'
+ '<div class="activity-input-bar" id="activity-input-bar">'
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
+ '<button id="activity-send-btn">Send</button>'
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
+ '</div>';
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 = '<span class="activity-role">' + escapeHtml(data.role || 'assistant') + '</span> '
+ '<span class="activity-content">' + escapeHtml(data.content || '') + '</span>';
break;
case 'tool_use':
el.innerHTML = '<details class="activity-tool-block"><summary>'
+ '<span class="activity-tool-icon">&#9881;</span> '
+ escapeHtml(data.tool_name || 'tool')
+ '</summary><pre class="activity-tool-input">'
+ escapeHtml(typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2))
+ '</pre></details>';
break;
case 'tool_result': {
const trSuccess = data.success !== false;
const trIcon = trSuccess ? '&#10003;' : '&#10007;';
const trOutput = data.output || data.error || '';
const trClass = 'activity-tool-block activity-tool-result'
+ (trSuccess ? '' : ' activity-tool-error');
el.innerHTML = '<details class="' + trClass + '"><summary>'
+ '<span class="activity-tool-icon">' + trIcon + '</span> '
+ escapeHtml(data.tool_name || 'result')
+ '</summary><pre class="activity-tool-output">'
+ escapeHtml(trOutput)
+ '</pre></details>';
break;
}
case 'status':
el.innerHTML = '<span class="activity-status">' + escapeHtml(data.message || '') + '</span>';
break;
case 'result':
el.className += ' activity-final';
const success = data.success !== false;
el.innerHTML = '<span class="activity-result-status" data-success="' + success + '">'
+ escapeHtml(data.message || data.error || data.status || 'done') + '</span>';
if (data.session_id) {
el.innerHTML += ' <span class="activity-session-id">session: ' + escapeHtml(data.session_id) + '</span>';
}
break;
default:
el.innerHTML = '<span class="activity-status">' + escapeHtml(JSON.stringify(data)) + '</span>';
}
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 = '<span class="activity-status">Done signal sent</span>';
}
}).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 '<tr class="routine-row" onclick="openRoutineDetail(\'' + r.id + '\')">'
+ '<td>' + escapeHtml(r.name) + '</td>'
+ '<td>' + escapeHtml(r.trigger_summary) + '</td>'
+ '<td>' + escapeHtml(r.action_type) + '</td>'
+ '<td>' + formatRelativeTime(r.last_run_at) + '</td>'
+ '<td>' + formatRelativeTime(r.next_fire_at) + '</td>'
+ '<td>' + r.run_count + '</td>'
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
+ '<td>'
+ '<button class="' + toggleClass + '" onclick="event.stopPropagation(); toggleRoutine(\'' + r.id + '\')">' + toggleLabel + '</button> '
+ '<button class="btn-restart" onclick="event.stopPropagation(); triggerRoutine(\'' + r.id + '\')">Run</button> '
+ '<button class="btn-cancel" onclick="event.stopPropagation(); deleteRoutine(\'' + r.id + '\', \'' + escapeHtml(r.name) + '\')">Delete</button>'
+ '</td>'
+ '</tr>';
}).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 = '<div class="job-detail-header">'
+ '<button class="btn-back" onclick="closeRoutineDetail()">&larr; Back</button>'
+ '<h2>' + escapeHtml(routine.name) + '</h2>'
+ '<span class="badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span>'
+ '</div>';
// Metadata grid
html += '<div class="job-meta-grid">'
+ 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))
+ '</div>';
// Description
if (routine.description) {
html += '<div class="job-description"><h3>Description</h3>'
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
}
// Trigger config
html += '<div class="job-description"><h3>Trigger</h3>'
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
// Action config
html += '<div class="job-description"><h3>Action</h3>'
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '</pre></div>';
// Recent runs
if (routine.recent_runs && routine.recent_runs.length > 0) {
html += '<div class="job-timeline-section"><h3>Recent Runs</h3>'
+ '<table class="routines-table"><thead><tr>'
+ '<th>Trigger</th><th>Started</th><th>Completed</th><th>Status</th><th>Summary</th><th>Tokens</th>'
+ '</tr></thead><tbody>';
for (const run of routine.recent_runs) {
const runStatusClass = run.status === 'Ok' ? 'completed'
: run.status === 'Failed' ? 'failed'
: run.status === 'Attention' ? 'stuck'
: 'in_progress';
html += '<tr>'
+ '<td>' + escapeHtml(run.trigger_type) + '</td>'
+ '<td>' + formatDate(run.started_at) + '</td>'
+ '<td>' + formatDate(run.completed_at) + '</td>'
+ '<td><span class="badge ' + runStatusClass + '">' + escapeHtml(run.status) + '</span></td>'
+ '<td>' + escapeHtml(run.result_summary || '-')
+ (run.job_id ? ' <a href="#" onclick="event.preventDefault(); switchTab(\'jobs\'); openJobDetail(\'' + run.job_id + '\')">[view job]</a>' : '')
+ '</td>'
+ '<td>' + (run.tokens_used != null ? run.tokens_used : '-') + '</td>'
+ '</tr>';
}
html += '</tbody></table></div>';
}
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 formatTokenCount(n) {
if (n == null || n === 0) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return '' + n;
}
function formatCost(costStr) {
if (!costStr) return '$0.00';
var n = parseFloat(costStr);
if (n < 0.01) return '$' + n.toFixed(4);
return '$' + n.toFixed(2);
}
function shortModelName(model) {
// Strip provider prefix and shorten common model names
var m = model.indexOf('/') >= 0 ? model.split('/').pop() : model;
// Shorten dated suffixes
m = m.replace(/-20\d{6}$/, '');
return m;
}
function fetchGatewayStatus() {
apiFetch('/api/gateway/status').then(function(data) {
var popover = document.getElementById('gateway-popover');
var html = '';
// Connection info
html += '<div class="gw-section-label">Connections</div>';
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
// Cost tracker
if (data.daily_cost != null) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">Cost Today</div>';
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
if (data.actions_this_hour != null) {
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
}
}
// Per-model token usage
if (data.model_usage && data.model_usage.length > 0) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">Token Usage</div>';
data.model_usage.sort(function(a, b) {
return (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens);
});
for (var i = 0; i < data.model_usage.length; i++) {
var m = data.model_usage[i];
var name = escapeHtml(shortModelName(m.model));
html += '<div class="gw-model-row">'
+ '<span class="gw-model-name">' + name + '</span>'
+ '<span class="gw-model-cost">' + escapeHtml(formatCost(m.cost)) + '</span>'
+ '</div>';
html += '<div class="gw-token-detail">'
+ '<span>in: ' + formatTokenCount(m.input_tokens) + '</span>'
+ '<span>out: ' + formatTokenCount(m.output_tokens) + '</span>'
+ '</div>';
}
}
popover.innerHTML = html;
}).catch(function() {});
}
// 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');
});
// --- TEE attestation ---
let teeInfo = null;
let teeReportCache = null;
let teeReportLoading = false;
function teeApiBase() {
var parts = window.location.hostname.split('.');
if (parts.length < 2) return null;
var domain = parts.slice(1).join('.');
return window.location.protocol + '//api.' + domain;
}
function teeInstanceName() {
return window.location.hostname.split('.')[0];
}
function checkTeeStatus() {
var base = teeApiBase();
if (!base) return;
var name = teeInstanceName();
fetch(base + '/instances/' + encodeURIComponent(name) + '/attestation').then(function(res) {
if (!res.ok) throw new Error(res.status);
return res.json();
}).then(function(data) {
teeInfo = data;
document.getElementById('tee-shield').style.display = 'flex';
}).catch(function() {});
}
function fetchTeeReport() {
if (teeReportCache) {
renderTeePopover(teeReportCache);
return;
}
if (teeReportLoading) return;
teeReportLoading = true;
var base = teeApiBase();
if (!base) return;
var popover = document.getElementById('tee-popover');
popover.innerHTML = '<div class="tee-popover-loading">Loading attestation report...</div>';
fetch(base + '/attestation/report').then(function(res) {
if (!res.ok) throw new Error(res.status);
return res.json();
}).then(function(data) {
teeReportCache = data;
renderTeePopover(data);
}).catch(function() {
popover.innerHTML = '<div class="tee-popover-loading">Could not load attestation report</div>';
}).finally(function() {
teeReportLoading = false;
});
}
function renderTeePopover(report) {
var popover = document.getElementById('tee-popover');
var digest = (teeInfo && teeInfo.image_digest) || 'N/A';
var fingerprint = report.tls_certificate_fingerprint || 'N/A';
var reportData = report.report_data || '';
var vmConfig = report.vm_config || 'N/A';
var truncated = reportData.length > 32 ? reportData.slice(0, 32) + '...' : reportData;
popover.innerHTML = '<div class="tee-popover-title">'
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
+ 'TEE Attestation</div>'
+ '<div class="tee-field"><div class="tee-field-label">Image Digest</div>'
+ '<div class="tee-field-value">' + escapeHtml(digest) + '</div></div>'
+ '<div class="tee-field"><div class="tee-field-label">TLS Certificate Fingerprint</div>'
+ '<div class="tee-field-value">' + escapeHtml(fingerprint) + '</div></div>'
+ '<div class="tee-field"><div class="tee-field-label">Report Data</div>'
+ '<div class="tee-field-value">' + escapeHtml(truncated) + '</div></div>'
+ '<div class="tee-field"><div class="tee-field-label">VM Config</div>'
+ '<div class="tee-field-value">' + escapeHtml(vmConfig) + '</div></div>'
+ '<div class="tee-popover-actions">'
+ '<button class="tee-btn-copy" onclick="copyTeeReport()">Copy Full Report</button></div>';
}
function copyTeeReport() {
if (!teeReportCache) return;
var combined = Object.assign({}, teeReportCache, teeInfo || {});
navigator.clipboard.writeText(JSON.stringify(combined, null, 2)).then(function() {
showToast('Attestation report copied', 'success');
}).catch(function() {
showToast('Failed to copy report', 'error');
});
}
document.getElementById('tee-shield').addEventListener('mouseenter', function() {
fetchTeeReport();
document.getElementById('tee-popover').classList.add('visible');
});
document.getElementById('tee-shield').addEventListener('mouseleave', function() {
document.getElementById('tee-popover').classList.remove('visible');
});
// --- Extension install ---
function installWasmExtension() {
var name = document.getElementById('wasm-install-name').value.trim();
if (!name) {
showToast('Extension name is required', 'error');
return;
}
var url = document.getElementById('wasm-install-url').value.trim();
if (!url) {
showToast('URL to .tar.gz bundle is required', 'error');
return;
}
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: name, url: url, kind: 'wasm_tool' },
}).then(function(res) {
if (res.success) {
showToast('Installed ' + name, 'success');
document.getElementById('wasm-install-name').value = '';
document.getElementById('wasm-install-url').value = '';
loadExtensions();
} else {
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
}
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
});
}
function addMcpServer() {
var name = document.getElementById('mcp-install-name').value.trim();
if (!name) {
showToast('Server name is required', 'error');
return;
}
var url = document.getElementById('mcp-install-url').value.trim();
if (!url) {
showToast('MCP server URL is required', 'error');
return;
}
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: name, url: url, kind: 'mcp_server' },
}).then(function(res) {
if (res.success) {
showToast('Added MCP server ' + name, 'success');
document.getElementById('mcp-install-name').value = '';
document.getElementById('mcp-install-url').value = '';
loadExtensions();
} else {
showToast('Failed to add MCP server: ' + (res.message || 'unknown error'), 'error');
}
}).catch(function(err) {
showToast('Failed to add MCP server: ' + err.message, 'error');
});
}
// --- Skills ---
function loadSkills() {
var skillsList = document.getElementById('skills-list');
apiFetch('/api/skills').then(function(data) {
if (!data.skills || data.skills.length === 0) {
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
return;
}
skillsList.innerHTML = '';
for (var i = 0; i < data.skills.length; i++) {
skillsList.appendChild(renderSkillCard(data.skills[i]));
}
}).catch(function(err) {
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
});
}
function renderSkillCard(skill) {
var card = document.createElement('div');
card.className = 'ext-card';
var header = document.createElement('div');
header.className = 'ext-header';
var name = document.createElement('span');
name.className = 'ext-name';
name.textContent = skill.name;
header.appendChild(name);
var trust = document.createElement('span');
var trustClass = skill.trust.toLowerCase() === 'trusted' ? 'trust-trusted' : 'trust-installed';
trust.className = 'skill-trust ' + trustClass;
trust.textContent = skill.trust;
header.appendChild(trust);
var version = document.createElement('span');
version.className = 'skill-version';
version.textContent = 'v' + skill.version;
header.appendChild(version);
card.appendChild(header);
var desc = document.createElement('div');
desc.className = 'ext-desc';
desc.textContent = skill.description;
card.appendChild(desc);
if (skill.keywords && skill.keywords.length > 0) {
var kw = document.createElement('div');
kw.className = 'ext-keywords';
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
card.appendChild(kw);
}
var actions = document.createElement('div');
actions.className = 'ext-actions';
// Only show Remove for registry-installed skills, not user-placed trusted skills
if (skill.trust.toLowerCase() !== 'trusted') {
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
actions.appendChild(removeBtn);
}
card.appendChild(actions);
return card;
}
function searchClawHub() {
var input = document.getElementById('skill-search-input');
var query = input.value.trim();
if (!query) return;
var resultsDiv = document.getElementById('skill-search-results');
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
apiFetch('/api/skills/search', {
method: 'POST',
body: { query: query },
}).then(function(data) {
resultsDiv.innerHTML = '';
// Show registry error as a warning banner if present
if (data.catalog_error) {
var warning = document.createElement('div');
warning.className = 'empty-state';
warning.style.color = '#f0ad4e';
warning.style.borderLeft = '3px solid #f0ad4e';
warning.style.paddingLeft = '12px';
warning.style.marginBottom = '16px';
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
resultsDiv.appendChild(warning);
}
// Show catalog results
if (data.catalog && data.catalog.length > 0) {
// Build a set of installed skill names for quick lookup
var installedNames = {};
if (data.installed) {
for (var j = 0; j < data.installed.length; j++) {
installedNames[data.installed[j].name] = true;
}
}
for (var i = 0; i < data.catalog.length; i++) {
var card = renderCatalogSkillCard(data.catalog[i], installedNames);
card.style.animationDelay = (i * 0.06) + 's';
resultsDiv.appendChild(card);
}
}
// Show matching installed skills too
if (data.installed && data.installed.length > 0) {
for (var k = 0; k < data.installed.length; k++) {
var installedCard = renderSkillCard(data.installed[k]);
installedCard.style.animationDelay = ((data.catalog ? data.catalog.length : 0) + k) * 0.06 + 's';
installedCard.classList.add('skill-search-result');
resultsDiv.appendChild(installedCard);
}
}
if (resultsDiv.children.length === 0) {
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
}
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
});
}
function renderCatalogSkillCard(entry, installedNames) {
var card = document.createElement('div');
card.className = 'ext-card ext-available skill-search-result';
var header = document.createElement('div');
header.className = 'ext-header';
var name = document.createElement('a');
name.className = 'ext-name';
name.textContent = entry.name || entry.slug;
name.href = 'https://clawhub.ai/skills/' + encodeURIComponent(entry.slug);
name.target = '_blank';
name.rel = 'noopener';
name.style.textDecoration = 'none';
name.style.color = 'inherit';
name.title = 'View on ClawHub';
header.appendChild(name);
if (entry.version) {
var version = document.createElement('span');
version.className = 'skill-version';
version.textContent = 'v' + entry.version;
header.appendChild(version);
}
card.appendChild(header);
if (entry.description) {
var desc = document.createElement('div');
desc.className = 'ext-desc';
desc.textContent = entry.description;
card.appendChild(desc);
}
// Metadata row: owner, stars, downloads, recency
var meta = document.createElement('div');
meta.className = 'ext-meta';
meta.style.fontSize = '11px';
meta.style.color = '#888';
meta.style.marginTop = '6px';
function addMetaSep() {
if (meta.children.length > 0) {
meta.appendChild(document.createTextNode(' \u00b7 '));
}
}
if (entry.owner) {
var ownerSpan = document.createElement('span');
ownerSpan.textContent = 'by ' + entry.owner;
meta.appendChild(ownerSpan);
}
if (entry.stars != null) {
addMetaSep();
var starsSpan = document.createElement('span');
starsSpan.textContent = entry.stars + ' stars';
meta.appendChild(starsSpan);
}
if (entry.downloads != null) {
addMetaSep();
var dlSpan = document.createElement('span');
dlSpan.textContent = formatCompactNumber(entry.downloads) + ' downloads';
meta.appendChild(dlSpan);
}
if (entry.updatedAt) {
var ago = formatTimeAgo(entry.updatedAt);
if (ago) {
addMetaSep();
var updatedSpan = document.createElement('span');
updatedSpan.textContent = 'updated ' + ago;
meta.appendChild(updatedSpan);
}
}
if (meta.children.length > 0) {
card.appendChild(meta);
}
var actions = document.createElement('div');
actions.className = 'ext-actions';
var slug = entry.slug || entry.name;
var isInstalled = installedNames[entry.name] || installedNames[slug];
if (isInstalled) {
var label = document.createElement('span');
label.className = 'ext-active-label';
label.textContent = 'Installed';
actions.appendChild(label);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = 'Install';
installBtn.addEventListener('click', (function(s, btn) {
return function() {
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
btn.disabled = true;
btn.textContent = 'Installing...';
installSkill(s, null, btn);
};
})(slug, installBtn));
actions.appendChild(installBtn);
}
card.appendChild(actions);
return card;
}
function formatCompactNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return '' + n;
}
function formatTimeAgo(epochMs) {
var now = Date.now();
var diff = now - epochMs;
if (diff < 0) return null;
var minutes = Math.floor(diff / 60000);
if (minutes < 60) return minutes <= 1 ? 'just now' : minutes + 'm ago';
var hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
var days = Math.floor(hours / 24);
if (days < 30) return days + 'd ago';
var months = Math.floor(days / 30);
if (months < 12) return months + 'mo ago';
return Math.floor(months / 12) + 'y ago';
}
function installSkill(nameOrSlug, url, btn) {
var body = { name: nameOrSlug };
if (url) body.url = url;
apiFetch('/api/skills/install', {
method: 'POST',
headers: { 'X-Confirm-Action': 'true' },
body: body,
}).then(function(res) {
if (res.success) {
showToast('Installed skill "' + nameOrSlug + '"', 'success');
} else {
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
}
loadSkills();
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
});
}
function removeSkill(name) {
if (!confirm('Remove skill "' + name + '"?')) return;
apiFetch('/api/skills/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'X-Confirm-Action': 'true' },
}).then(function(res) {
if (res.success) {
showToast('Removed skill "' + name + '"', 'success');
} else {
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
}
loadSkills();
}).catch(function(err) {
showToast('Remove failed: ' + err.message, 'error');
});
}
function installSkillFromForm() {
var name = document.getElementById('skill-install-name').value.trim();
if (!name) { showToast('Skill name is required', 'error'); return; }
var url = document.getElementById('skill-install-url').value.trim() || null;
if (url && !url.startsWith('https://')) {
showToast('URL must use HTTPS', 'error');
return;
}
if (!confirm('Install skill "' + name + '"?')) return;
installSkill(name, url, null);
document.getElementById('skill-install-name').value = '';
document.getElementById('skill-install-url').value = '';
}
// Wire up Enter key on search input
document.getElementById('skill-search-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') searchClawHub();
});
// --- 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', 'extensions', 'skills'];
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 autocomplete, job detail, or blur input
if (e.key === 'Escape') {
const acEl = document.getElementById('slash-autocomplete');
if (acEl && acEl.style.display !== 'none') {
hideSlashAutocomplete();
return;
}
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();
}