38e1a175b6
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
422 lines
18 KiB
JavaScript
422 lines
18 KiB
JavaScript
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 `<div class="band evidence" id="evidence" hidden></div>`;
|
|
const health = ev.available
|
|
? `<span class="health-ok">tracking</span>`
|
|
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
|
const now = ev.current && (ev.current.class || ev.current.title)
|
|
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
|
|
const rows = (ev.buckets || []).map(b =>
|
|
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
|
|
return `<div class="band evidence" id="evidence">
|
|
<div class="now">now ${now} ${health}</div>
|
|
<ul class="buckets">${rows}</ul>
|
|
</div>`;
|
|
}
|
|
|
|
// updateActiveEvidence repaints the live evidence band (current window, per-window
|
|
// buckets, switch count) on every active tick. The band carries no event listeners,
|
|
// so swapping outerHTML wholesale is safe and keeps a single render source.
|
|
function updateActiveEvidence(state) {
|
|
const el = document.getElementById('evidence');
|
|
if (el) el.outerHTML = evidenceBlock(state.evidence);
|
|
}
|
|
|
|
// 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 =>
|
|
`<div class="summary-row"><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></div>`).join('');
|
|
const switches = `<div class="summary-row"><span>context switches</span><span>${ev.switch_count || 0}</span></div>`;
|
|
return `<div class="band summary">${switches}${rows}</div>`;
|
|
}
|
|
|
|
// 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 = `<div><span class="pill">Drift</span></div>
|
|
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
|
|
${drift.enforced ? '<div class="enforce-note">Off-task window minimized.</div>' : ''}
|
|
<div class="band-actions">
|
|
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
|
|
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
|
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
|
</div>`;
|
|
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 = `<div><span class="pill">Heads up</span></div>
|
|
<div class="nudge-msg">${nudge}</div>
|
|
<div class="band-actions">
|
|
<button id="dismissnudge" type="button" class="btn btn-ghost">Dismiss</button>
|
|
</div>`;
|
|
document.getElementById('dismissnudge').onclick = () => {
|
|
dismissedNudge = nudge;
|
|
setStateAttr(state);
|
|
updateActiveDrift(state);
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (drift.status === 'pending') {
|
|
el.className = 'band statusband';
|
|
el.innerHTML = `<span class="pill">Active</span><span class="status-meta">checking focus…</span>`;
|
|
return;
|
|
}
|
|
|
|
el.className = 'band statusband';
|
|
el.innerHTML = `<span class="pill">Active</span>
|
|
<span class="status-meta">on task · ${switches} switches</span>`;
|
|
}
|
|
|
|
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 = `<div class="meta">loading tasks…</div>`; return; }
|
|
const list = tasks.tasks || [];
|
|
if (!list.length) { el.innerHTML = ''; return; }
|
|
const chips = list.map((t, i) =>
|
|
`<button type="button" class="task-chip" data-i="${i}">${t.title}</button>`).join('');
|
|
el.innerHTML = `<label>Today</label><div class="tasklist">${chips}</div>`;
|
|
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 =
|
|
`<span class="knowline meta">${line}</span> <button type="button" class="link" id="knowChange">change</button>`;
|
|
document.getElementById('knowChange').onclick = openSettings;
|
|
}
|
|
|
|
// 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 `<div class="band"><div class="reflectline meta">reflecting…</div></div>`;
|
|
if (refl.status === 'ready' && refl.recap) {
|
|
return `<div class="band"><div class="reflectline">${refl.recap}</div></div>`;
|
|
}
|
|
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 = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
|
|
}
|
|
|
|
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);
|
|
updateActiveEvidence(state);
|
|
return;
|
|
}
|
|
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
|
renderedState = rs;
|
|
|
|
if (rs === 'locked') {
|
|
view.innerHTML = `<div class="band statusband"><span class="pill">Locked</span></div>
|
|
<div class="band">
|
|
<p class="meta">No active commitment.</p>
|
|
<button id="plan" class="btn btn-primary">Start planning</button>
|
|
</div>`;
|
|
document.getElementById('plan').onclick = () => post('/planning');
|
|
|
|
} else if (rs === 'planning') {
|
|
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
|
<div class="band">
|
|
<label>Rough intent</label>
|
|
<input id="intent" placeholder="e.g. work on the quarterly report">
|
|
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
|
<div id="coachStatus" class="meta"></div>
|
|
</div>
|
|
<div class="band" id="tasksBand"></div>
|
|
<div class="band" id="knowBand"></div>
|
|
<div class="band" id="reflectBand"></div>
|
|
<div class="band">
|
|
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
|
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
|
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
|
<label>Allowed apps (comma-separated window classes)</label>
|
|
<input id="apps" placeholder="e.g. code, firefox">
|
|
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
|
|
<p class="hint">Minimize off-task windows when you drift.</p>
|
|
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
|
</div>`;
|
|
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),
|
|
enforce: document.getElementById('enforce').checked,
|
|
});
|
|
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 = `<div class="band statusband" id="statusband"></div>
|
|
<div class="band"><div class="timer" id="t">--:--</div></div>
|
|
<div class="band">
|
|
<div class="action">${c.next_action || ''}</div>
|
|
<p class="meta">done when: ${c.success_condition || ''}</p>
|
|
</div>
|
|
${evidenceBlock(state.evidence)}
|
|
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
|
|
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 = `<div class="band statusband"><span class="pill">Review</span></div>
|
|
<div class="band">
|
|
<div class="action">Session ended</div>
|
|
<p class="meta">${c.next_action || ''}</p>
|
|
<p class="meta">done when: ${c.success_condition || ''}</p>
|
|
</div>
|
|
${reflectionBlock(state.reflection)}
|
|
${reviewSummary(state.evidence)}
|
|
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
|
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?'; };
|
|
|
|
// ---- Settings overlay ----
|
|
function openSettings() {
|
|
fetch('/settings').then(r => r.json()).then(s => {
|
|
const ov = document.getElementById('settingsOverlay');
|
|
ov.innerHTML = `
|
|
<div class="modal">
|
|
<h2>Settings</h2>
|
|
<label>AI backend</label>
|
|
<select id="setBackend">
|
|
<option value="claude">claude</option>
|
|
<option value="codex">codex</option>
|
|
</select>
|
|
<label>Marvin tasks command</label>
|
|
<input id="setMarvin" placeholder="uv run am">
|
|
<label>Knowledge profile path</label>
|
|
<div class="path-row">
|
|
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
|
|
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
|
</div>
|
|
<div id="browsePane" class="browse" hidden></div>
|
|
<div id="setError" class="set-error"></div>
|
|
<div class="modal-actions">
|
|
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="setSave">Save</button>
|
|
</div>
|
|
</div>`;
|
|
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
|
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
|
document.getElementById('setKnow').value = s.knowledge_path || '';
|
|
ov.hidden = false;
|
|
document.getElementById('setCancel').onclick = closeSettings;
|
|
document.getElementById('setSave').onclick = saveSettings;
|
|
document.getElementById('setBrowse').onclick = () =>
|
|
loadBrowse(parentDir(document.getElementById('setKnow').value));
|
|
}).catch(() => {
|
|
const ov = document.getElementById('settingsOverlay');
|
|
ov.innerHTML = '<div class="modal"><div class="set-error">Could not load settings.</div>' +
|
|
'<div class="modal-actions"><button type="button" class="btn btn-ghost" id="setClose">Close</button></div></div>';
|
|
ov.hidden = false;
|
|
document.getElementById('setClose').onclick = closeSettings;
|
|
});
|
|
}
|
|
|
|
function closeSettings() {
|
|
const ov = document.getElementById('settingsOverlay');
|
|
ov.hidden = true;
|
|
ov.innerHTML = '';
|
|
}
|
|
|
|
function saveSettings() {
|
|
const body = {
|
|
ai_backend: document.getElementById('setBackend').value,
|
|
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
|
knowledge_path: document.getElementById('setKnow').value.trim(),
|
|
};
|
|
post('/settings', body).then(r => {
|
|
if (r.ok) { closeSettings(); return; }
|
|
return r.json().then(e => {
|
|
document.getElementById('setError').textContent = e.error || 'save failed';
|
|
});
|
|
});
|
|
}
|
|
|
|
function parentDir(path) {
|
|
if (!path) return '';
|
|
const i = path.lastIndexOf('/');
|
|
if (i <= 0) return '/';
|
|
return path.slice(0, i);
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
}
|
|
|
|
function loadBrowse(dir) {
|
|
const pane = document.getElementById('browsePane');
|
|
pane.hidden = false;
|
|
fetch('/fs/browse?dir=' + encodeURIComponent(dir || '')).then(r => {
|
|
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'cannot read directory'); });
|
|
return r.json();
|
|
}).then(d => {
|
|
const items = [];
|
|
if (d.parent && d.parent !== d.dir) {
|
|
items.push(`<li><button type="button" data-dir="${esc(d.parent)}">../</button></li>`);
|
|
}
|
|
for (const e of d.entries) {
|
|
if (e.is_dir) {
|
|
items.push(`<li><button type="button" data-dir="${esc(e.path)}">${esc(e.name)}/</button></li>`);
|
|
} else {
|
|
items.push(`<li><button type="button" data-file="${esc(e.path)}">${esc(e.name)}</button></li>`);
|
|
}
|
|
}
|
|
pane.innerHTML = `<div class="browse-dir">${esc(d.dir)}</div><ul class="browse-list">${items.join('')}</ul>`;
|
|
pane.querySelectorAll('button[data-dir]').forEach(b =>
|
|
b.onclick = () => loadBrowse(b.getAttribute('data-dir')));
|
|
pane.querySelectorAll('button[data-file]').forEach(b =>
|
|
b.onclick = () => {
|
|
document.getElementById('setKnow').value = b.getAttribute('data-file');
|
|
pane.hidden = true;
|
|
pane.innerHTML = '';
|
|
});
|
|
}).catch(err => {
|
|
pane.innerHTML = `<div class="set-error">${err.message}</div>`;
|
|
});
|
|
}
|
|
|
|
document.getElementById('gear').onclick = openSettings;
|