(() => { const socket = io(); const logEl = document.getElementById('log'); const thinkingEl = document.getElementById('thinking'); const thinkingLabel = document.getElementById('thinkingLabel'); const statusDot = document.getElementById('statusDot'); const statusLabel = document.getElementById('statusLabel'); const agentNameEl = document.getElementById('agentName'); const composer = document.getElementById('composer'); const input = document.getElementById('composerInput'); const toolListEl = document.getElementById('toolList'); const benchToggle = document.getElementById('benchToggle'); const toolbench = document.getElementById('toolbench'); const modelSelect = document.getElementById('modelSelect'); let agentName = 'Forge'; let thinkingTimer = null; const ticketNodes = new Map(); // toolId -> { card, stamp, actions, resolved } const toolsById = new Map(); const icon = { check: '', x: '', }; function scrollToBottom() { logEl.scrollTop = logEl.scrollHeight; } function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function renderUser(entry) { logEl.appendChild(el('div', 'msg msg--user', entry.content)); } function renderAssistant(entry) { const node = el('div', 'msg msg--assistant', entry.content); node.dataset.name = agentName; logEl.appendChild(node); } function renderSystem(entry, isError) { logEl.appendChild(el('div', 'msg msg--system' + (isError ? ' is-error' : ''), entry.content)); } function stampLabel(tool) { if (tool.status === 'approved') return 'approved'; if (tool.status === 'rejected') return 'declined'; return tool.kind === 'fix' ? 'fix pending' : 'pending approval'; } function stampClass(tool) { if (tool.status === 'approved') return 'stamp approved'; if (tool.status === 'rejected') return 'stamp rejected'; return 'stamp'; } function renderTicket(tool) { const card = el('div', 'ticket'); const top = el('div', 'ticket__top'); const nameWrap = document.createElement('div'); const nameEl = el('span', 'ticket__name', tool.name); nameWrap.appendChild(nameEl); if (tool.kind === 'fix') nameWrap.appendChild(el('span', 'ticket__kind', 'fix')); const stamp = el('span', stampClass(tool), stampLabel(tool)); top.appendChild(nameWrap); top.appendChild(stamp); card.appendChild(top); card.appendChild(el('p', 'ticket__desc', tool.description)); if (tool.reason) card.appendChild(el('p', 'ticket__reason', `“${tool.reason}”`)); const details = document.createElement('details'); const summary = el('summary', null, 'view code'); const pre = document.createElement('pre'); pre.textContent = tool.code; details.appendChild(summary); details.appendChild(pre); card.appendChild(details); const actions = el('div', 'ticket__actions'); const resolved = el('div', 'ticket__resolved'); resolved.hidden = true; if (tool.status === 'pending') { const approveBtn = el('button', 'ticket__btn ticket__btn--approve'); approveBtn.innerHTML = icon.check + 'Approve'; approveBtn.addEventListener('click', () => { approveBtn.disabled = true; socket.emit('tool:approve', tool.id); }); const rejectBtn = el('button', 'ticket__btn ticket__btn--reject'); rejectBtn.innerHTML = icon.x + 'Decline'; rejectBtn.addEventListener('click', () => { rejectBtn.disabled = true; socket.emit('tool:reject', { toolId: tool.id }); }); actions.appendChild(approveBtn); actions.appendChild(rejectBtn); } else { resolved.hidden = false; resolved.textContent = tool.status === 'approved' ? 'Ready to use.' : 'Not built.'; } card.appendChild(actions); card.appendChild(resolved); logEl.appendChild(card); ticketNodes.set(tool.id, { card, stamp, actions, resolved }); } function updateTicket(matchId, tool) { const node = ticketNodes.get(matchId); if (!node) return; node.stamp.className = stampClass(tool); node.stamp.textContent = stampLabel(tool); node.actions.innerHTML = ''; node.resolved.hidden = false; node.resolved.textContent = tool.status === 'approved' ? 'Ready to use.' : 'Not built.'; } function renderEntry(entry) { switch (entry.type) { case 'user': renderUser(entry); break; case 'assistant': renderAssistant(entry); break; case 'tool_proposed': toolsById.set(entry.tool.id, entry.tool); renderTicket(entry.tool); break; case 'tool_update': { toolsById.set(entry.tool.id, entry.tool); const matchId = entry.matchId || entry.tool.id; updateTicket(matchId, entry.tool); renderSystem({ content: `${entry.tool.name} ${entry.action === 'approved' ? 'approved — Forge can use it now.' : 'declined.'}`, }); break; } case 'tool_error': renderSystem({ content: `${entry.toolName} hit an error: ${entry.message}` }, true); break; default: break; } } function renderToolbench() { const tools = Array.from(toolsById.values()); toolListEl.innerHTML = ''; if (!tools.length) { toolListEl.appendChild(el('p', 'toolbench__empty', 'No tools yet — ask Forge to build something.')); return; } tools .slice() .sort((a, b) => (a.status === 'pending' ? -1 : 1) - (b.status === 'pending' ? -1 : 1)) .forEach((tool) => { const row = el('div', 'tool-row'); row.appendChild(el('span', `tool-row__dot ${tool.status}`)); row.appendChild(el('span', 'tool-row__name', tool.name)); row.appendChild(el('span', 'tool-row__status', tool.status)); toolListEl.appendChild(row); }); } function populateModels(models, current) { modelSelect.innerHTML = ''; (models || []).forEach((m) => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; if (m === current) opt.selected = true; modelSelect.appendChild(opt); }); } modelSelect.addEventListener('change', () => { socket.emit('model:change', modelSelect.value); }); socket.on('init', (data) => { agentName = data.agentName || 'Forge'; agentNameEl.textContent = agentName; populateModels(data.availableModels, data.model); (data.tools || []).forEach((t) => toolsById.set(t.id, t)); (data.log || []).forEach(renderEntry); renderToolbench(); scrollToBottom(); }); socket.on('log:entry', (entry) => { renderEntry(entry); renderToolbench(); scrollToBottom(); }); socket.on('model:changed', ({ model }) => { const opt = modelSelect.querySelector(`option[value="${model}"]`); if (opt) opt.selected = true; }); socket.on('agent:status', ({ state, detail }) => { if (thinkingTimer) { clearTimeout(thinkingTimer); thinkingTimer = null; } if (state === 'idle') { thinkingEl.hidden = true; statusDot.classList.remove('busy'); statusLabel.textContent = 'online'; } else if (state === 'thinking' || state === 'running_tool') { thinkingEl.hidden = false; thinkingLabel.textContent = state === 'thinking' ? 'thinking' : `running ${detail || 'a tool'}`; statusDot.classList.add('busy'); statusLabel.textContent = state === 'thinking' ? 'thinking' : 'building'; thinkingTimer = setTimeout(() => { thinkingEl.hidden = true; statusDot.classList.remove('busy'); statusLabel.textContent = 'online'; thinkingTimer = null; }, 90000); } scrollToBottom(); }); socket.on('connect_error', () => { statusLabel.textContent = 'offline'; statusDot.classList.remove('busy'); }); composer.addEventListener('submit', (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; socket.emit('chat:send', text); input.value = ''; input.style.height = 'auto'; }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); composer.requestSubmit(); } }); input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 160) + 'px'; }); benchToggle.addEventListener('click', () => { toolbench.classList.toggle('open'); }); })();