// — the graceful project handoff control (AGENT.md §8.3). // // A self-contained control (§1.1): shadow DOM, self-fetching, live over SSE, // cleans up on disconnect. It lets the user ask Claude to switch to the active // project, shows the "waiting for a good stopping point" state while a request // is pending, and announces the completion (with Claude's summary of where it // left off) when Claude calls ack_switch. class HandoffBar extends HTMLElement { #es = null; #controller = null; #active = ''; #pending = null; #completed = null; constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.#renderShell(); this.#loadInitial(); this.#es = new EventSource('/events'); this.#es.addEventListener('activity', (e) => { try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore */ } }); } disconnectedCallback() { this.#es?.close(); this.#controller?.abort(); } async #loadInitial() { this.#controller?.abort(); this.#controller = new AbortController(); try { const [swRes, apRes] = await Promise.all([ fetch('/api/switch', { signal: this.#controller.signal }), fetch('/api/active-project', { signal: this.#controller.signal }), ]); const sw = await swRes.json(); const ap = await apRes.json(); this.#pending = sw.pending ? sw : null; this.#active = ap.path || ''; this.#render(); } catch (err) { if (err.name !== 'AbortError') { /* keep default UI */ } } } #onEvent(ev) { switch (ev.kind) { case 'switch-requested': this.#pending = { target: ev.repo, note: ev.detail }; this.#completed = null; break; case 'switch-completed': this.#pending = null; this.#active = ev.repo || this.#active; this.#completed = { target: ev.repo, summary: ev.detail }; break; case 'switch-cancelled': this.#pending = null; break; case 'active-project-changed': this.#active = ev.repo || ''; break; default: return; } this.#render(); } async #request() { if (!this.#active) return; await fetch('/api/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: this.#active }), }).catch(() => {}); // The SSE switch-requested event updates the UI. } async #cancel() { await fetch('/api/switch', { method: 'DELETE' }).catch(() => {}); } #render() { const body = this.shadowRoot.getElementById('body'); if (this.#pending) { body.innerHTML = ` Waiting for Claude to reach a good stopping point to switch to ${this.#esc(this.#base(this.#pending.target))} `; this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel(); return; } const done = this.#completed ? `✅ Claude switched to ${this.#esc(this.#base(this.#completed.target))}${ this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : '' }` : ''; if (!this.#active) { body.innerHTML = `${done}Select a repository to make it the active project, then hand it off to Claude.`; return; } body.innerHTML = ` ${done} `; this.shadowRoot.getElementById('ask').onclick = () => this.#request(); } #renderShell() { this.shadowRoot.innerHTML = `
`; } #base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; } #esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } } customElements.define('handoff-bar', HandoffBar);