const app = document.getElementById('app'); const view = document.getElementById('view'); let countdownTimer = null; let renderedState = null; // which runtime_state the DOM currently shows let dismissedNudge = null; // text of the nudge the user has dismissed function post(path, body) { return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); } function fmt(secs) { secs = Math.max(0, Math.floor(secs)); const m = String(Math.floor(secs / 60)).padStart(2, '0'); const s = String(secs % 60).padStart(2, '0'); return `${m}:${s}`; } // stateKey maps server state to the data-state accent bucket. Drift outranks // a nudge, and a nudge the user already dismissed reverts to plain active. function stateKey(state) { const rs = state.runtime_state; if (rs !== 'active') return rs || 'locked'; const d = state.drift || {}; if (d.status === 'drifting') return 'drift'; if (d.nudge && d.nudge !== dismissedNudge) return 'nudge'; return 'active'; } function setStateAttr(state) { app.dataset.state = stateKey(state); } function evidenceBlock(ev) { if (!ev) return ''; const health = ev.available ? `tracking` : `evidence unavailable: ${ev.reason || 'unknown'}`; const now = ev.current && (ev.current.class || ev.current.title) ? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—'; const rows = (ev.buckets || []).map(b => `
  • ${(b.class || '?')} · ${b.title || ''}${fmt(b.seconds)}
  • `).join(''); return `
    now ${now}   ${health}
    context switches: ${ev.switch_count || 0}
    `; } // reviewSummary renders a presentational recap from already-available state: // the commitment plus the per-window evidence buckets. No new backend data. function reviewSummary(ev) { if (!ev) return ''; const rows = (ev.buckets || []).map(b => `
    ${(b.class || '?')} · ${b.title || ''}${fmt(b.seconds)}
    `).join(''); const switches = `
    context switches${ev.switch_count || 0}
    `; return `
    ${switches}${rows}
    `; } // updateActiveDrift owns the active status band: it renders on-task, nudge, // drift, or pending content into #statusband and rebinds the buttons. function updateActiveDrift(state) { const el = document.getElementById('statusband'); if (!el) return; setStateAttr(state); const drift = state.drift || {}; const switches = (state.evidence && state.evidence.switch_count) || 0; if (drift.status === 'drifting') { el.className = 'band statusband col'; el.innerHTML = `
    Drift
    ${drift.reason || 'This looks off task.'}
    `; document.getElementById('refocus').onclick = () => post('/refocus'); document.getElementById('ontask').onclick = () => post('/ontask'); document.getElementById('enddrift').onclick = () => post('/complete'); return; } const nudge = drift.nudge || ''; if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges if (nudge && nudge !== dismissedNudge) { el.className = 'band statusband col'; el.innerHTML = `
    Heads up
    ${nudge}
    `; document.getElementById('dismissnudge').onclick = () => { dismissedNudge = nudge; setStateAttr(state); updateActiveDrift(state); }; return; } if (drift.status === 'pending') { el.className = 'band statusband'; el.innerHTML = `Activechecking focus…`; return; } el.className = 'band statusband'; el.innerHTML = `Active on task · ${switches} switches`; } function updatePlanningCoach(coach) { const statusEl = document.getElementById('coachStatus'); if (!statusEl) return; if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; } if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; } if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; } if (coach.status === 'ready' && coach.proposal) { statusEl.textContent = 'AI suggestion applied — edit freely.'; const stamp = JSON.stringify(coach.proposal); if (statusEl.dataset.applied === stamp) return; statusEl.dataset.applied = stamp; const na = document.getElementById('na'), sc = document.getElementById('sc'), mins = document.getElementById('mins'), start = document.getElementById('start'); if (na && !na.value.trim()) na.value = coach.proposal.next_action || ''; if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || ''; if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60); if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); const apps = document.getElementById('apps'); if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) { apps.value = coach.proposal.allowed_window_classes.join(', '); } } } // updatePlanningTasks renders today's Marvin tasks as clickable seed chips. // Clicking a chip fills the intent field; the coach pipeline is unchanged. // idle/error/empty render nothing; pending shows a quiet loading line. function updatePlanningTasks(tasks) { const el = document.getElementById('tasksBand'); if (!el) return; if (!tasks || tasks.status === 'idle' || tasks.status === 'error') { el.innerHTML = ''; return; } if (tasks.status === 'pending') { el.innerHTML = `
    loading tasks…
    `; return; } const list = tasks.tasks || []; if (!list.length) { el.innerHTML = ''; return; } const chips = list.map((t, i) => ``).join(''); el.innerHTML = `
    ${chips}
    `; el.querySelectorAll('.task-chip').forEach(btn => { btn.onclick = () => { const intent = document.getElementById('intent'); if (intent) { intent.value = list[+btn.dataset.i].title; intent.focus(); } }; }); } // updatePlanningKnowledge renders the standing-profile grounding indicator and // a small "change" affordance to repoint the file. idle/nil renders nothing; // pending/ready/absent/error each show one quiet line. The profile text never // reaches the browser — only status, path, and size. function updatePlanningKnowledge(k) { const el = document.getElementById('knowBand'); if (!el) return; if (!k || k.status === 'idle') { el.innerHTML = ''; return; } const base = (k.path || '').split('/').pop() || k.path || ''; let line; if (k.status === 'pending') line = 'loading profile…'; else if (k.status === 'ready') line = `grounded by ${base} · ${k.chars} chars`; else if (k.status === 'absent') line = `no profile (${k.path || 'unset'})`; else line = 'profile unreadable'; el.innerHTML = `${line} `; document.getElementById('knowChange').onclick = () => { const next = prompt('Profile file path (blank = default):', k.path || ''); if (next !== null) post('/knowledge/path', { path: next.trim() }); }; } // reflectionBlock renders the reviewer's recap on the Review screen. idle/nil or // an empty recap renders nothing; pending shows a quiet line; ready shows the // one-line recap. function reflectionBlock(refl) { if (!refl) return ''; if (refl.status === 'pending') return `
    reflecting…
    `; if (refl.status === 'ready' && refl.recap) { return `
    ${refl.recap}
    `; } return ''; } // updatePlanningReflection renders last session's carry-forward takeaway as a // quiet one-liner on the planning screen. Nothing renders without one. function updatePlanningReflection(refl) { const el = document.getElementById('reflectBand'); if (!el) return; if (!refl || !refl.carry_forward) { el.innerHTML = ''; return; } el.innerHTML = `Last time: ${refl.carry_forward}`; } function render(state) { setStateAttr(state); const rs = state.runtime_state; if (rs === 'planning' && renderedState === 'planning') { updatePlanningCoach(state.coach); updatePlanningTasks(state.tasks); updatePlanningKnowledge(state.knowledge); updatePlanningReflection(state.reflection); return; } if (rs === 'active' && renderedState === 'active') { updateActiveDrift(state); return; } if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } renderedState = rs; if (rs === 'locked') { view.innerHTML = `
    Locked

    No active commitment.

    `; document.getElementById('plan').onclick = () => post('/planning'); } else if (rs === 'planning') { view.innerHTML = `
    Planning
    `; const na = document.getElementById('na'), sc = document.getElementById('sc'), mins = document.getElementById('mins'), start = document.getElementById('start'); const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); }; [na, sc, mins].forEach(el => el.oninput = check); start.onclick = () => post('/commitment', { next_action: na.value.trim(), success_condition: sc.value.trim(), timebox_secs: Math.round(+mins.value * 60), allowed_window_classes: (document.getElementById('apps').value || '') .split(',').map(s => s.trim()).filter(Boolean), }); document.getElementById('sharpen').onclick = () => { const intent = document.getElementById('intent').value.trim(); if (intent) post('/coach', { intent }); }; updatePlanningCoach(state.coach); updatePlanningTasks(state.tasks); updatePlanningKnowledge(state.knowledge); updatePlanningReflection(state.reflection); } else if (rs === 'active') { const c = state.commitment || {}; view.innerHTML = `
    --:--
    ${c.next_action || ''}

    done when: ${c.success_condition || ''}

    ${evidenceBlock(state.evidence)}
    `; document.getElementById('done').onclick = () => post('/complete'); const t = document.getElementById('t'); const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); }; tick(); countdownTimer = setInterval(tick, 250); updateActiveDrift(state); } else if (rs === 'review') { const c = state.commitment || {}; view.innerHTML = `
    Review
    Session ended

    ${c.next_action || ''}

    done when: ${c.success_condition || ''}

    ${reflectionBlock(state.reflection)} ${reviewSummary(state.evidence)}
    `; document.getElementById('end').onclick = () => post('/end'); } else { view.textContent = rs; } } const es = new EventSource('/events'); es.onmessage = (e) => render(JSON.parse(e.data)); es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };