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>
This commit is contained in:
@@ -50,7 +50,9 @@ class ActivityFeed extends HTMLElement {
|
||||
#onEvent(ev) {
|
||||
this.#events.push(ev);
|
||||
if (this.#events.length > 200) this.#events = this.#events.slice(-200);
|
||||
if (ev.kind === 'active-project-changed') this.#renderActive(ev.repo || '');
|
||||
if (ev.kind === 'active-project-changed' || ev.kind === 'switch-completed') {
|
||||
this.#renderActive(ev.repo || '');
|
||||
}
|
||||
this.#renderFeed();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ graceful project handoff (§8.3).
|
||||
|
||||
## History
|
||||
- 2026-09-20: created — slice 3; live active-project + activity view over SSE.
|
||||
- 2026-09-20: also update the active-project display on `switch-completed` events
|
||||
(slice 4 handoff), not only `active-project-changed`.
|
||||
|
||||
## Notes / gotchas
|
||||
- All server-derived text is escaped before insertion (repo paths, details,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// <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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('handoff-bar', HandoffBar);
|
||||
@@ -0,0 +1,28 @@
|
||||
# handoff-bar
|
||||
|
||||
## Intent
|
||||
The user-facing control for the **graceful project handoff** (AGENT.md §8.3). It
|
||||
lets the user ask Claude to switch to the active project, shows the "waiting for a
|
||||
good stopping point" state while the request is pending, and announces completion
|
||||
(with Claude's summary of where it left off) when Claude calls `ack_switch`. The
|
||||
switch is Claude-completed at a checkpoint, never app-forced (§1.4-class rule).
|
||||
|
||||
## Public surface
|
||||
- **Tag:** `<handoff-bar>`
|
||||
- **Attributes/properties:** none.
|
||||
- **Fetches (initial):** `GET /api/switch` (pending request), `GET /api/active-project`.
|
||||
- **Writes:** `POST /api/switch {target}` to request a handoff to the active
|
||||
project (actor=user); `DELETE /api/switch` to cancel.
|
||||
- **Subscribes:** `EventSource('/events')` — reacts to `switch-requested`,
|
||||
`switch-completed` (shows Claude's summary), `switch-cancelled`, and
|
||||
`active-project-changed`. Closed on disconnect.
|
||||
|
||||
## History
|
||||
- 2026-09-20: created — slice 4; request/pending/completed UI over the
|
||||
`/api/switch` endpoints and SSE.
|
||||
|
||||
## Notes / gotchas
|
||||
- All server-derived text is escaped before insertion (targets, summaries).
|
||||
- The request targets the current **active project**; select a repo first (that
|
||||
sets the active project via `<repo-list>`).
|
||||
- The completion message persists until the next request; it is informational.
|
||||
Reference in New Issue
Block a user