dashboard: approval_added / approval_resolved mutation events + client derived state

This commit is contained in:
müde 2026-05-17 13:30:25 +02:00
parent 291f1fce42
commit 56d615b51f
6 changed files with 244 additions and 11 deletions

View file

@ -634,7 +634,51 @@
}
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
function renderApprovals(s) {
// Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_added` / `approval_resolved` dashboard events.
// `pending` is the open queue (newest-first); `history` is the last
// 30 resolved rows.
const APPROVAL_HISTORY_LIMIT = 30;
const approvalsState = { pending: [], history: [] };
function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice();
}
function applyApprovalAdded(ev) {
// Upsert by id so a snapshot that already included the row (cold
// load + event lands at the same tick) doesn't double it.
const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
const row = {
id: ev.id,
agent: ev.agent,
kind: ev.approval_kind,
sha_short: ev.sha_short || null,
diff: ev.diff || null,
description: ev.description || null,
};
if (existing >= 0) approvalsState.pending[existing] = row;
else approvalsState.pending.push(row);
renderApprovals();
}
function applyApprovalResolved(ev) {
// Drop from pending; prepend to history (newest-first), cap at 30.
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
approvalsState.history.unshift({
id: ev.id,
agent: ev.agent,
kind: ev.approval_kind,
sha_short: ev.sha_short || null,
status: ev.status,
resolved_at: ev.resolved_at,
note: ev.note || null,
description: ev.description || null,
});
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
}
renderApprovals();
}
function renderApprovals() {
const root = $('approvals-section');
root.innerHTML = '';
@ -655,7 +699,8 @@
);
root.append(spawn);
const history = s.approval_history || [];
const pending = approvalsState.pending;
const history = approvalsState.history;
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
const tabs = el('div', { class: 'approval-tabs' });
const pendingTab = el(
@ -664,7 +709,7 @@
type: 'button',
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
},
`pending · ${s.approvals.length}`,
`pending · ${pending.length}`,
);
const historyTab = el(
'button',
@ -676,11 +721,11 @@
);
pendingTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
renderApprovals(s);
renderApprovals();
});
historyTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
renderApprovals(s);
renderApprovals();
});
tabs.append(pendingTab, historyTab);
root.append(tabs);
@ -690,12 +735,12 @@
return;
}
if (!s.approvals.length) {
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
return;
}
const ul = el('ul', { class: 'approvals' });
for (const a of s.approvals) {
for (const a of pending) {
const li = el('li');
const row = el('div', { class: 'row' });
if (a.kind === 'apply_commit') {
@ -954,7 +999,12 @@
renderTombstones(s);
renderQuestions(s);
renderInbox();
renderApprovals(s);
// Sync the derived approvals store from the snapshot, then
// render. Live `approval_added` / `approval_resolved` events
// mutate the store directly and call renderApprovals() without
// a snapshot refetch.
syncApprovalsFromSnapshot(s);
renderApprovals();
renderMetaInputs(s);
restoreOpenDetails(openDetails);
notifyDeltas(s);
@ -1014,6 +1064,11 @@
renderers: {
sent: (ev, api) => renderMsg(ev, api, '→'),
delivered: (ev, api) => renderMsg(ev, api, '✓'),
// Mutation events update derived state and trigger a
// section re-render — no terminal log row (the terminal is
// for broker traffic, not state-change chatter).
approval_added: (ev) => { applyApprovalAdded(ev); },
approval_resolved: (ev) => { applyApprovalResolved(ev); },
},
// Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and