Files
GitManager/components/handoff-bar/handoff-bar.js
T
TBNilles 2b77e15b36 Slice 4: graceful project handoff (pending switch + ack)
Add pending-switch coordination to internal/activity (RequestSwitch/PendingSwitch/AckSwitch/CancelSwitch); AckSwitch atomically sets the active project and records switch-completed with Claude's summary. New MCP tools get_pending_switch and ack_switch (request is user-only via HTTP). HTTP GET/POST/DELETE /api/switch. New <handoff-bar> component (ask/waiting/cancel/completed) over SSE; activity-feed reflects switch-completed. Test covers request->ack->clear. Verified live: request/waiting/cancel over SSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:27:22 -04:00

156 lines
5.0 KiB
JavaScript

// <handoff-bar> — 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 = `
<span class="spinner">⏳</span>
<span>Waiting for Claude to reach a good stopping point to switch to
<strong>${this.#esc(this.#base(this.#pending.target))}</strong>…</span>
<button id="cancel" class="ghost">Cancel</button>`;
this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel();
return;
}
const done = this.#completed
? `<span class="done">✅ Claude switched to
<strong>${this.#esc(this.#base(this.#completed.target))}</strong>${
this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : ''
}</span>`
: '';
if (!this.#active) {
body.innerHTML = `${done}<span class="hint">Select a repository to make it the active project, then hand it off to Claude.</span>`;
return;
}
body.innerHTML = `
${done}
<button id="ask" class="primary">Ask Claude to switch to ${this.#esc(this.#base(this.#active))}</button>`;
this.shadowRoot.getElementById('ask').onclick = () => this.#request();
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
#body {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
}
button { font: inherit; border-radius: var(--radius-sm); cursor: pointer;
padding: 5px 12px; border: 1px solid var(--border-strong);
background: var(--surface-2); color: var(--color-fg); }
button.primary { border-color: var(--fill-accent); color: var(--fill-accent); }
button.ghost { color: var(--color-fg-muted); }
button:hover { border-color: var(--fill-accent); }
.spinner { font-size: 15px; }
.done { color: var(--color-success); }
.hint { color: var(--color-fg-muted); }
strong { color: var(--fill-accent); }
#cancel { margin-left: auto; }
</style>
<div id="body"></div>`;
}
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('handoff-bar', HandoffBar);