const state = {
ws: null,
shells: [],
agents: [],
repos: [],
workstreams: [],
currentView: 'dashboard', activeShellId: null,
activeAgentId: null,
term: null,
fitAddon: null,
resizeListener: null,
showRaw: false,
repoModal: {
open: false,
currentPath: '/',
children: [],
suggestedName: '',
gitRemote: null,
gitBranch: null,
loading: false,
filter: '',
},
};
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
state.ws = new WebSocket(`${proto}//${location.host}/ws`);
state.ws.onopen = () => {
document.getElementById('status').textContent = 'connected';
document.getElementById('status').className = 'status connected';
};
state.ws.onclose = () => {
document.getElementById('status').textContent = 'disconnected';
document.getElementById('status').className = 'status disconnected';
setTimeout(connect, 2000);
};
state.ws.onmessage = (evt) => {
let msg;
try {
msg = JSON.parse(evt.data);
} catch {
return;
}
handleMessage(msg);
};
}
function handleMessage(msg) {
switch (msg.type) {
case 'state_snapshot':
state.shells = msg.state.shells || [];
state.agents = msg.state.agents || [];
state.repos = msg.state.repos || [];
state.workstreams = msg.state.workstreams || [];
renderSidebar();
if (state._pendingRoute) {
applyPendingRoute();
} else if (state.currentView === 'dashboard') {
renderDashboard();
}
break;
case 'shell_output':
if (state.currentView === 'terminal' && state.activeShellId === msg.shell_id && state.term) {
const data = new Uint8Array(msg.data);
state.term.write(data);
}
break;
case 'agent_conversation_line':
if (state.currentView === 'conversation' && state.activeAgentId === msg.shell_id) {
appendConversationLine(msg.line);
}
break;
case 'agent_watch_end':
if (state.currentView === 'conversation' && state.activeAgentId === msg.shell_id) {
appendConversationLine('[watch ended]');
}
break;
case 'introspect_result':
if (state.repoModal.open && state.repoModal.currentPath === msg.path) {
state.repoModal.children = msg.children || [];
state.repoModal.suggestedName = msg.suggested_name || '';
state.repoModal.gitRemote = msg.git_remote || null;
state.repoModal.gitBranch = msg.git_branch || null;
state.repoModal.loading = false;
renderRepoModal();
}
break;
}
}
function send(cmd) {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify(cmd));
}
}
function renderSidebar() {
const tree = document.getElementById('sidebar-tree');
tree.innerHTML = '';
const agentShellIds = new Set(state.agents.map(a => a.vex_shell_id));
const claimedAgents = new Set();
const claimedShells = new Set();
function agentsIn(path) {
return state.agents.filter(a => a.cwd && a.cwd.startsWith(path));
}
function nonAgentShells() {
return state.shells.filter(s => !agentShellIds.has(s.id));
}
if (state.repos.length > 0) {
const section = document.createElement('div');
section.className = 'section';
const h3 = document.createElement('h3');
h3.textContent = 'Repos';
section.appendChild(h3);
const ul = document.createElement('ul');
state.repos.forEach(r => {
const li = document.createElement('li');
li.textContent = r.name;
li.addEventListener('click', () => viewRepoDetail(r.name));
ul.appendChild(li);
const repoWorkstreams = state.workstreams.filter(ws => ws.repo === r.name);
repoWorkstreams.forEach(ws => {
const wsLi = document.createElement('li');
wsLi.className = 'nested-ws';
wsLi.textContent = ws.name;
wsLi.addEventListener('click', (e) => {
e.stopPropagation();
viewWorkstreamDetail(ws.repo, ws.name);
});
ul.appendChild(wsLi);
const wsAgents = agentsIn(ws.worktree_path);
wsAgents.forEach(a => {
claimedAgents.add(a.vex_shell_id);
const aLi = document.createElement('li');
aLi.className = 'nested-agent';
if (state.activeAgentId === a.vex_shell_id) aLi.classList.add('active');
aLi.textContent = a.vex_shell_id.substring(0, 8);
const badge = document.createElement('span');
badge.className = a.needs_intervention ? 'badge needs' : 'badge idle';
badge.textContent = a.needs_intervention ? 'NEEDS' : 'idle';
aLi.appendChild(badge);
aLi.addEventListener('click', (e) => { e.stopPropagation(); viewAgent(a.vex_shell_id); });
ul.appendChild(aLi);
});
});
const repoAgents = agentsIn(r.path).filter(a => !claimedAgents.has(a.vex_shell_id));
repoAgents.forEach(a => {
claimedAgents.add(a.vex_shell_id);
const aLi = document.createElement('li');
aLi.className = 'nested-agent';
if (state.activeAgentId === a.vex_shell_id) aLi.classList.add('active');
aLi.textContent = a.vex_shell_id.substring(0, 8);
const badge = document.createElement('span');
badge.className = a.needs_intervention ? 'badge needs' : 'badge idle';
badge.textContent = a.needs_intervention ? 'NEEDS' : 'idle';
aLi.appendChild(badge);
aLi.addEventListener('click', (e) => { e.stopPropagation(); viewAgent(a.vex_shell_id); });
ul.appendChild(aLi);
});
});
section.appendChild(ul);
tree.appendChild(section);
}
const unclaimedAgents = state.agents.filter(a => !claimedAgents.has(a.vex_shell_id));
if (unclaimedAgents.length > 0) {
const section = document.createElement('div');
section.className = 'section';
const h3 = document.createElement('h3');
h3.textContent = 'Agents';
section.appendChild(h3);
const ul = document.createElement('ul');
unclaimedAgents.forEach(a => {
const li = document.createElement('li');
if (state.activeAgentId === a.vex_shell_id) li.className = 'active';
li.textContent = a.vex_shell_id.substring(0, 8);
const badge = document.createElement('span');
badge.className = a.needs_intervention ? 'badge needs' : 'badge idle';
badge.textContent = a.needs_intervention ? 'NEEDS' : 'idle';
li.appendChild(badge);
li.addEventListener('click', () => viewAgent(a.vex_shell_id));
ul.appendChild(li);
});
section.appendChild(ul);
tree.appendChild(section);
}
const orphanShells = nonAgentShells();
if (orphanShells.length > 0) {
const section = document.createElement('div');
section.className = 'section';
const h3 = document.createElement('h3');
h3.textContent = 'Shells';
section.appendChild(h3);
const ul = document.createElement('ul');
orphanShells.forEach(s => {
const li = document.createElement('li');
if (state.activeShellId === s.id) li.className = 'active';
li.textContent = s.id.substring(0, 8);
li.addEventListener('click', () => attachShell(s.id));
ul.appendChild(li);
});
section.appendChild(ul);
tree.appendChild(section);
}
}
function renderDashboard() {
const agentsBody = document.querySelector('#agents-table tbody');
agentsBody.innerHTML = '';
state.agents.forEach(a => {
const shortId = a.vex_shell_id.substring(0, 8);
const statusText = a.needs_intervention ? '\u26A0 NEEDS' : '\u25CF idle';
const tr = document.createElement('tr');
const tdId = document.createElement('td');
tdId.textContent = shortId;
tr.appendChild(tdId);
const tdStatus = document.createElement('td');
tdStatus.textContent = statusText;
tr.appendChild(tdStatus);
const tdCwd = document.createElement('td');
tdCwd.textContent = a.cwd;
tr.appendChild(tdCwd);
const tdActions = document.createElement('td');
const watchBtn = document.createElement('button');
watchBtn.className = 'action-btn';
watchBtn.textContent = 'watch';
watchBtn.addEventListener('click', () => viewAgent(a.vex_shell_id));
tdActions.appendChild(watchBtn);
const promptBtn = document.createElement('button');
promptBtn.className = 'action-btn';
promptBtn.textContent = 'prompt';
promptBtn.addEventListener('click', () => promptAgent(a.vex_shell_id));
tdActions.appendChild(promptBtn);
const killBtn = document.createElement('button');
killBtn.className = 'action-btn danger';
killBtn.textContent = 'kill';
killBtn.addEventListener('click', () => killShell(a.vex_shell_id));
tdActions.appendChild(killBtn);
tr.appendChild(tdActions);
agentsBody.appendChild(tr);
});
const shellsBody = document.querySelector('#shells-table tbody');
shellsBody.innerHTML = '';
state.shells.forEach(s => {
const shortId = s.id.substring(0, 8);
const tr = document.createElement('tr');
const tdId = document.createElement('td');
tdId.textContent = shortId;
tr.appendChild(tdId);
const tdSize = document.createElement('td');
tdSize.textContent = s.cols + 'x' + s.rows;
tr.appendChild(tdSize);
const tdClients = document.createElement('td');
tdClients.textContent = s.client_count;
tr.appendChild(tdClients);
const tdCreated = document.createElement('td');
tdCreated.textContent = new Date(s.created_at).toLocaleTimeString();
tr.appendChild(tdCreated);
const tdActions = document.createElement('td');
const attachBtn = document.createElement('button');
attachBtn.className = 'action-btn';
attachBtn.textContent = 'attach';
attachBtn.addEventListener('click', () => attachShell(s.id));
tdActions.appendChild(attachBtn);
const killBtn = document.createElement('button');
killBtn.className = 'action-btn danger';
killBtn.textContent = 'kill';
killBtn.addEventListener('click', () => killShell(s.id));
tdActions.appendChild(killBtn);
tr.appendChild(tdActions);
shellsBody.appendChild(tr);
});
}
function showView(view) {
document.getElementById('dashboard').style.display = view === 'dashboard' ? '' : 'none';
document.getElementById('terminal-container').style.display = view === 'terminal' ? '' : 'none';
document.getElementById('conversation-container').style.display = view === 'conversation' ? '' : 'none';
document.getElementById('repo-detail-container').style.display = view === 'repo-detail' ? '' : 'none';
document.getElementById('workstream-detail-container').style.display = view === 'workstream-detail' ? '' : 'none';
state.currentView = view;
}
function attachShell(id) {
state.activeShellId = id;
showView('terminal');
updateURL('terminal', id);
const termEl = document.getElementById('terminal');
termEl.innerHTML = '';
document.getElementById('terminal-title').textContent = 'Shell ' + id.substring(0, 8);
state.term = new Terminal({ theme: { background: '#1a1b26', foreground: '#a9b1d6' } });
state.fitAddon = new FitAddon.FitAddon();
state.term.loadAddon(state.fitAddon);
state.term.open(termEl);
state.fitAddon.fit();
const cols = state.term.cols;
const rows = state.term.rows;
send({ type: 'ShellAttach', id, cols, rows });
state.term.onData(data => {
const bytes = Array.from(new TextEncoder().encode(data));
send({ type: 'ShellInput', id, data: bytes });
});
state.term.onResize(({ cols, rows }) => {
send({ type: 'ShellResize', id, cols, rows });
});
if (state.resizeListener) {
window.removeEventListener('resize', state.resizeListener);
}
state.resizeListener = () => {
if (state.fitAddon) state.fitAddon.fit();
};
window.addEventListener('resize', state.resizeListener);
renderSidebar();
}
function detachShell() {
if (state.activeShellId) {
send({ type: 'ShellDetach', id: state.activeShellId });
}
if (state.term) {
state.term.dispose();
state.term = null;
}
if (state.resizeListener) {
window.removeEventListener('resize', state.resizeListener);
state.resizeListener = null;
}
state.activeShellId = null;
showView('dashboard');
updateURL('dashboard');
renderSidebar();
renderDashboard();
}
function viewAgent(id) {
state.activeAgentId = id;
showView('conversation');
updateURL('conversation', id);
document.getElementById('conversation-title').textContent = 'Agent ' + id.substring(0, 8);
document.getElementById('conversation-lines').innerHTML = '';
send({ type: 'AgentWatch', shell_id: id });
renderSidebar();
}
function backFromConversation() {
state.activeAgentId = null;
showView('dashboard');
updateURL('dashboard');
renderSidebar();
renderDashboard();
}
function renderMarkdown(text) {
if (typeof marked !== 'undefined' && marked.parse) {
return marked.parse(text, { breaks: true });
}
return esc(text).replace(/\n/g, '<br>');
}
function appendConversationLine(line) {
const container = document.getElementById('conversation-lines');
const div = document.createElement('div');
div.className = 'line';
let isRawOnly = false;
try {
const parsed = JSON.parse(line);
if (parsed.type === 'user') {
div.className = 'line user';
const text = extractText(parsed);
div.innerHTML = '<span class="user-prefix">> </span>' + renderMarkdown(text);
} else if (parsed.type === 'assistant') {
div.className = 'line assistant';
div.innerHTML = renderMarkdown(extractAssistantText(parsed));
} else {
div.textContent = line;
isRawOnly = true;
}
} catch {
div.textContent = line;
isRawOnly = true;
}
if (isRawOnly) {
div.className = 'line raw-only';
}
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function extractText(msg) {
const content = msg.message?.content;
if (typeof content === 'string') return content.trim();
if (Array.isArray(content)) {
return content.filter(c => c.type === 'text').map(c => c.text).join('\n').trim();
}
return '';
}
function extractAssistantText(msg) {
const content = msg.message?.content;
if (typeof content === 'string') return content.trim();
if (Array.isArray(content)) {
return content.map(c => {
if (c.type === 'text') return c.text;
if (c.type === 'tool_use') {
const path = c.input?.file_path || c.input?.path || c.input?.command || '';
return '[tool: ' + c.name + '] ' + path;
}
return '';
}).filter(Boolean).join('\n').trim();
}
return '';
}
function promptAgent(id) {
const text = prompt('Send prompt to agent:');
if (text) {
send({ type: 'AgentPrompt', shell_id: id, text });
}
}
function killShell(id) {
if (confirm('Kill this shell?')) {
send({ type: 'ShellKill', id });
}
}
function createShell() {
send({ type: 'ShellCreate', repo: null, workstream: null });
}
function spawnAgent() {
if (state.repos.length === 0) {
alert('No repos registered');
return;
}
const repo = prompt('Repo name:', state.repos[0]?.name || '');
if (repo) {
send({ type: 'AgentSpawn', repo, workstream: null });
}
}
document.getElementById('btn-detach').addEventListener('click', detachShell);
document.getElementById('btn-back-conversation').addEventListener('click', backFromConversation);
document.getElementById('btn-create-shell').addEventListener('click', createShell);
document.getElementById('btn-spawn-agent').addEventListener('click', spawnAgent);
document.getElementById('btn-send-prompt').addEventListener('click', () => {
const input = document.getElementById('prompt-input');
const text = input.value.trim();
if (text && state.activeAgentId) {
send({ type: 'AgentPrompt', shell_id: state.activeAgentId, text });
input.value = '';
}
});
document.getElementById('prompt-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
document.getElementById('btn-send-prompt').click();
}
});
document.getElementById('btn-agent-shell').addEventListener('click', () => {
if (state.activeAgentId) {
attachShell(state.activeAgentId);
}
});
document.getElementById('btn-toggle-raw').addEventListener('click', () => {
state.showRaw = !state.showRaw;
document.body.classList.toggle('show-raw', state.showRaw);
document.getElementById('btn-toggle-raw').textContent = state.showRaw ? 'Show Parsed' : 'Show Raw';
});
function viewRepoDetail(repoName) {
showView('repo-detail');
document.getElementById('repo-detail-title').textContent = 'Repo: ' + repoName;
const body = document.getElementById('repo-detail-body');
body.innerHTML = '';
const repo = state.repos.find(r => r.name === repoName);
if (!repo) {
body.textContent = 'Repo not found';
return;
}
const info = document.createElement('div');
info.className = 'detail-section';
info.innerHTML = '<h3>Info</h3>';
const dl = document.createElement('dl');
dl.className = 'detail-dl';
dl.innerHTML = '<dt>Name</dt><dd>' + esc(repo.name) + '</dd>'
+ '<dt>Path</dt><dd>' + esc(repo.path) + '</dd>';
info.appendChild(dl);
body.appendChild(info);
const wsSection = document.createElement('div');
wsSection.className = 'detail-section';
const wsHeader = document.createElement('div');
wsHeader.className = 'detail-section-header';
wsHeader.innerHTML = '<h3>Workstreams</h3>';
const addWsBtn = document.createElement('button');
addWsBtn.textContent = '+ Workstream';
addWsBtn.addEventListener('click', () => openWorkstreamModal(repoName));
wsHeader.appendChild(addWsBtn);
wsSection.appendChild(wsHeader);
const repoWorkstreams = state.workstreams.filter(ws => ws.repo === repoName);
if (repoWorkstreams.length === 0) {
const empty = document.createElement('p');
empty.className = 'detail-empty';
empty.textContent = 'No workstreams yet';
wsSection.appendChild(empty);
} else {
const ul = document.createElement('ul');
ul.className = 'detail-list';
repoWorkstreams.forEach(ws => {
const li = document.createElement('li');
li.textContent = ws.name + ' (' + ws.branch + ')';
li.addEventListener('click', () => viewWorkstreamDetail(ws.repo, ws.name));
ul.appendChild(li);
});
wsSection.appendChild(ul);
}
body.appendChild(wsSection);
const actionsSection = document.createElement('div');
actionsSection.className = 'detail-section';
const actionsHeader = document.createElement('div');
actionsHeader.className = 'detail-section-header';
actionsHeader.innerHTML = '<h3>Actions</h3>';
actionsSection.appendChild(actionsHeader);
const actionsRow = document.createElement('div');
actionsRow.className = 'detail-actions';
const spawnAgentBtn = document.createElement('button');
spawnAgentBtn.textContent = '+ Agent';
spawnAgentBtn.addEventListener('click', () => {
send({ type: 'AgentSpawn', repo: repoName, workstream: null });
});
actionsRow.appendChild(spawnAgentBtn);
const createShellBtn = document.createElement('button');
createShellBtn.textContent = '+ Shell';
createShellBtn.addEventListener('click', () => {
send({ type: 'ShellCreate', repo: repoName, workstream: null });
});
actionsRow.appendChild(createShellBtn);
const removeRepoBtn = document.createElement('button');
removeRepoBtn.className = 'danger';
removeRepoBtn.textContent = 'Remove Repo';
removeRepoBtn.addEventListener('click', () => {
if (confirm('Remove repo "' + repoName + '"? This only unregisters it.')) {
send({ type: 'RepoRemove', name: repoName });
backFromRepoDetail();
}
});
actionsRow.appendChild(removeRepoBtn);
actionsSection.appendChild(actionsRow);
body.appendChild(actionsSection);
const agentSection = document.createElement('div');
agentSection.className = 'detail-section';
agentSection.innerHTML = '<h3>Agents</h3>';
const repoAgents = state.agents.filter(a => a.cwd && a.cwd.startsWith(repo.path));
if (repoAgents.length === 0) {
const empty = document.createElement('p');
empty.className = 'detail-empty';
empty.textContent = 'No agents in this repo';
agentSection.appendChild(empty);
} else {
const ul = document.createElement('ul');
ul.className = 'detail-list';
repoAgents.forEach(a => {
const li = document.createElement('li');
const shortId = a.vex_shell_id.substring(0, 8);
const status = a.needs_intervention ? 'NEEDS' : 'idle';
li.textContent = shortId + ' - ' + status;
li.addEventListener('click', () => viewAgent(a.vex_shell_id));
ul.appendChild(li);
});
agentSection.appendChild(ul);
}
body.appendChild(agentSection);
}
function viewWorkstreamDetail(repoName, wsName) {
showView('workstream-detail');
document.getElementById('workstream-detail-title').textContent = 'Workstream: ' + wsName;
const body = document.getElementById('workstream-detail-body');
body.innerHTML = '';
const ws = state.workstreams.find(w => w.repo === repoName && w.name === wsName);
if (!ws) {
body.textContent = 'Workstream not found';
return;
}
const dl = document.createElement('dl');
dl.className = 'detail-dl';
dl.innerHTML = '<dt>Name</dt><dd>' + esc(ws.name) + '</dd>'
+ '<dt>Repo</dt><dd>' + esc(ws.repo) + '</dd>'
+ '<dt>Branch</dt><dd>' + esc(ws.branch) + '</dd>'
+ '<dt>Worktree</dt><dd>' + esc(ws.worktree_path) + '</dd>';
body.appendChild(dl);
const actionsSection = document.createElement('div');
actionsSection.className = 'detail-section';
const actionsHeader = document.createElement('div');
actionsHeader.className = 'detail-section-header';
actionsHeader.innerHTML = '<h3>Actions</h3>';
actionsSection.appendChild(actionsHeader);
const actionsRow = document.createElement('div');
actionsRow.className = 'detail-actions';
const spawnAgentBtn = document.createElement('button');
spawnAgentBtn.textContent = '+ Agent';
spawnAgentBtn.addEventListener('click', () => {
send({ type: 'AgentSpawn', repo: repoName, workstream: wsName });
});
actionsRow.appendChild(spawnAgentBtn);
const createShellBtn = document.createElement('button');
createShellBtn.textContent = '+ Shell';
createShellBtn.addEventListener('click', () => {
send({ type: 'ShellCreate', repo: repoName, workstream: wsName });
});
actionsRow.appendChild(createShellBtn);
const removeWsBtn = document.createElement('button');
removeWsBtn.className = 'danger';
removeWsBtn.textContent = 'Remove Workstream';
removeWsBtn.addEventListener('click', () => {
if (confirm('Remove workstream "' + wsName + '"? This will delete the git worktree and branch.')) {
send({ type: 'WorkstreamRemove', repo: repoName, name: wsName });
backFromWorkstreamDetail();
}
});
actionsRow.appendChild(removeWsBtn);
actionsSection.appendChild(actionsRow);
body.appendChild(actionsSection);
const repo = state.repos.find(r => r.name === repoName);
const wsAgents = state.agents.filter(a => a.cwd && a.cwd.startsWith(ws.worktree_path));
if (wsAgents.length > 0) {
const agentSection = document.createElement('div');
agentSection.className = 'detail-section';
agentSection.innerHTML = '<h3>Agents</h3>';
const ul = document.createElement('ul');
ul.className = 'detail-list';
wsAgents.forEach(a => {
const li = document.createElement('li');
const shortId = a.vex_shell_id.substring(0, 8);
const status = a.needs_intervention ? 'NEEDS' : 'idle';
li.textContent = shortId + ' - ' + status;
li.addEventListener('click', () => viewAgent(a.vex_shell_id));
ul.appendChild(li);
});
agentSection.appendChild(ul);
body.appendChild(agentSection);
}
}
function backFromRepoDetail() {
showView('dashboard');
renderDashboard();
}
function backFromWorkstreamDetail() {
showView('dashboard');
renderDashboard();
}
function esc(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.getElementById('btn-back-repo-detail').addEventListener('click', backFromRepoDetail);
document.getElementById('btn-back-ws-detail').addEventListener('click', backFromWorkstreamDetail);
function openWorkstreamModal(preselectedRepo) {
if (state.repos.length === 0) {
alert('No repos registered');
return;
}
document.getElementById('workstream-modal').style.display = '';
const select = document.getElementById('ws-modal-repo');
select.innerHTML = '';
state.repos.forEach(r => {
const opt = document.createElement('option');
opt.value = r.name;
opt.textContent = r.name;
if (r.name === preselectedRepo) opt.selected = true;
select.appendChild(opt);
});
document.getElementById('ws-modal-name').value = '';
}
function closeWorkstreamModal() {
document.getElementById('workstream-modal').style.display = 'none';
}
function normalizeName(s) {
return s.toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9\-_]/g, '');
}
function confirmWorkstreamModal() {
const repo = document.getElementById('ws-modal-repo').value;
const raw = document.getElementById('ws-modal-name').value.trim();
const name = normalizeName(raw);
if (!repo || !name) return;
send({ type: 'WorkstreamCreate', repo, name });
closeWorkstreamModal();
}
document.getElementById('btn-add-workstream').addEventListener('click', () => openWorkstreamModal(null));
document.getElementById('ws-modal-cancel').addEventListener('click', closeWorkstreamModal);
document.getElementById('ws-modal-confirm').addEventListener('click', confirmWorkstreamModal);
document.getElementById('ws-modal-name').addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmWorkstreamModal();
});
function fuzzyMatch(text, pattern) {
if (!pattern) return true;
const lower = text.toLowerCase();
const pat = pattern.toLowerCase();
let pi = 0;
for (let i = 0; i < lower.length && pi < pat.length; i++) {
if (lower[i] === pat[pi]) pi++;
}
return pi === pat.length;
}
function openRepoModal() {
state.repoModal.open = true;
state.repoModal.currentPath = '/';
state.repoModal.children = [];
state.repoModal.suggestedName = '';
state.repoModal.gitRemote = null;
state.repoModal.gitBranch = null;
state.repoModal.loading = true;
state.repoModal.filter = '';
document.getElementById('repo-modal').style.display = '';
document.getElementById('repo-modal-name').value = '';
document.getElementById('repo-modal-filter').value = '';
renderRepoModal();
send({ type: 'IntrospectPath', path: '/' });
}
function closeRepoModal() {
state.repoModal.open = false;
document.getElementById('repo-modal').style.display = 'none';
}
function renderRepoModal() {
document.getElementById('repo-modal-path').textContent = state.repoModal.currentPath;
const nameInput = document.getElementById('repo-modal-name');
if (!nameInput.value && state.repoModal.suggestedName) {
nameInput.value = state.repoModal.suggestedName;
}
const gitDiv = document.getElementById('repo-modal-git');
if (state.repoModal.gitRemote || state.repoModal.gitBranch) {
gitDiv.style.display = '';
const parts = [];
if (state.repoModal.gitRemote) parts.push(state.repoModal.gitRemote);
if (state.repoModal.gitBranch) parts.push('(' + state.repoModal.gitBranch + ')');
gitDiv.textContent = 'Git: ' + parts.join(' ');
} else {
gitDiv.style.display = 'none';
}
const list = document.getElementById('repo-modal-children');
list.innerHTML = '';
if (state.repoModal.loading) {
const li = document.createElement('li');
li.textContent = 'loading...';
li.className = 'loading';
list.appendChild(li);
} else if (state.repoModal.children.length === 0) {
const li = document.createElement('li');
li.textContent = '(no subdirectories)';
li.className = 'empty';
list.appendChild(li);
} else {
const filtered = state.repoModal.children.filter(c => fuzzyMatch(c, state.repoModal.filter));
if (filtered.length === 0) {
const li = document.createElement('li');
li.textContent = '(no matches)';
li.className = 'empty';
list.appendChild(li);
} else {
filtered.forEach(child => {
const li = document.createElement('li');
li.textContent = child;
li.addEventListener('click', () => navigateRepoModal(child));
list.appendChild(li);
});
}
}
}
function navigateRepoModal(child) {
const current = state.repoModal.currentPath;
const newPath = current === '/' ? '/' + child : current + '/' + child;
state.repoModal.currentPath = newPath;
state.repoModal.loading = true;
state.repoModal.children = [];
state.repoModal.filter = '';
document.getElementById('repo-modal-name').value = '';
document.getElementById('repo-modal-filter').value = '';
renderRepoModal();
send({ type: 'IntrospectPath', path: newPath });
}
function repoModalUp() {
const current = state.repoModal.currentPath;
if (current === '/') return;
const pos = current.lastIndexOf('/');
const newPath = pos === 0 ? '/' : current.substring(0, pos);
state.repoModal.currentPath = newPath;
state.repoModal.loading = true;
state.repoModal.children = [];
state.repoModal.filter = '';
document.getElementById('repo-modal-name').value = '';
document.getElementById('repo-modal-filter').value = '';
renderRepoModal();
send({ type: 'IntrospectPath', path: newPath });
}
function confirmRepoModal() {
const name = document.getElementById('repo-modal-name').value.trim();
const path = state.repoModal.currentPath;
if (!name || !path) return;
send({ type: 'RepoAdd', name, path });
closeRepoModal();
}
document.getElementById('btn-add-repo').addEventListener('click', openRepoModal);
document.getElementById('repo-modal-cancel').addEventListener('click', closeRepoModal);
document.getElementById('repo-modal-confirm').addEventListener('click', confirmRepoModal);
document.getElementById('repo-modal-up').addEventListener('click', repoModalUp);
document.getElementById('repo-modal-filter').addEventListener('input', (e) => {
state.repoModal.filter = e.target.value;
renderRepoModal();
});
function updateURL(view, id) {
if (view === 'dashboard') {
history.pushState({ view }, '', '/');
} else if (view === 'terminal') {
history.pushState({ view, id }, '', '/shell/' + id.substring(0, 8));
} else if (view === 'conversation') {
history.pushState({ view, id }, '', '/agent/' + id.substring(0, 8));
}
}
window.addEventListener('popstate', (event) => {
const s = event.state;
if (!s || s.view === 'dashboard') {
if (state.currentView === 'terminal') {
if (state.activeShellId) {
send({ type: 'ShellDetach', id: state.activeShellId });
}
if (state.term) {
state.term.dispose();
state.term = null;
}
if (state.resizeListener) {
window.removeEventListener('resize', state.resizeListener);
state.resizeListener = null;
}
state.activeShellId = null;
}
state.activeAgentId = null;
showView('dashboard');
renderSidebar();
renderDashboard();
} else if (s.view === 'terminal' && s.id) {
attachShellNoURLPush(s.id);
} else if (s.view === 'conversation' && s.id) {
viewAgentNoURLPush(s.id);
}
});
function attachShellNoURLPush(id) {
state.activeShellId = id;
showView('terminal');
const termEl = document.getElementById('terminal');
termEl.innerHTML = '';
document.getElementById('terminal-title').textContent = 'Shell ' + id.substring(0, 8);
state.term = new Terminal({ theme: { background: '#1a1b26', foreground: '#a9b1d6' } });
state.fitAddon = new FitAddon.FitAddon();
state.term.loadAddon(state.fitAddon);
state.term.open(termEl);
state.fitAddon.fit();
const cols = state.term.cols;
const rows = state.term.rows;
send({ type: 'ShellAttach', id, cols, rows });
state.term.onData(data => {
const bytes = Array.from(new TextEncoder().encode(data));
send({ type: 'ShellInput', id, data: bytes });
});
state.term.onResize(({ cols, rows }) => {
send({ type: 'ShellResize', id, cols, rows });
});
if (state.resizeListener) {
window.removeEventListener('resize', state.resizeListener);
}
state.resizeListener = () => {
if (state.fitAddon) state.fitAddon.fit();
};
window.addEventListener('resize', state.resizeListener);
renderSidebar();
}
function viewAgentNoURLPush(id) {
state.activeAgentId = id;
showView('conversation');
document.getElementById('conversation-title').textContent = 'Agent ' + id.substring(0, 8);
document.getElementById('conversation-lines').innerHTML = '';
send({ type: 'AgentWatch', shell_id: id });
renderSidebar();
}
function resolveFullId(shortId) {
const shell = state.shells.find(s => s.id.startsWith(shortId));
if (shell) return { type: 'terminal', id: shell.id };
const agent = state.agents.find(a => a.vex_shell_id.startsWith(shortId));
if (agent) return { type: 'conversation', id: agent.vex_shell_id };
return null;
}
function routeFromURL() {
const path = window.location.pathname;
const shellMatch = path.match(/^\/shell\/([a-f0-9]+)$/);
const agentMatch = path.match(/^\/agent\/([a-f0-9]+)$/);
if (shellMatch) {
state._pendingRoute = { view: 'terminal', shortId: shellMatch[1] };
} else if (agentMatch) {
state._pendingRoute = { view: 'conversation', shortId: agentMatch[1] };
} else {
history.replaceState({ view: 'dashboard' }, '', '/');
}
}
function applyPendingRoute() {
if (!state._pendingRoute) return;
const { view, shortId } = state._pendingRoute;
const resolved = resolveFullId(shortId);
if (!resolved) {
history.replaceState({ view: 'dashboard' }, '', '/');
state._pendingRoute = null;
return;
}
state._pendingRoute = null;
if (view === 'terminal' && resolved.type === 'terminal') {
history.replaceState({ view: 'terminal', id: resolved.id }, '', '/shell/' + shortId);
attachShellNoURLPush(resolved.id);
} else if (view === 'conversation' && resolved.type === 'conversation') {
history.replaceState({ view: 'conversation', id: resolved.id }, '', '/agent/' + shortId);
viewAgentNoURLPush(resolved.id);
} else {
history.replaceState({ view: 'dashboard' }, '', '/');
}
}
routeFromURL();
connect();