Slice 12: inline command-result toasts
New <toast-host> overlay: components post 'toast' CustomEvents ({message, kind: success|error|info}) and it shows brief, auto-dismissing, bottom-right toasts (errors linger). <repo-menu> (git + coordination actions) and <pr-list> (create/merge) now post friendly success/error toasts instead of alert(); the activity feed still logs everything. Also adds the slice 11 CHANGELOG entry. Verified live: 'Checked for updates' toast shown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,12 +59,17 @@ class PRList extends HTMLElement {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
this.#toast(`Merged & cleaned up PR #${pr.number}`, 'success');
|
||||
this.#load(this.#path); // refresh the list
|
||||
} catch (err) {
|
||||
this.#error(`Merge failed: ${err.message}`);
|
||||
this.#toast(`Merge failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
#toast(message, kind) {
|
||||
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
|
||||
}
|
||||
|
||||
async #create() {
|
||||
const branch = this.#repo?.branch;
|
||||
if (!branch) return;
|
||||
@@ -78,9 +83,10 @@ class PRList extends HTMLElement {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
this.#toast(`Opened PR #${data.number}`, 'success');
|
||||
this.#load(this.#path); // refresh so the new PR appears
|
||||
} catch (err) {
|
||||
window.alert(`Create PR failed: ${err.message}`);
|
||||
this.#toast(`Create PR failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,12 +135,15 @@ class RepoMenu extends HTMLElement {
|
||||
}
|
||||
case 'active':
|
||||
await this.#post('/api/active-project', { path: repo.path });
|
||||
this.#toast(`${name} is now the active project`, 'info');
|
||||
break;
|
||||
case 'handoff':
|
||||
await this.#post('/api/switch', { target: repo.path });
|
||||
this.#toast(`Asked Claude to switch to ${name}`, 'info');
|
||||
break;
|
||||
case 'copy':
|
||||
try { await navigator.clipboard.writeText(repo.path); } catch { /* ignore */ }
|
||||
try { await navigator.clipboard.writeText(repo.path); this.#toast('Path copied', 'info'); }
|
||||
catch { this.#toast('Could not copy path', 'error'); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +153,19 @@ class RepoMenu extends HTMLElement {
|
||||
await this.#git('checkout', { branch });
|
||||
}
|
||||
|
||||
#okLabel(op, extra) {
|
||||
switch (op) {
|
||||
case 'pull': return 'Got the latest';
|
||||
case 'push': return 'Published';
|
||||
case 'fetch': return 'Checked for updates';
|
||||
case 'commit': return 'Saved your work';
|
||||
case 'checkout': return `Switched to ${extra.branch}`;
|
||||
case 'create-branch': return `Created branch ${extra.branch}`;
|
||||
case 'discard': return 'Discarded changes';
|
||||
default: return 'Done';
|
||||
}
|
||||
}
|
||||
|
||||
async #git(op, extra = {}) {
|
||||
try {
|
||||
const res = await fetch('/api/repo/git', {
|
||||
@@ -159,11 +175,16 @@ class RepoMenu extends HTMLElement {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
this.#toast(this.#okLabel(op, extra), 'success');
|
||||
} catch (err) {
|
||||
window.alert(`${op} failed: ${err.message}`);
|
||||
this.#toast(`${this.#okLabel(op, extra)} failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
#toast(message, kind) {
|
||||
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
|
||||
}
|
||||
|
||||
async #post(url, body) {
|
||||
try {
|
||||
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// <toast-host> — a singleton overlay that shows brief command-result toasts.
|
||||
//
|
||||
// A self-contained control (AGENT.md §1.1): shadow DOM, no data of its own. Any
|
||||
// component posts a toast by dispatching a `toast` CustomEvent on document:
|
||||
// document.dispatchEvent(new CustomEvent('toast',
|
||||
// { detail: { message: 'Published', kind: 'success' } }));
|
||||
// kind ∈ success | error | info. Toasts stack bottom-right, auto-dismiss (errors
|
||||
// linger longer), and dismiss on click.
|
||||
|
||||
class ToastHost extends HTMLElement {
|
||||
#onToast = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#render();
|
||||
this.#onToast = (e) => this.#show(e.detail || {});
|
||||
document.addEventListener('toast', this.#onToast);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('toast', this.#onToast);
|
||||
}
|
||||
|
||||
#show({ message, kind = 'info', timeout }) {
|
||||
if (!message) return;
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast ' + (['success', 'error', 'info'].includes(kind) ? kind : 'info');
|
||||
t.textContent = String(message);
|
||||
t.addEventListener('click', () => t.remove());
|
||||
this.shadowRoot.getElementById('stack').appendChild(t);
|
||||
requestAnimationFrame(() => t.classList.add('in'));
|
||||
const ms = timeout || (kind === 'error' ? 6000 : 3500);
|
||||
setTimeout(() => {
|
||||
t.classList.remove('in');
|
||||
setTimeout(() => t.remove(), 200);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
#render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
#stack {
|
||||
position: fixed; right: 16px; bottom: 16px; z-index: 1100;
|
||||
display: flex; flex-direction: column-reverse; gap: 8px;
|
||||
max-width: min(360px, 90vw);
|
||||
}
|
||||
.toast {
|
||||
background: var(--surface-2); color: var(--color-fg);
|
||||
border: 1px solid var(--border-strong); border-left-width: 3px;
|
||||
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.45); cursor: pointer;
|
||||
opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s;
|
||||
}
|
||||
.toast.in { opacity: 1; transform: none; }
|
||||
.toast.success { border-left-color: var(--color-success); }
|
||||
.toast.error { border-left-color: var(--color-danger); }
|
||||
.toast.info { border-left-color: var(--fill-accent); }
|
||||
</style>
|
||||
<div id="stack"></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('toast-host', ToastHost);
|
||||
@@ -0,0 +1,22 @@
|
||||
# toast-host
|
||||
|
||||
## Intent
|
||||
A singleton overlay for brief command-result feedback (AGENT.md §6 polish). It
|
||||
gives immediate, legible confirmation of what a UI action did — "Published",
|
||||
"Merged & cleaned up PR #3", or an error — instead of only the activity feed or a
|
||||
browser `alert()`. Any component can post to it without a reference to it.
|
||||
|
||||
## Public surface
|
||||
- **Tag:** `<toast-host>` (place once, near the end of the page).
|
||||
- **Listens:** `toast` on `document` — `detail: { message, kind?, timeout? }`,
|
||||
where `kind` ∈ `success | error | info` (default `info`).
|
||||
- **Behavior:** toasts stack bottom-right, animate in, auto-dismiss (errors last
|
||||
longer — 6s vs 3.5s), and dismiss on click. Renders nothing until posted to.
|
||||
|
||||
## History
|
||||
- 2026-09-20: created — slice 12; inline toasts for menu/PR command results.
|
||||
|
||||
## Notes / gotchas
|
||||
- Posters build the `CustomEvent` themselves (components are standalone, no shared
|
||||
module); keep the detail shape in sync with this contract.
|
||||
- Messages are set via `textContent` (no HTML injection).
|
||||
Reference in New Issue
Block a user