591 lines
21 KiB
JavaScript
591 lines
21 KiB
JavaScript
'use strict'
|
||
|
||
const $ = (id) => document.getElementById(id)
|
||
|
||
const state = {
|
||
data: null,
|
||
logSeeded: false,
|
||
lastSeq: 0,
|
||
modalIteration: null,
|
||
goals: [],
|
||
selectedFile: '',
|
||
forceLauncher: false,
|
||
prevStatus: null,
|
||
}
|
||
|
||
const ACTIVE_STATUSES = new Set(['starting', 'running', 'paused', 'waiting-answer'])
|
||
|
||
// Top-bar activity indicator: error/warn/problem states tint the bar, active
|
||
// states animate it, terminal states settle to a static line.
|
||
const ERROR_STATUSES = new Set(['error', 'done-with-errors'])
|
||
const WARN_STATUSES = new Set(['aborted', 'stopped-budget', 'stalled', 'stopped-guard'])
|
||
const TOPBAR_CLASSES = ['topbar--running', 'topbar--paused', 'topbar--waiting', 'topbar--done', 'topbar--warn', 'topbar--error']
|
||
|
||
function topbarState(s) {
|
||
if (s.idle === true) return ''
|
||
if (s.stale || ERROR_STATUSES.has(s.status)) return 'error'
|
||
if (WARN_STATUSES.has(s.status)) return 'warn'
|
||
if (s.status === 'starting' || s.status === 'running') return 'running'
|
||
if (s.status === 'waiting-answer') return 'waiting'
|
||
if (s.status === 'paused') return 'paused'
|
||
if (s.status === 'done') return 'done'
|
||
return ''
|
||
}
|
||
|
||
const fmt = (n) => Number(n || 0).toLocaleString('en-US')
|
||
const fmtCost = (n) => {
|
||
const v = Number(n || 0)
|
||
if (!v) return '$0.0000'
|
||
return v < 0.01 ? `$${v.toFixed(5)}` : `$${v.toFixed(4)}`
|
||
}
|
||
const fmtDur = (ms) => {
|
||
const s = Math.max(0, Math.round((ms || 0) / 1000))
|
||
const m = Math.floor(s / 60)
|
||
const h = Math.floor(m / 60)
|
||
if (h) return `${h}h ${String(m % 60).padStart(2, '0')}m`
|
||
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||
}
|
||
const esc = (s) =>
|
||
String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
||
|
||
// ---------- stats ----------
|
||
function renderStats(s) {
|
||
const totals = s.totals || { tokens: {}, cost: 0, durationMs: 0 }
|
||
const done = s.iterations.length
|
||
const last = s.iterations[s.iterations.length - 1]
|
||
const percent = last?.percent ?? 0
|
||
const context = last?.contextSize ?? 0
|
||
const verify = s.verify || { passed: 0, failed: 0 }
|
||
const statuses = s.iterations.map((i) => i.status)
|
||
const statusText = s.stale ? `${s.status} · stale` : s.status
|
||
const cards = [
|
||
{
|
||
k: 'Progress',
|
||
v: `${percent}<small>%</small>`,
|
||
bar: percent,
|
||
},
|
||
{ k: 'Iteration', v: `${done}<small> / ${s.configuredIterations ?? s.iterations.length}</small>` },
|
||
{ k: 'Status', v: `<span style="font-size:14px">${esc(statusText)}</span>` },
|
||
{ k: 'Tokens', v: `${fmt(totals.tokens?.total)}<small> in ${fmt(totals.tokens?.input)} · out ${fmt(totals.tokens?.output)}</small>` },
|
||
{ k: 'Cost', v: fmtCost(totals.cost) },
|
||
{
|
||
k: 'Context',
|
||
v: `${fmt(context)}<small> tokens</small>`,
|
||
},
|
||
{ k: 'Verify', v: `${verify.passed || 0}<small> pass · ${verify.failed || 0} fail</small>` },
|
||
]
|
||
$('stats').innerHTML = cards
|
||
.map(
|
||
(c) => `<div class="stat"><div class="k">${c.k}</div><div class="v">${c.v}</div>${
|
||
c.bar !== undefined ? `<div class="bar"><i style="width:${Math.max(0, Math.min(100, c.bar))}%"></i></div>` : ''
|
||
}</div>`,
|
||
)
|
||
.join('')
|
||
void statuses
|
||
}
|
||
|
||
// ---------- stages (kanban strip) ----------
|
||
function renderStages(s) {
|
||
const box = $('stages')
|
||
const stages = s.stages || []
|
||
if (!stages.length) {
|
||
box.innerHTML = '<div class="empty">No stages in the goal.</div>'
|
||
return
|
||
}
|
||
const last = s.iterations[s.iterations.length - 1]
|
||
const currentId = last?.stageId || null
|
||
box.innerHTML = stages
|
||
.map((st, i) => {
|
||
const isCurrent = st.id === currentId
|
||
const cls = st.done ? 'done' : isCurrent ? 'current' : 'pending'
|
||
const verify = st.verify ? `<span class="stage-verify" title="verify: ${esc(st.verify)}">verify</span>` : ''
|
||
return `<div class="stage-chip ${cls}" title="${esc(st.details || st.title)}">
|
||
<span class="n">${i + 1}</span><span class="t">${esc(st.title)}</span>${verify}
|
||
</div>`
|
||
})
|
||
.join('')
|
||
const v = s.verify || {}
|
||
const hint = $('stages-hint')
|
||
if (hint) hint.textContent = `verification gate · ${v.passed || 0} passed · ${v.failed || 0} failed`
|
||
}
|
||
|
||
// ---------- iterations ----------
|
||
function renderIterations(s) {
|
||
const box = $('iterations')
|
||
if (!s.iterations.length) {
|
||
box.innerHTML = '<div class="empty">No iterations yet.</div>'
|
||
return
|
||
}
|
||
box.innerHTML = s.iterations
|
||
.map((it) => {
|
||
const pct = it.percent ?? 0
|
||
const status = it.status || 'pending'
|
||
const questions = (it.questions || []).length
|
||
? `<ul class="qlist">${it.questions.map((q) => `<li>${esc(q.text)}</li>`).join('')}</ul>`
|
||
: ''
|
||
const files = (it.filesChanged || []).length
|
||
? `<div class="files">${it.filesChanged.slice(0, 8).map((f) => `<span class="chip">${esc(f)}</span>`).join('')}${
|
||
it.filesChanged.length > 8 ? `<span class="chip">+${it.filesChanged.length - 8}</span>` : ''
|
||
}</div>`
|
||
: ''
|
||
return `<div class="iter ${esc(status)}">
|
||
<div class="head">
|
||
<span class="num">#${it.index}</span>
|
||
<span class="topic">${esc(it.topic || 'running…')}</span>
|
||
<span class="badge mode">${s.sharedContext ? 'shared ctx' : 'fresh ctx'}</span>
|
||
<span class="badge ${esc(status)}">${esc(status)}</span>
|
||
</div>
|
||
<div class="meta">
|
||
<span>${fmt(it.tokens?.total)} tok</span>
|
||
<span>${fmtCost(it.cost)}</span>
|
||
<span>ctx ${fmt(it.contextSize)}</span>
|
||
<span>${fmtDur(it.durationMs)}</span>
|
||
${it.toolCount ? `<span>${it.toolCount} tools</span>` : ''}
|
||
${it.verify ? `<span class="vb ${it.verify.ok ? 'ok' : 'bad'}">verify ${it.verify.ok ? 'pass' : it.verify.timedOut ? 'timeout' : 'FAIL'}</span>` : ''}
|
||
${it.attempts > 1 ? `<span class="vb warn" title="${esc(it.retry?.reason || 'model connection error')}">auto-retry ×${it.attempts}</span>` : ''}
|
||
${it.guardViolation ? `<span class="vb bad">guard blocked</span>` : ''}
|
||
${it.checkpointId ? `<span title="checkpoint before this iteration">cp ${esc(it.checkpointId)}</span>` : ''}
|
||
${it.sessionID ? `<span class="sess" title="${esc(it.sessionID)}">session ${esc(String(it.sessionID).slice(0, 12))}…</span>` : ''}
|
||
</div>
|
||
<div class="stage"><span class="lbl">Stage:</span> ${esc(it.stageTitle || '—')} <span class="lbl">[${esc(it.stageStatus || 'n/a')}]</span></div>
|
||
<div class="stage"><span class="lbl">Next:</span> ${esc(it.nextStage || '—')}</div>
|
||
${it.summary ? `<div class="summary">${esc(it.summary)}</div>` : ''}
|
||
${files}
|
||
${questions}
|
||
${it.checkpointId ? `<div class="actions"><button class="revert" data-iter="${it.index}" title="Restore the working tree to the checkpoint taken before this iteration">Revert before #${it.index}</button></div>` : ''}
|
||
<div class="bar"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i></div>
|
||
</div>`
|
||
})
|
||
.join('')
|
||
void box
|
||
}
|
||
|
||
// ---------- log ----------
|
||
function logNode(entry) {
|
||
const div = document.createElement('div')
|
||
div.className = `line ${entry.level || 'info'}`
|
||
const time = new Date(entry.t).toLocaleTimeString('en-US', { hour12: false })
|
||
const prefix = entry.level === 'section' ? '' : `<span class="t">${time}</span>`
|
||
div.innerHTML = prefix + esc(entry.text)
|
||
return div
|
||
}
|
||
|
||
function appendLog(entry) {
|
||
if (!entry || entry.seq <= state.lastSeq) return
|
||
state.lastSeq = entry.seq
|
||
const log = $('log')
|
||
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60
|
||
log.appendChild(logNode(entry))
|
||
while (log.childElementCount > 2000) log.removeChild(log.firstChild)
|
||
if (nearBottom || entry.level === 'section') log.scrollTop = log.scrollHeight
|
||
}
|
||
|
||
function seedLog(entries) {
|
||
const log = $('log')
|
||
log.innerHTML = ''
|
||
state.lastSeq = 0
|
||
for (const e of entries || []) {
|
||
state.lastSeq = Math.max(state.lastSeq, e.seq || 0)
|
||
log.appendChild(logNode(e))
|
||
}
|
||
log.scrollTop = log.scrollHeight
|
||
}
|
||
|
||
// ---------- question modal ----------
|
||
function renderModal(s) {
|
||
const modal = $('modal')
|
||
const pq = s.pendingQuestion
|
||
if (!pq) {
|
||
modal.classList.add('hidden')
|
||
state.modalIteration = null
|
||
return
|
||
}
|
||
if (state.modalIteration === pq.iteration) return
|
||
state.modalIteration = pq.iteration
|
||
|
||
const questions = pq.questions.length ? pq.questions : [{ id: 'q1', text: 'Any corrections before the next iteration?', options: [] }]
|
||
$('modal-sub').textContent = `Iteration ${pq.iteration} finished. Answers are injected into the next session.`
|
||
$('modal-questions').innerHTML = questions
|
||
.map(
|
||
(q, i) => `<div class="q" data-i="${i}">
|
||
<label>${esc(q.text)}</label>
|
||
${
|
||
q.options?.length
|
||
? `<div class="opts">${q.options
|
||
.map((o) => `<button type="button" class="opt" data-val="${esc(o)}">${esc(o)}</button>`)
|
||
.join('')}</div>`
|
||
: ''
|
||
}
|
||
<input type="text" placeholder="Type your answer (Enter to submit)" />
|
||
</div>`,
|
||
)
|
||
.join('')
|
||
modal.classList.remove('hidden')
|
||
|
||
const first = $('modal-questions').querySelector('input')
|
||
if (first) setTimeout(() => first.focus(), 30)
|
||
|
||
$('modal-questions')
|
||
.querySelectorAll('.opt')
|
||
.forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const input = btn.closest('.q').querySelector('input')
|
||
input.value = btn.dataset.val
|
||
input.focus()
|
||
})
|
||
})
|
||
}
|
||
|
||
async function submitModal(skip) {
|
||
const questions = [...$('modal-questions').querySelectorAll('.q')]
|
||
const answers = questions.map((q) => ({
|
||
question: q.querySelector('label')?.textContent || '',
|
||
answer: skip ? '' : q.querySelector('input')?.value?.trim() || '',
|
||
}))
|
||
await fetch('/api/answer', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ answers }),
|
||
})
|
||
$('modal').classList.add('hidden')
|
||
state.modalIteration = null
|
||
}
|
||
|
||
// ---------- launcher (plan picker) ----------
|
||
async function loadGoals() {
|
||
try {
|
||
const res = await fetch('/api/goals')
|
||
state.goals = await res.json()
|
||
} catch {
|
||
state.goals = []
|
||
}
|
||
const sel = $('goal-select')
|
||
if (!state.goals.length) {
|
||
sel.innerHTML = '<option value="">No plans found</option>'
|
||
selectGoal('')
|
||
return
|
||
}
|
||
sel.innerHTML = state.goals
|
||
.map((g) => `<option value="${esc(g.file)}">${esc(g.title)} — ${g.done}/${g.total} (${g.percent}%)</option>`)
|
||
.join('')
|
||
const keep = state.goals.some((g) => g.file === state.selectedFile) ? state.selectedFile : state.goals[0].file
|
||
sel.value = keep
|
||
selectGoal(keep)
|
||
}
|
||
|
||
function selectGoal(file) {
|
||
state.selectedFile = file
|
||
const g = state.goals.find((x) => x.file === file)
|
||
const meta = $('goal-meta')
|
||
const list = $('goal-stages')
|
||
for (const id of ['btn-mark-all', 'btn-clear-all', 'btn-delete-goal']) {
|
||
const btn = $(id)
|
||
if (btn) btn.disabled = !g
|
||
}
|
||
if (!g) {
|
||
meta.textContent = ''
|
||
list.innerHTML = ''
|
||
return
|
||
}
|
||
const last = g.lastRun
|
||
? `last run ${g.lastRun.status} · ${g.lastRun.iterations} it · $${(g.lastRun.cost || 0).toFixed(4)}`
|
||
: 'never run'
|
||
meta.textContent = `${g.file} — ${g.done}/${g.total} done (${g.percent}%) · ${g.type} · ${last}`
|
||
$('global-verify').value = g.globalVerify || ''
|
||
list.innerHTML =
|
||
g.stages
|
||
.map(
|
||
(st, i) => `<div class="goal-stage ${st.done ? 'done' : ''}">
|
||
<span class="st-state">${st.done ? 'done' : 'todo'}</span>
|
||
<span class="st-title" title="${esc(st.title)}">${i + 1}. ${esc(st.title)}</span>
|
||
<input type="text" data-stage="${i}" placeholder="verify command" value="${esc(st.verify || '')}" />
|
||
</div>`,
|
||
)
|
||
.join('') || '<div class="empty">This plan has no stages.</div>'
|
||
}
|
||
|
||
async function saveStageVerify(stageIndex, verify) {
|
||
if (!state.selectedFile) return
|
||
try {
|
||
const res = await fetch('/api/goals/verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ file: state.selectedFile, stageIndex, verify }),
|
||
})
|
||
const data = await res.json()
|
||
if (!data.ok) {
|
||
$('launcher-error').textContent = data.error || 'save failed'
|
||
return
|
||
}
|
||
const i = state.goals.findIndex((x) => x.file === state.selectedFile)
|
||
if (i >= 0) state.goals[i] = data.goal
|
||
selectGoal(state.selectedFile)
|
||
} catch (err) {
|
||
$('launcher-error').textContent = err.message
|
||
}
|
||
}
|
||
|
||
async function saveGlobalVerify() {
|
||
try {
|
||
const res = await fetch('/api/goals/verify-global', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ verify: $('global-verify').value }),
|
||
})
|
||
const data = await res.json()
|
||
if (!data.ok) {
|
||
$('launcher-error').textContent = data.error || 'save failed'
|
||
return
|
||
}
|
||
await loadGoals()
|
||
} catch (err) {
|
||
$('launcher-error').textContent = err.message
|
||
}
|
||
}
|
||
|
||
// POST a plan action scoped to the currently selected file and surface errors.
|
||
async function postGoalAction(path, body, label) {
|
||
if (!state.selectedFile) return null
|
||
try {
|
||
const res = await fetch(path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ file: state.selectedFile, ...body }),
|
||
})
|
||
const data = await res.json().catch(() => ({}))
|
||
if (!res.ok || data.ok === false) {
|
||
$('launcher-error').textContent = data.error || `${label} failed (${res.status})`
|
||
return null
|
||
}
|
||
return data
|
||
} catch (err) {
|
||
$('launcher-error').textContent = err.message
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function setGoalDone(done) {
|
||
$('launcher-error').textContent = ''
|
||
const data = await postGoalAction('/api/goals/done', { done }, done ? 'mark' : 'clear')
|
||
if (data) await loadGoals()
|
||
}
|
||
|
||
async function deleteGoal() {
|
||
const g = state.goals.find((x) => x.file === state.selectedFile)
|
||
const label = g?.title || state.selectedFile
|
||
const ok = confirm(
|
||
`Delete the plan "${label}"?\n\nThe file ${state.selectedFile} is moved to .kilocode-loop/trash/ so it can be restored. This is not a commit.`,
|
||
)
|
||
if (!ok) return
|
||
$('launcher-error').textContent = ''
|
||
const data = await postGoalAction('/api/goals/delete', {}, 'delete')
|
||
if (data) {
|
||
state.selectedFile = ''
|
||
await loadGoals()
|
||
}
|
||
}
|
||
|
||
async function startRun() {
|
||
if (!state.selectedFile) {
|
||
$('launcher-error').textContent = 'Pick a plan first.'
|
||
return
|
||
}
|
||
$('launcher-error').textContent = ''
|
||
const body = {
|
||
file: state.selectedFile,
|
||
iterations: Number($('opt-iterations').value) || 5,
|
||
agent: $('opt-agent').value || undefined,
|
||
hitl: $('opt-hitl').value,
|
||
dryRun: $('opt-dryrun').checked,
|
||
promptExtra: $('opt-prompt-extra').value.trim() || undefined,
|
||
}
|
||
try {
|
||
const res = await fetch('/api/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
const data = await res.json().catch(() => ({}))
|
||
if (!res.ok || !data.ok) {
|
||
$('launcher-error').textContent = data.error || `start failed (${res.status})`
|
||
return
|
||
}
|
||
state.forceLauncher = false
|
||
} catch (err) {
|
||
$('launcher-error').textContent = err.message
|
||
}
|
||
}
|
||
|
||
$('goal-select').addEventListener('change', (e) => selectGoal(e.target.value))
|
||
$('goal-stages').addEventListener('change', (e) => {
|
||
const input = e.target.closest('input[data-stage]')
|
||
if (input) saveStageVerify(Number(input.dataset.stage), input.value)
|
||
})
|
||
$('btn-save-global-verify').addEventListener('click', saveGlobalVerify)
|
||
$('btn-goals-refresh').addEventListener('click', loadGoals)
|
||
$('btn-mark-all').addEventListener('click', () => setGoalDone(true))
|
||
$('btn-clear-all').addEventListener('click', () => setGoalDone(false))
|
||
$('btn-delete-goal').addEventListener('click', deleteGoal)
|
||
$('btn-start').addEventListener('click', startRun)
|
||
$('btn-new-run').addEventListener('click', () => {
|
||
state.forceLauncher = true
|
||
loadGoals()
|
||
render(state.data || { status: 'idle', idle: true })
|
||
})
|
||
|
||
// ---------- controls ----------
|
||
async function control(action, extra) {
|
||
try {
|
||
await fetch('/api/control', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action, ...(extra || {}) }),
|
||
})
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
// ---------- state ----------
|
||
function render(s) {
|
||
state.data = s
|
||
|
||
// Top-bar activity indicator (CSS animation drives it; JS only sets state).
|
||
const bar = $('topbar')
|
||
if (bar) {
|
||
bar.classList.remove(...TOPBAR_CLASSES)
|
||
const barState = topbarState(s)
|
||
if (barState) bar.classList.add(`topbar--${barState}`)
|
||
}
|
||
|
||
const idle = s.idle === true
|
||
const stopped = ['done', 'done-with-errors', 'aborted', 'error', 'stopped-budget', 'stalled', 'stopped-guard'].includes(s.status)
|
||
const showLauncher = idle || state.forceLauncher
|
||
|
||
$('launcher').classList.toggle('hidden', !showLauncher)
|
||
$('run-view').classList.toggle('hidden', showLauncher)
|
||
$('toggle-shared').classList.toggle('hidden', idle)
|
||
$('btn-new-run').classList.toggle('hidden', idle || !stopped)
|
||
|
||
// When a run finishes, refresh the plan list so the launcher shows new state.
|
||
if (state.prevStatus && ACTIVE_STATUSES.has(state.prevStatus) && !ACTIVE_STATUSES.has(s.status)) loadGoals()
|
||
state.prevStatus = s.status
|
||
|
||
if (idle) {
|
||
$('run-meta').textContent = 'no active run — pick a plan'
|
||
const pillIdle = $('status-pill')
|
||
pillIdle.textContent = 'idle'
|
||
pillIdle.className = 'status'
|
||
return
|
||
}
|
||
|
||
const pauseBtn = $('btn-pause')
|
||
const paused = s.status === 'paused' || s.pauseRequested
|
||
pauseBtn.textContent = paused ? 'Resume' : 'Pause'
|
||
pauseBtn.disabled = stopped
|
||
$('btn-abort').textContent = s.abortRequested ? 'Abort armed ✓' : 'Abort after session'
|
||
$('btn-abort').classList.toggle('primary', Boolean(s.abortRequested))
|
||
|
||
const pill = $('status-pill')
|
||
pill.textContent = s.status
|
||
pill.className = `status ${s.status}`
|
||
|
||
const goal = s.goal?.title || '—'
|
||
const mode = s.sharedContext ? 'shared context' : 'fresh sessions'
|
||
const custom = s.customInstruction ? ' · custom instruction' : ''
|
||
$('run-meta').textContent = `${s.runId} · ${goal} · agent ${s.agent} · ${mode}${custom}${s.dryRun ? ' · DRY-RUN' : ''}`
|
||
|
||
const sharedChk = $('chk-shared')
|
||
if (document.activeElement !== sharedChk) sharedChk.checked = Boolean(s.sharedContext)
|
||
sharedChk.disabled = stopped
|
||
$('toggle-shared').classList.toggle('on', Boolean(s.sharedContext))
|
||
|
||
const exportLink = $('btn-export')
|
||
if (exportLink) exportLink.href = s.runId ? `/api/runs/${encodeURIComponent(s.runId)}/export` : '#'
|
||
|
||
renderStats(s)
|
||
renderStages(s)
|
||
renderIterations(s)
|
||
renderModal(s)
|
||
}
|
||
|
||
function connect() {
|
||
const es = new EventSource('/api/events')
|
||
es.onmessage = (ev) => {
|
||
let msg
|
||
try {
|
||
msg = JSON.parse(ev.data)
|
||
} catch {
|
||
return
|
||
}
|
||
if (msg.type === 'state') {
|
||
if (!state.logSeeded) {
|
||
state.logSeeded = true
|
||
seedLog(msg.state.logs)
|
||
}
|
||
render(msg.state)
|
||
} else if (msg.type === 'log') {
|
||
appendLog(msg.entry)
|
||
}
|
||
}
|
||
es.onerror = () => {
|
||
// EventSource reconnects automatically; surface it in the status pill.
|
||
const pill = $('status-pill')
|
||
if (pill) pill.textContent = 'reconnecting…'
|
||
}
|
||
}
|
||
|
||
$('btn-pause').addEventListener('click', () => {
|
||
const paused = state.data?.status === 'paused' || state.data?.pauseRequested
|
||
control(paused ? 'resume' : 'pause')
|
||
})
|
||
$('btn-abort').addEventListener('click', () => control('abort-after-current'))
|
||
$('btn-stop').addEventListener('click', () => {
|
||
if (confirm('Stop the loop now and kill the current session?')) control('stop')
|
||
})
|
||
|
||
// Revert the working tree to the checkpoint taken before an iteration.
|
||
$('iterations').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('.revert')
|
||
if (!btn) return
|
||
const iteration = Number(btn.dataset.iter)
|
||
if (!confirm(`Revert the working tree to the checkpoint taken before iteration ${iteration}?\n\nThis discards tracked and untracked changes made since then. It is not a commit and cannot be undone from here.`)) return
|
||
try {
|
||
const res = await fetch('/api/revert', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ iteration }),
|
||
})
|
||
const data = await res.json()
|
||
if (!data.ok) alert(data.error || 'revert failed')
|
||
} catch (err) {
|
||
alert(`revert failed: ${err.message}`)
|
||
}
|
||
})
|
||
$('chk-shared').addEventListener('change', (e) => control('set-shared-context', { value: e.target.checked }))
|
||
$('btn-submit').addEventListener('click', () => submitModal(false))
|
||
$('btn-skip').addEventListener('click', () => submitModal(true))
|
||
|
||
// Enter submits inside the modal.
|
||
$('modal').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
submitModal(false)
|
||
}
|
||
})
|
||
|
||
// Terminal-style shortcuts mirror the CLI controls.
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!$('modal').classList.contains('hidden')) return
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||
const k = e.key.toLowerCase()
|
||
if (k === 'x') control('abort-after-current')
|
||
else if (k === 'p') {
|
||
const paused = state.data?.status === 'paused' || state.data?.pauseRequested
|
||
control(paused ? 'resume' : 'pause')
|
||
} else if (k === 'q' && e.shiftKey) control('stop')
|
||
})
|
||
|
||
loadGoals()
|
||
connect()
|