|
|
@@ -8,8 +8,9 @@
|
|
|
// re-runs the sealed refresh action whenever task or saddle state
|
|
|
// changes, so progress, blocks and review requests stream in.
|
|
|
// The notification bell drives the Web Push subscribe flow (plan
|
|
|
- // 11 task E): permission -> service worker -> pushManager.subscribe
|
|
|
- // with the page's VAPID key -> the sealed push_subscribe action.
|
|
|
+ // 11 task E) — the shared engine lives in app.js
|
|
|
+ // (window.initPushBell, goal 41 task 3: the dashboard's compact
|
|
|
+ // rail strip and /profile's settings card run the same code).
|
|
|
if (window.statum) {
|
|
|
(function () {
|
|
|
'use strict';
|
|
|
@@ -20,26 +21,6 @@
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- function toBase64Url(buffer) {
|
|
|
- var bytes = new Uint8Array(buffer);
|
|
|
- var binary = '';
|
|
|
- for (var index = 0; index < bytes.length; index++) {
|
|
|
- binary += String.fromCharCode(bytes[index]);
|
|
|
- }
|
|
|
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
|
- }
|
|
|
-
|
|
|
- function vapidKeyBytes(base64Url) {
|
|
|
- var padding = '='.repeat((4 - base64Url.length % 4) % 4);
|
|
|
- var base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
|
- var raw = atob(base64);
|
|
|
- var bytes = new Uint8Array(raw.length);
|
|
|
- for (var index = 0; index < raw.length; index++) {
|
|
|
- bytes[index] = raw.charCodeAt(index);
|
|
|
- }
|
|
|
- return bytes;
|
|
|
- }
|
|
|
-
|
|
|
function dashboard() {
|
|
|
return statum.read('dashboard') || {};
|
|
|
}
|
|
|
@@ -59,419 +40,362 @@
|
|
|
statum.act(state.start_advisor, { message: text }).catch(function () { });
|
|
|
};
|
|
|
|
|
|
- function fail(error) {
|
|
|
- if (window.showToast) {
|
|
|
- showToast('Notifications: ' + (error && error.message ? error.message : error), 'error');
|
|
|
- }
|
|
|
- }
|
|
|
+ // ---- activity: "new since your last visit" + row cap ----
|
|
|
+ // Goal 41 task 3. The framework carries no cookies (auth
|
|
|
+ // is slot-based) and statum exposes no client slot WRITE,
|
|
|
+ // so a true server-side last-visit is not available: the
|
|
|
+ // stamp lives in localStorage, written when the dashboard
|
|
|
+ // tab UNLOADS — a visit updates the baseline, a background
|
|
|
+ // pulse does not (an open tab keeps its count growing
|
|
|
+ // until you come back). First visit: no stamp, no badge
|
|
|
+ // (everything would be "new"). The count covers the full
|
|
|
+ // window the slot carries; the visible rows cap at eight
|
|
|
+ // until "Show all".
|
|
|
+ var VISIT_KEY = 'clanker.lastVisit';
|
|
|
|
|
|
- function enablePush() {
|
|
|
- var state = dashboard();
|
|
|
- if (!state.push_vapid_key) {
|
|
|
- fail('not available on this server');
|
|
|
- return;
|
|
|
+ function readLastVisit() {
|
|
|
+ try {
|
|
|
+ var raw = window.localStorage.getItem(VISIT_KEY);
|
|
|
+ if (!raw) return null;
|
|
|
+ var stamp = parseInt(raw, 10);
|
|
|
+ return isNaN(stamp) ? null : stamp;
|
|
|
+ } catch (error) {
|
|
|
+ return null;
|
|
|
}
|
|
|
- if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
|
- fail('this browser has no Web Push support');
|
|
|
- return;
|
|
|
- }
|
|
|
- Notification.requestPermission().then(function (permission) {
|
|
|
- if (permission !== 'granted') {
|
|
|
- fail('permission denied in the browser');
|
|
|
- return;
|
|
|
- }
|
|
|
- // The service worker lives at the origin root (GET
|
|
|
- // /sw.js, Service-Worker-Allowed: /), so its scope
|
|
|
- // covers every page.
|
|
|
- return navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
|
|
- .then(function () { return navigator.serviceWorker.ready; })
|
|
|
- .then(function (registration) {
|
|
|
- return registration.pushManager.getSubscription().then(function (existing) {
|
|
|
- if (existing) return existing;
|
|
|
- return registration.pushManager.subscribe({
|
|
|
- userVisibleOnly: true,
|
|
|
- applicationServerKey: vapidKeyBytes(state.push_vapid_key)
|
|
|
- });
|
|
|
- });
|
|
|
- })
|
|
|
- .then(function (subscription) {
|
|
|
- var keys = subscription.toJSON().keys || {};
|
|
|
- return statum.act(state.push_subscribe, {
|
|
|
- endpoint: subscription.endpoint,
|
|
|
- p256dh: keys.p256dh,
|
|
|
- auth: keys.auth
|
|
|
- });
|
|
|
- })
|
|
|
- .catch(function (error) { fail(error); });
|
|
|
- });
|
|
|
}
|
|
|
|
|
|
- function disablePush() {
|
|
|
- var state = dashboard();
|
|
|
- navigator.serviceWorker.getRegistration('/').then(function (registration) {
|
|
|
- if (!registration) return null;
|
|
|
- return registration.pushManager.getSubscription();
|
|
|
- }).then(function (subscription) {
|
|
|
- var done = subscription ? subscription.unsubscribe() : Promise.resolve(true);
|
|
|
- return done.then(function () {
|
|
|
- if (subscription && state.push_unsubscribe) {
|
|
|
- return statum.act(state.push_unsubscribe, {
|
|
|
- endpoint: subscription.endpoint
|
|
|
- });
|
|
|
- }
|
|
|
- });
|
|
|
- }).catch(function (error) { fail(error); });
|
|
|
- }
|
|
|
-
|
|
|
- // ---- per-browser bell state (the user-global flag lied) ----
|
|
|
- // The dashboard exposes the user's endpoint FINGERPRINTS;
|
|
|
- // this browser's own pushManager subscription is hashed
|
|
|
- // the same way and looked up in the list. Each browser
|
|
|
- // then shows its own truth: subscribed here ("Turn off")
|
|
|
- // vs subscribed elsewhere only ("Enable" + a count). The
|
|
|
- // client-side check is authoritative - the server flag is
|
|
|
- // just a convenience for other consumers.
|
|
|
- var browserSubscribed = null; // null = still asking the browser
|
|
|
- var hereNow = false; // the displayed verdict (click acts on it)
|
|
|
-
|
|
|
- function sha256Hex16(text) {
|
|
|
- // Mirrors PushService.endpoint_fingerprint: first 8
|
|
|
- // bytes of the SHA-256, hex - crypto.subtle needs a
|
|
|
- // secure context, exactly like pushManager itself.
|
|
|
- var data = new TextEncoder().encode(text);
|
|
|
- return crypto.subtle.digest('SHA-256', data).then(function (digest) {
|
|
|
- var bytes = new Uint8Array(digest).slice(0, 8);
|
|
|
- var hex = '';
|
|
|
- for (var index = 0; index < bytes.length; index++) {
|
|
|
- hex += ('0' + bytes[index].toString(16)).slice(-2);
|
|
|
+ function syncActivityCount() {
|
|
|
+ var state = statum.read('dashboard');
|
|
|
+ var badge = document.querySelector('[data-activity-new]');
|
|
|
+ if (!state || !badge || !state.activity) return;
|
|
|
+ var last = readLastVisit();
|
|
|
+ var fresh = 0;
|
|
|
+ if (last !== null) {
|
|
|
+ for (var index = 0; index < state.activity.length; index++) {
|
|
|
+ if (state.activity[index].at_epoch > last) fresh++;
|
|
|
}
|
|
|
- return hex;
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- function myFingerprint() {
|
|
|
- if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
|
- return Promise.resolve(null);
|
|
|
}
|
|
|
- return navigator.serviceWorker.getRegistration('/').then(function (registration) {
|
|
|
- if (!registration) return null;
|
|
|
- return registration.pushManager.getSubscription().then(function (sub) {
|
|
|
- return sub ? sha256Hex16(sub.endpoint) : null;
|
|
|
- });
|
|
|
- }).catch(function () { return null; });
|
|
|
+ badge.textContent = fresh > 0
|
|
|
+ ? fresh + ' new since your last visit' : '';
|
|
|
+ badge.hidden = fresh === 0;
|
|
|
}
|
|
|
|
|
|
- function otherBrowserCount() {
|
|
|
- // The dashboard's fingerprints minus this browser's own.
|
|
|
- var mine = browserSubscribed;
|
|
|
- var list = dashboard().push_endpoints || [];
|
|
|
- if (mine === null || mine === undefined) return list.length;
|
|
|
- var others = 0;
|
|
|
- for (var index = 0; index < list.length; index++) {
|
|
|
- if (list[index] !== mine) others++;
|
|
|
- }
|
|
|
- return others;
|
|
|
- }
|
|
|
-
|
|
|
- function refreshBell() {
|
|
|
- return myFingerprint().then(function (mine) {
|
|
|
- browserSubscribed = mine;
|
|
|
- var list = dashboard().push_endpoints || [];
|
|
|
- var card = document.querySelector('.push-bell-card');
|
|
|
- if (!card) return;
|
|
|
- var here = mine !== null && list.indexOf(mine) !== -1;
|
|
|
- var others = otherBrowserCount();
|
|
|
- var onBtn = card.querySelector('[data-bell-on]');
|
|
|
- var offBtn = card.querySelector('[data-bell-off]');
|
|
|
- var help = card.querySelector('[data-bell-help]');
|
|
|
- var testBtn = card.querySelector('[data-push-test]');
|
|
|
- hereNow = here;
|
|
|
- if (onBtn) onBtn.hidden = here;
|
|
|
- if (offBtn) offBtn.hidden = !here;
|
|
|
- if (testBtn) testBtn.hidden = !here;
|
|
|
- if (help) {
|
|
|
- if (here) {
|
|
|
- help.textContent = others > 0
|
|
|
- ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
|
|
|
- + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
|
|
|
- : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
|
|
|
- } else if (list.length > 0) {
|
|
|
- help.textContent = '\u2014 you are subscribed on ' + list.length
|
|
|
- + ' other browser(s); enable here too to be notified in this browser';
|
|
|
- } else {
|
|
|
- help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
+ function syncActivityToggle() {
|
|
|
+ var state = statum.read('dashboard');
|
|
|
+ var expander = document.querySelector('[data-activity-expander]');
|
|
|
+ if (!expander || !state || !state.activity) return;
|
|
|
+ var button = expander.querySelector('[data-activity-toggle]');
|
|
|
+ if (!button) return;
|
|
|
+ var all = expander.getAttribute('data-activity-all') === '1';
|
|
|
+ button.textContent = all ? 'Show fewer'
|
|
|
+ : 'Show all ' + state.activity.length;
|
|
|
}
|
|
|
|
|
|
- function sendTest() {
|
|
|
- var state = dashboard();
|
|
|
- if (!state.push_test) {
|
|
|
- fail('not available on this server');
|
|
|
- return;
|
|
|
- }
|
|
|
- statum.act(state.push_test, {}).catch(function (error) { fail(error); });
|
|
|
- }
|
|
|
-
|
|
|
- // The dashboard state refreshes on every pulse; the bell's
|
|
|
- // per-browser verdict must be re-derived each time (the
|
|
|
- // browser's own subscription may have changed under us).
|
|
|
- statum.addStateListener('dashboard', refreshBell);
|
|
|
- refreshBell();
|
|
|
-
|
|
|
document.addEventListener('click', function (event) {
|
|
|
if (!event.target.closest) return;
|
|
|
- var card = event.target.closest('.push-bell-card');
|
|
|
- if (!card) return;
|
|
|
- var bell = event.target.closest('[data-push-bell]');
|
|
|
- var test = event.target.closest('[data-push-test]');
|
|
|
- if (bell) {
|
|
|
- event.preventDefault();
|
|
|
- // Follow the DISPLAYED verdict: a browser holding a
|
|
|
- // subscription the server no longer knows (a pruned
|
|
|
- // row) shows Enable - and enabling re-registers the
|
|
|
- // existing subscription, healing it.
|
|
|
- if (hereNow) disablePush();
|
|
|
- else enablePush();
|
|
|
- } else if (test) {
|
|
|
- event.preventDefault();
|
|
|
- sendTest();
|
|
|
- }
|
|
|
+ var button = event.target.closest('[data-activity-toggle]');
|
|
|
+ if (!button) return;
|
|
|
+ event.preventDefault();
|
|
|
+ var expander = button.closest('[data-activity-expander]');
|
|
|
+ if (!expander) return;
|
|
|
+ var all = expander.getAttribute('data-activity-all') === '1';
|
|
|
+ expander.setAttribute('data-activity-all', all ? '0' : '1');
|
|
|
+ syncActivityToggle();
|
|
|
+ });
|
|
|
+
|
|
|
+ statum.afterEvaluate(syncActivityCount);
|
|
|
+ statum.afterEvaluate(syncActivityToggle);
|
|
|
+ window.addEventListener('beforeunload', function () {
|
|
|
+ try {
|
|
|
+ window.localStorage.setItem(VISIT_KEY,
|
|
|
+ String(Math.floor(Date.now() / 1000)));
|
|
|
+ } catch (error) { /* private mode: no baseline, no badge */ }
|
|
|
});
|
|
|
+
|
|
|
+ // The per-browser bell engine (fingerprint this browser's
|
|
|
+ // subscription against the slot's endpoint list; Enable vs
|
|
|
+ // Turn off follows the DISPLAYED verdict): shared with the
|
|
|
+ // /profile settings card, defined in app.js.
|
|
|
+ window.initPushBell({ stateType: 'dashboard' });
|
|
|
})();
|
|
|
}
|
|
|
</script>
|
|
|
</head>
|
|
|
<body>
|
|
|
- <section>
|
|
|
- <!-- The advisor hero (goal 27): the page's FIRST and
|
|
|
- predominant control. Starting a chat here creates it
|
|
|
- (titled from the first line) and lands on the chat page;
|
|
|
- the advisor reads fleet state, asks questions and
|
|
|
- suggests tasks/goals, but never runs commands. -->
|
|
|
- <section stm-if="dashboard != null" class="advisor-hero card">
|
|
|
- <!-- Its own block layout (.advisor-hero-form): NOT
|
|
|
- form.inline + .form-row, whose inline-flex and 34rem
|
|
|
- column cap squeeze a multi-line textarea into a
|
|
|
- cramped sliver. The composer spans the card's full
|
|
|
- width with a roomy textarea; the button sits below it,
|
|
|
- right-aligned. -->
|
|
|
- <form class="advisor-hero-form"
|
|
|
- onsubmit="window.startAdvisorChat(); return false;">
|
|
|
- <label for="advisor-composer">Ask the advisor</label>
|
|
|
- <textarea id="advisor-composer" rows="3"
|
|
|
- stm-attribute.disabled="!dashboard.advisor_configured"
|
|
|
- placeholder="What should the fleet work on next?"></textarea>
|
|
|
- <div class="advisor-hero-actions">
|
|
|
- <button type="submit" class="btn-primary"
|
|
|
- stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
|
|
|
- </div>
|
|
|
- </form>
|
|
|
- <p class="text-muted" stm-if="dashboard.advisor_configured">Reads your projects,
|
|
|
- tasks, goals, rules and discoveries - asks you questions and suggests
|
|
|
- work. <a href="/chats">Your chats</a></p>
|
|
|
- <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
|
|
|
- configured - set the model endpoint on the <a href="/models">Admin →
|
|
|
- Model</a> page to enable it.</p>
|
|
|
- <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
|
|
|
- no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
|
|
|
- the <code>share/clanker-saddle/canon</code> package files.</p>
|
|
|
- </section>
|
|
|
-
|
|
|
- <h2>Dashboard</h2>
|
|
|
+ <div class="stack">
|
|
|
<p stm-if="dashboard == null">Loading…</p>
|
|
|
|
|
|
- <div stm-if="dashboard != null">
|
|
|
- <!-- The notification bell (plan 11 task E): subscribe this
|
|
|
- browser (permission + service worker + VAPID key) or
|
|
|
- turn it off. Block events notify every subscribed user;
|
|
|
- per-task "Notify me" adds start/failure/completion. -->
|
|
|
- <!-- The notification bell (plan 11 task E; per-browser
|
|
|
- since the user-global flag lied to second browsers):
|
|
|
- THIS browser's own subscription decides Enable vs Turn
|
|
|
- off (the JS fingerprints the browser's endpoint and
|
|
|
- matches it against the dashboard's list), the helper
|
|
|
- text counts the user's OTHER subscribed browsers, and
|
|
|
- "Send test notification" fires the real end-to-end
|
|
|
- probe through this user's subscriptions. -->
|
|
|
- <section class="card push-bell-card">
|
|
|
- <form class="cluster">
|
|
|
- <button type="button" data-push-bell data-bell-on class="btn-primary"
|
|
|
- hidden>Enable notifications</button>
|
|
|
- <button type="button" data-push-bell data-bell-off
|
|
|
- hidden>Turn off notifications</button>
|
|
|
- <button type="button" data-push-test
|
|
|
- hidden>Send test notification</button>
|
|
|
- <span class="text-muted" data-bell-help>— be told when an agent asks a
|
|
|
- question, raises an RFC or review</span>
|
|
|
+ <div stm-if="dashboard != null" class="stack">
|
|
|
+ <!-- The advisor hero (goal 27; goal 41 task 3 keeps it the
|
|
|
+ page's first control but gives it the samplers' shape:
|
|
|
+ the heading and the description sit INSIDE the composer
|
|
|
+ card, above the textarea, so the primary surface is
|
|
|
+ self-describing). Starting a chat here creates it
|
|
|
+ (titled from the first line) and lands on the chat
|
|
|
+ page; the advisor reads fleet state, asks questions
|
|
|
+ and suggests tasks/goals, but never runs commands. -->
|
|
|
+ <section stm-if="dashboard != null" class="advisor-hero card">
|
|
|
+ <!-- Its own block layout (.advisor-hero-form): NOT
|
|
|
+ form.inline + .form-row, whose inline-flex and 34rem
|
|
|
+ column cap squeeze a multi-line textarea into a
|
|
|
+ cramped sliver. The composer spans the card's full
|
|
|
+ width with a roomy textarea; the button sits below
|
|
|
+ it, right-aligned. -->
|
|
|
+ <form class="advisor-hero-form"
|
|
|
+ onsubmit="window.startAdvisorChat(); return false;">
|
|
|
+ <label for="advisor-composer">Ask the advisor</label>
|
|
|
+ <p class="hint advisor-hero-lede">Reads your projects, tasks, goals, rules and
|
|
|
+ discoveries — asks you questions and suggests work. It never runs
|
|
|
+ commands.</p>
|
|
|
+ <textarea id="advisor-composer" rows="3"
|
|
|
+ stm-attribute.disabled="!dashboard.advisor_configured"
|
|
|
+ placeholder="What should the fleet work on next?"></textarea>
|
|
|
+ <div class="advisor-hero-actions">
|
|
|
+ <p class="hint advisor-hero-link"><a href="/chats">Your chats</a></p>
|
|
|
+ <button type="submit" class="btn-primary"
|
|
|
+ stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
|
|
|
+ </div>
|
|
|
</form>
|
|
|
+ <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
|
|
|
+ configured - set the model endpoint on the <a href="/models">Admin →
|
|
|
+ Model</a> page to enable it.</p>
|
|
|
+ <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
|
|
|
+ no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
|
|
|
+ the <code>share/clanker-saddle/canon</code> package files.</p>
|
|
|
</section>
|
|
|
|
|
|
- <div class="cards">
|
|
|
- <section class="card">
|
|
|
- <h3>Queue</h3>
|
|
|
- <p><strong stm-text="dashboard.ready_count">0</strong> ready to dispatch</p>
|
|
|
- <p class="text-muted"><span stm-text="dashboard.queued_count">0</span> queued ·
|
|
|
- <span stm-text="dashboard.active_count">0</span> active ·
|
|
|
- <span stm-text="dashboard.blocked_count">0</span> blocked</p>
|
|
|
- <p class="text-muted"><span stm-text="dashboard.done_count">0</span> done ·
|
|
|
- <span stm-text="dashboard.failed_count">0</span> failed</p>
|
|
|
- </section>
|
|
|
- <section class="card">
|
|
|
- <h3>Saddles</h3>
|
|
|
- <p><strong stm-text="dashboard.idle_saddle_count">0</strong> idle</p>
|
|
|
- <p><a href="/saddles">Manage saddles</a> · <a href="/tasks">Task queue</a></p>
|
|
|
- </section>
|
|
|
- </div>
|
|
|
+ <!-- ATTENTION FIRST (goal 41 task 3 / ux-notes §1 A+B): the
|
|
|
+ left column is why the page exists — pinned needs-a-human
|
|
|
+ blocks, then active tasks with their latest progress. The
|
|
|
+ right rail carries the reference material: queue metrics,
|
|
|
+ the saddle list and the push-bell preference strip (a
|
|
|
+ settings action, not news — the old full-width opener card
|
|
|
+ is gone; the preference UI now lives on /profile). Stacks
|
|
|
+ under the framework's 55rem breakpoint. -->
|
|
|
+ <div class="with-sidebar dash-board">
|
|
|
+ <div class="dash-attention stack">
|
|
|
+ <section aria-labelledby="needs-a-human-h">
|
|
|
+ <h2 id="needs-a-human-h" class="dash-heading">Needs a human
|
|
|
+ <span class="badge badge-warning"
|
|
|
+ stm-if="dashboard.pinned.length > 0"
|
|
|
+ stm-text="dashboard.pinned.length">0</span></h2>
|
|
|
+ <p class="text-muted">Open questions, RFCs and reviews across all tasks and
|
|
|
+ goals, newest first — follow the link to answer on the task or goal
|
|
|
+ page.</p>
|
|
|
+ <div class="cards" stm-if="dashboard.pinned.length > 0">
|
|
|
+ <section stm-for-blockview-in="dashboard.pinned"
|
|
|
+ stm-attribute.class="blockview.item_type == 'goal' ? 'card goal-card' : 'card'">
|
|
|
+ <p>
|
|
|
+ <span stm-if="blockview.item_type == 'goal'" class="goal-icon">🎯</span>
|
|
|
+ <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
|
|
|
+ stm-text="blockview.item_type">task</span>
|
|
|
+ <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
|
|
|
+ <span class="text-muted" stm-text="blockview.created_display">when</span></p>
|
|
|
+ <p><strong stm-text="blockview.question">question</strong></p>
|
|
|
+ <p stm-if="blockview.item_type == 'task'">
|
|
|
+ <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
|
|
|
+ <p stm-if="blockview.item_type == 'goal'">
|
|
|
+ <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ <p stm-if="dashboard.pinned.length == 0" class="text-muted">Nothing needs a
|
|
|
+ human — no open questions, RFCs or reviews.</p>
|
|
|
+ </section>
|
|
|
|
|
|
- <!-- The scratchpad (goal 8 task 3): jot down batches of
|
|
|
- changes before they are ready to be full-on goals.
|
|
|
- Check a note off, delete it, or promote it — promote
|
|
|
- creates a DRAFT goal (title = the first line, brief =
|
|
|
- the whole body) and jumps to its page to finish. -->
|
|
|
- <h3>Notes</h3>
|
|
|
- <section class="card">
|
|
|
- <form stm-action="dashboard.add_note">
|
|
|
- <div class="field">
|
|
|
- <textarea name="body" rows="2" required
|
|
|
- placeholder="Note down a batch of changes before it is ready to be a goal…"></textarea></div>
|
|
|
- <button type="submit">Add note</button>
|
|
|
- </form>
|
|
|
- </section>
|
|
|
+ <section aria-labelledby="active-tasks-h"
|
|
|
+ stm-if="dashboard.active_tasks.length > 0">
|
|
|
+ <h2 id="active-tasks-h" class="dash-heading">Active tasks</h2>
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table class="table-hover">
|
|
|
+ <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ <tr stm-for-active-in="dashboard.active_tasks">
|
|
|
+ <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
|
|
|
+ <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
|
|
|
+ <td stm-text="active.task_branch">branch</td>
|
|
|
+ <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
|
|
|
+ <span class="text-muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
|
|
|
+ <span class="text-muted" stm-if="active.latest_progress.length == 0">—</span></td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
|
|
|
- <!-- The five newest discoveries (goal 24): the fleet's
|
|
|
- knowledge base at a glance; the Discoveries page owns
|
|
|
- the searchable listing. -->
|
|
|
- <div stm-if="dashboard.discoveries.length > 0">
|
|
|
- <h3>Latest discoveries</h3>
|
|
|
- <ul>
|
|
|
- <li stm-for-discovery-in="dashboard.discoveries" class="md footnote"
|
|
|
- stm-markdown="discovery.body">body</li>
|
|
|
- </ul>
|
|
|
- <p class="text-muted"><a href="/discoveries">All discoveries →</a></p>
|
|
|
- </div>
|
|
|
- <table stm-if="dashboard.notes.length > 0">
|
|
|
- <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
|
|
|
- <tbody>
|
|
|
- <tr stm-for-note-in="dashboard.notes">
|
|
|
- <td><button type="button" stm-action="note.toggle"
|
|
|
- stm-attribute.class="note.done ? 'approve' : ''"
|
|
|
- stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
|
|
|
- <td><span stm-attribute.class="note.done ? 'footnote' : ''"
|
|
|
- stm-text="note.body">body</span></td>
|
|
|
- <td class="text-muted" stm-text="note.created_display">when</td>
|
|
|
- <td>
|
|
|
- <span stm-if="note.goal_id > 0">
|
|
|
- <span class="goal-icon">🎯</span>
|
|
|
- <a stm-attribute.href="'/goals/' + note.goal_id"
|
|
|
- stm-text="note.goal_title">goal</a>
|
|
|
- </span>
|
|
|
- <form class="cluster" stm-if="note.goal_id == 0 && !note.done"
|
|
|
- stm-action="note.promote">
|
|
|
- <select name="project_id" required>
|
|
|
- <option stm-for-project-in="dashboard.projects"
|
|
|
- stm-attribute.value="project.id"
|
|
|
- stm-text="project.name">project</option>
|
|
|
- </select>
|
|
|
- <input type="text" name="branch_name" required
|
|
|
- placeholder="goal/branch" size="14">
|
|
|
- <button type="submit" class="btn-primary">Create goal</button>
|
|
|
- </form>
|
|
|
- <span class="text-muted" stm-if="note.goal_id == 0 && note.done">—</span>
|
|
|
- <form class="cluster" stm-action="note.delete">
|
|
|
- <button type="submit">Delete</button>
|
|
|
- </form>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- <p stm-if="dashboard.notes.length == 0" class="text-muted">No notes yet — jot down the
|
|
|
- next batch of changes above.</p>
|
|
|
+ <!-- The right rail: metrics, the saddle verdict list and
|
|
|
+ the bell strip. Everything here is one-glance state or
|
|
|
+ a settings action — nothing that outranks the
|
|
|
+ attention column. -->
|
|
|
+ <aside class="dash-rail stack" aria-label="Fleet status">
|
|
|
+ <section class="card card-flat">
|
|
|
+ <h3>Queue</h3>
|
|
|
+ <p class="dash-metric"><strong class="num" stm-text="dashboard.ready_count">0</strong>
|
|
|
+ ready to dispatch</p>
|
|
|
+ <p class="text-muted"><span stm-text="dashboard.queued_count">0</span> queued ·
|
|
|
+ <span stm-text="dashboard.active_count">0</span> active ·
|
|
|
+ <span stm-text="dashboard.blocked_count">0</span> blocked</p>
|
|
|
+ <p class="text-muted"><span stm-text="dashboard.done_count">0</span> done ·
|
|
|
+ <span stm-text="dashboard.failed_count">0</span> failed</p>
|
|
|
+ <p><a href="/tasks">Task queue</a></p>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="card card-flat" aria-labelledby="rail-saddles-h">
|
|
|
+ <h3 id="rail-saddles-h">Saddles</h3>
|
|
|
+ <p class="dash-metric"><strong class="num" stm-text="dashboard.idle_saddle_count">0</strong>
|
|
|
+ idle</p>
|
|
|
+ <ul class="dash-saddles">
|
|
|
+ <li stm-for-saddle-in="dashboard.saddles">
|
|
|
+ <span stm-attribute.class="'badge badge-' + saddle.status"
|
|
|
+ stm-text="saddle.status">status</span>
|
|
|
+ <strong stm-text="saddle.name">name</strong>
|
|
|
+ <span stm-if="saddle.current_goal_id > 0">·
|
|
|
+ <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
|
|
|
+ stm-text="saddle.current_goal_title">goal</a></span>
|
|
|
+ <span stm-if="saddle.current_goal_id == 0 && saddle.current_task_id > 0">·
|
|
|
+ <a stm-attribute.href="'/tasks/' + saddle.current_task_id"
|
|
|
+ stm-text="saddle.current_task_title">task</a></span>
|
|
|
+ </li>
|
|
|
+ <li stm-if="dashboard.saddles.length == 0" class="text-muted">No saddles
|
|
|
+ registered.</li>
|
|
|
+ </ul>
|
|
|
+ <p><a href="/saddles">Manage saddles</a></p>
|
|
|
+ </section>
|
|
|
|
|
|
- <h3 stm-if="dashboard.pinned.length > 0">Needs a human
|
|
|
- (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
|
|
|
- <p class="text-muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and
|
|
|
- reviews across all tasks and goals, newest first — follow the link to
|
|
|
- answer on the task or goal page.</p>
|
|
|
- <div class="cards" stm-if="dashboard.pinned.length > 0">
|
|
|
- <section stm-for-blockview-in="dashboard.pinned"
|
|
|
- stm-attribute.class="blockview.item_type == 'goal' ? 'card goal-card' : 'card'">
|
|
|
- <p>
|
|
|
- <span stm-if="blockview.item_type == 'goal'" class="goal-icon">🎯</span>
|
|
|
- <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
|
|
|
- stm-text="blockview.item_type">task</span>
|
|
|
- <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
|
|
|
- <span class="text-muted" stm-text="blockview.created_display">when</span></p>
|
|
|
- <p><strong stm-text="blockview.question">question</strong></p>
|
|
|
- <p stm-if="blockview.item_type == 'task'">
|
|
|
- <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
|
|
|
- <p stm-if="blockview.item_type == 'goal'">
|
|
|
- <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
|
|
|
- </section>
|
|
|
+ <!-- The notification bell (plan 11 task E; per-browser
|
|
|
+ since the user-global flag lied to second
|
|
|
+ browsers): THIS browser's own subscription decides
|
|
|
+ Enable vs Turn off (the JS fingerprints the
|
|
|
+ browser's endpoint and matches it against the
|
|
|
+ slot's list), the helper text counts the user's
|
|
|
+ OTHER subscribed browsers, and "Send test
|
|
|
+ notification" fires the real end-to-end probe
|
|
|
+ through this user's subscriptions. A compact
|
|
|
+ strip in the rail (goal 41 task 3): the
|
|
|
+ preference UI lives on /profile. -->
|
|
|
+ <section class="card card-flat push-bell-card">
|
|
|
+ <h3>Notifications</h3>
|
|
|
+ <form class="stack">
|
|
|
+ <button type="button" class="btn-sm btn-primary" data-push-bell
|
|
|
+ data-bell-on hidden>Enable notifications</button>
|
|
|
+ <button type="button" class="btn-sm" data-push-bell data-bell-off
|
|
|
+ hidden>Turn off notifications</button>
|
|
|
+ <button type="button" class="btn-sm" data-push-test
|
|
|
+ hidden>Send test notification</button>
|
|
|
+ <p class="hint" data-bell-help>— be told when an agent asks a question,
|
|
|
+ raises an RFC or review</p>
|
|
|
+ <p class="hint"><a href="/profile">Notification settings</a></p>
|
|
|
+ </form>
|
|
|
+ </section>
|
|
|
+ </aside>
|
|
|
</div>
|
|
|
- <p stm-if="dashboard.pinned.length == 0" class="text-muted">Nothing needs a human —
|
|
|
- no open questions, RFCs or reviews.</p>
|
|
|
|
|
|
- <h3 stm-if="dashboard.active_tasks.length > 0">Active tasks</h3>
|
|
|
- <table stm-if="dashboard.active_tasks.length > 0">
|
|
|
- <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
|
|
|
- <tbody>
|
|
|
- <tr stm-for-active-in="dashboard.active_tasks">
|
|
|
- <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
|
|
|
- <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
|
|
|
- <td stm-text="active.task_branch">branch</td>
|
|
|
- <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
|
|
|
- <span class="text-muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
|
|
|
- <span class="text-muted" stm-if="active.latest_progress.length == 0">—</span></td>
|
|
|
- </tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
+ <!-- ACTIVITY (ux-notes §1 C): the page's only pure-read
|
|
|
+ section folds under a collapsed expander. The summary
|
|
|
+ carries a live "N new since your last visit" badge (the
|
|
|
+ localStorage stamp — see the head script for why) and
|
|
|
+ the rows cap at eight until "Show all" (the count still
|
|
|
+ covers the whole window the slot carries). -->
|
|
|
+ <details class="dash-ambient" data-activity-expander>
|
|
|
+ <summary>Activity <span class="badge badge-info" data-activity-new hidden>new</span></summary>
|
|
|
+ <div class="dash-ambient-body">
|
|
|
+ <p class="text-muted">The last <span stm-text="dashboard.activity.length">0</span>
|
|
|
+ events across all tasks, newest first.</p>
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table class="table-compact dash-activity-table">
|
|
|
+ <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ <tr stm-for-item-in="dashboard.activity">
|
|
|
+ <td stm-text="item.when_display">when</td>
|
|
|
+ <td stm-if="item.goal_id > 0">
|
|
|
+ <span class="badge badge-kind-goal">goal</span>
|
|
|
+ <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
|
|
|
+ <a stm-if="item.task_id > 0" class="text-muted"
|
|
|
+ stm-attribute.href="'/tasks/' + item.task_id"
|
|
|
+ >→ <span stm-text="'task #' + item.task_id">task</span></a></td>
|
|
|
+ <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
|
|
|
+ <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
|
|
|
+ <!-- Comment rows read as footnotes (plan 13
|
|
|
+ B4): the body leads, muted and smaller. -->
|
|
|
+ <span stm-attribute.class="item.kind == 'comment' || item.kind == 'goal-comment' || item.kind == 'goal-system' ? 'footnote' : ''"
|
|
|
+ stm-text="item.text">activity</span></td>
|
|
|
+ </tr>
|
|
|
+ <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="text-muted">Nothing yet.</td></tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ <button type="button" class="btn-ghost btn-sm" data-activity-toggle
|
|
|
+ stm-attribute.hidden="dashboard.activity.length <= 8">Show all</button>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
|
|
|
- <h3>Saddles</h3>
|
|
|
- <div class="cards">
|
|
|
- <section class="card" stm-for-saddle-in="dashboard.saddles">
|
|
|
- <h3 stm-text="saddle.name">name</h3>
|
|
|
- <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
|
|
|
- <span class="text-muted" stm-text="saddle.host">host</span></p>
|
|
|
- <p stm-if="saddle.current_goal_id > 0">Supervising goal
|
|
|
- <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
|
|
|
- stm-text="saddle.current_goal_title">goal</a>
|
|
|
- <span class="badge badge-kind-goal">goal</span></p>
|
|
|
- <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Working on
|
|
|
- <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a></p>
|
|
|
- <p stm-if="saddle.current_task_id == 0" class="text-muted">No current task</p>
|
|
|
- </section>
|
|
|
- <section class="card" stm-if="dashboard.saddles.length == 0">
|
|
|
- <p class="text-muted">No saddles registered.</p>
|
|
|
- </section>
|
|
|
- </div>
|
|
|
+ <!-- AMBIENT ZONES (goal 41 task 3; the mocks/ sampler
|
|
|
+ review: reference material starts COLLAPSED so a visit
|
|
|
+ for "answer the question" never scrolls past it). -->
|
|
|
+ <div class="expander-group dash-ambient">
|
|
|
+ <!-- The scratchpad (goal 8 task 3): jot down batches of
|
|
|
+ changes before they are ready to be full-on goals.
|
|
|
+ Check a note off, delete it, or promote it — promote
|
|
|
+ creates a DRAFT goal (title = the first line, brief =
|
|
|
+ the whole body) and jumps to its page to finish. -->
|
|
|
+ <details stm-if="dashboard.notes.length > 0">
|
|
|
+ <summary>Notes <span class="badge"
|
|
|
+ stm-text="dashboard.notes.length">0</span></summary>
|
|
|
+ <div class="dash-ambient-body">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ <tr stm-for-note-in="dashboard.notes">
|
|
|
+ <td><button type="button" stm-action="note.toggle"
|
|
|
+ stm-attribute.class="note.done ? 'approve' : ''"
|
|
|
+ stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
|
|
|
+ <td><span stm-attribute.class="note.done ? 'footnote' : ''"
|
|
|
+ stm-text="note.body">body</span></td>
|
|
|
+ <td class="text-muted" stm-text="note.created_display">when</td>
|
|
|
+ <td>
|
|
|
+ <span stm-if="note.goal_id > 0">
|
|
|
+ <span class="goal-icon">🎯</span>
|
|
|
+ <a stm-attribute.href="'/goals/' + note.goal_id"
|
|
|
+ stm-text="note.goal_title">goal</a>
|
|
|
+ </span>
|
|
|
+ <form class="cluster" stm-if="note.goal_id == 0 && !note.done"
|
|
|
+ stm-action="note.promote">
|
|
|
+ <select name="project_id" required>
|
|
|
+ <option stm-for-project-in="dashboard.projects"
|
|
|
+ stm-attribute.value="project.id"
|
|
|
+ stm-text="project.name">project</option>
|
|
|
+ </select>
|
|
|
+ <input type="text" name="branch_name" required
|
|
|
+ placeholder="goal/branch" size="14">
|
|
|
+ <button type="submit" class="btn-primary">Create goal</button>
|
|
|
+ </form>
|
|
|
+ <span class="text-muted" stm-if="note.goal_id == 0 && note.done">—</span>
|
|
|
+ <form class="cluster" stm-action="note.delete">
|
|
|
+ <button type="submit">Delete</button>
|
|
|
+ </form>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
|
|
|
- <h3>Activity</h3>
|
|
|
- <p class="text-muted">The last <span stm-text="dashboard.activity.length">0</span> events
|
|
|
- across all tasks, newest first.</p>
|
|
|
- <table>
|
|
|
- <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
|
|
|
- <tbody>
|
|
|
- <tr stm-for-item-in="dashboard.activity">
|
|
|
- <td stm-text="item.when_display">when</td>
|
|
|
- <td stm-if="item.goal_id > 0">
|
|
|
- <span class="badge badge-kind-goal">goal</span>
|
|
|
- <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
|
|
|
- <a stm-if="item.task_id > 0" class="text-muted"
|
|
|
- stm-attribute.href="'/tasks/' + item.task_id"
|
|
|
- >→ <span stm-text="'task #' + item.task_id">task</span></a></td>
|
|
|
- <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
|
|
|
- <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
|
|
|
- <!-- Comment rows read as footnotes (plan 13
|
|
|
- B4): the body leads, muted and smaller. -->
|
|
|
- <span stm-attribute.class="item.kind == 'comment' || item.kind == 'goal-comment' || item.kind == 'goal-system' ? 'footnote' : ''"
|
|
|
- stm-text="item.text">activity</span></td>
|
|
|
- </tr>
|
|
|
- <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="text-muted">Nothing yet.</td></tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
+ <!-- The five newest discoveries (goal 24): the fleet's
|
|
|
+ knowledge base at a glance; the Discoveries page owns
|
|
|
+ the searchable listing. -->
|
|
|
+ <details stm-if="dashboard.discoveries.length > 0">
|
|
|
+ <summary>Latest discoveries <span class="badge"
|
|
|
+ stm-text="dashboard.discoveries.length">0</span></summary>
|
|
|
+ <div class="dash-ambient-body">
|
|
|
+ <ul>
|
|
|
+ <li stm-for-discovery-in="dashboard.discoveries" class="md footnote"
|
|
|
+ stm-markdown="discovery.body">body</li>
|
|
|
+ </ul>
|
|
|
+ <p class="text-muted"><a href="/discoveries">All discoveries →</a></p>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </section>
|
|
|
+ </div>
|
|
|
</body>
|
|
|
</html>
|