2b77e15b36
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>
142 lines
4.9 KiB
JavaScript
142 lines
4.9 KiB
JavaScript
// <activity-feed> — shows the active project and a live feed of what happened
|
|
// (user AND Claude actions). A self-contained control (AGENT.md §1.1): shadow
|
|
// DOM, fetches its own initial data, subscribes to the /events SSE stream, and
|
|
// cleans up on disconnect. It reflects the coordination state that Claude reads
|
|
// over MCP (§8.2), so the user can see the two staying in sync.
|
|
|
|
class ActivityFeed extends HTMLElement {
|
|
#es = null;
|
|
#controller = null;
|
|
#events = [];
|
|
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#renderShell();
|
|
this.#loadInitial();
|
|
// Live updates. EventSource auto-reconnects if the stream drops.
|
|
this.#es = new EventSource('/events');
|
|
this.#es.addEventListener('activity', (e) => {
|
|
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore malformed */ }
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.#es?.close();
|
|
this.#controller?.abort();
|
|
}
|
|
|
|
async #loadInitial() {
|
|
this.#controller?.abort();
|
|
this.#controller = new AbortController();
|
|
try {
|
|
const [apRes, actRes] = await Promise.all([
|
|
fetch('/api/active-project', { signal: this.#controller.signal }),
|
|
fetch('/api/activity', { signal: this.#controller.signal }),
|
|
]);
|
|
const ap = await apRes.json();
|
|
const events = await actRes.json();
|
|
this.#events = Array.isArray(events) ? events : [];
|
|
this.#renderActive(ap.path || '');
|
|
this.#renderFeed();
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') this.#renderError(err);
|
|
}
|
|
}
|
|
|
|
#onEvent(ev) {
|
|
this.#events.push(ev);
|
|
if (this.#events.length > 200) this.#events = this.#events.slice(-200);
|
|
if (ev.kind === 'active-project-changed' || ev.kind === 'switch-completed') {
|
|
this.#renderActive(ev.repo || '');
|
|
}
|
|
this.#renderFeed();
|
|
}
|
|
|
|
#renderActive(path) {
|
|
const el = this.shadowRoot.getElementById('active');
|
|
el.textContent = path ? this.#base(path) : 'none';
|
|
el.title = path;
|
|
}
|
|
|
|
#renderFeed() {
|
|
const ul = this.shadowRoot.getElementById('feed');
|
|
// Newest first.
|
|
ul.replaceChildren(...[...this.#events].reverse().map((ev) => {
|
|
const li = document.createElement('li');
|
|
const repo = ev.repo ? this.#base(ev.repo) : '';
|
|
li.innerHTML = `
|
|
<span class="actor ${this.#esc(ev.actor)}">${this.#esc(ev.actor)}</span>
|
|
<span class="kind">${this.#esc(this.#label(ev.kind))}</span>
|
|
${repo ? `<span class="repo">${this.#esc(repo)}</span>` : ''}
|
|
${ev.detail ? `<span class="detail">${this.#esc(ev.detail)}</span>` : ''}
|
|
<span class="time">${this.#time(ev.time)}</span>`;
|
|
return li;
|
|
}));
|
|
}
|
|
|
|
#renderError(err) {
|
|
this.shadowRoot.getElementById('feed').innerHTML =
|
|
`<li class="error">Could not load activity: ${this.#esc(err.message)}</li>`;
|
|
}
|
|
|
|
#renderShell() {
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host { display: block; }
|
|
.box {
|
|
background: var(--surface-1);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 12px 16px;
|
|
}
|
|
.active { margin-bottom: 8px; color: var(--color-fg-muted); }
|
|
.active strong { color: var(--fill-accent); }
|
|
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
|
color: var(--color-fg-muted); margin: 8px 0 6px; }
|
|
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px;
|
|
max-height: 220px; overflow-y: auto; }
|
|
li { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
|
|
.actor { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
|
|
border: 1px solid var(--border-strong); text-transform: uppercase; }
|
|
.actor.user { color: var(--git-ahead); border-color: var(--git-ahead); }
|
|
.actor.claude { color: var(--color-success); border-color: var(--color-success); }
|
|
.actor.system { color: var(--color-fg-muted); }
|
|
.repo { font-weight: 600; }
|
|
.detail { color: var(--color-fg-muted); }
|
|
.time { margin-left: auto; color: var(--color-fg-muted); font-size: 11px; white-space: nowrap; }
|
|
.error { color: var(--color-danger); }
|
|
.empty { color: var(--color-fg-muted); }
|
|
</style>
|
|
<div class="box">
|
|
<div class="active">Active project: <strong id="active">none</strong></div>
|
|
<h3>Activity</h3>
|
|
<ul id="feed"><li class="empty">No activity yet.</li></ul>
|
|
</div>`;
|
|
}
|
|
|
|
#base(p) {
|
|
return String(p).split(/[/\\]/).filter(Boolean).pop() || p;
|
|
}
|
|
|
|
#label(kind) {
|
|
return String(kind || '').replace(/-/g, ' ');
|
|
}
|
|
|
|
#time(t) {
|
|
const d = new Date(t);
|
|
return isNaN(d) ? '' : d.toLocaleTimeString();
|
|
}
|
|
|
|
#esc(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
));
|
|
}
|
|
}
|
|
|
|
customElements.define('activity-feed', ActivityFeed);
|