(() => {
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');
});
})();