diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 68c2be6..263f227 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -1625,5 +1625,6 @@ textarea { resize: vertical; min-height: 120px; }
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
+.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}
\ No newline at end of file
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 9428373..8010bd4 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -680,6 +680,7 @@
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
status:qs('#human-gates-status'), panel:qs('#human-gates'),
detail:qs('#human-gate-detail'),
+ pendingTab:qs('#human-gates-pending'), historyTab:qs('#human-gates-history'),
},
});
progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey);
@@ -698,6 +699,10 @@
if (!card) return;
humanGates.select(card.dataset.humanGateId);
});
+ qs('#human-gates-pending').addEventListener('click', () => humanGates.showPending());
+ qs('#human-gates-history').addEventListener('click', () => humanGates.showHistory().catch(error => {
+ qs('#human-gates-status').textContent = error.message;
+ }));
qs('#human-gate-detail').addEventListener('click', event => {
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
diff --git a/frontend/human-gates.js b/frontend/human-gates.js
index f2c3539..45a6b07 100644
--- a/frontend/human-gates.js
+++ b/frontend/human-gates.js
@@ -15,6 +15,7 @@ function createHumanGates(options = {}) {
let decisionFlight = null;
let openFlight = null;
let onChange = options.onChange;
+ let historyItems = [];
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
@@ -85,7 +86,6 @@ function createHumanGates(options = {}) {
nodes.detail?.addEventListener?.('input', captureProgress);
nodes.detail?.addEventListener?.('change', captureProgress);
-
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
@@ -101,6 +101,73 @@ function createHumanGates(options = {}) {
).join(''));
}
+ function setView(view) {
+ nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false');
+ nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
+ }
+
+ function renderHistory() {
+ setView('history');
+ setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
+ if (!historyItems.length) {
+ setHtml(nodes.list, '
No decision historyReleased and held candidates will appear here.
');
+ setHtml(nodes.detail, '');
+ return;
+ }
+ setHtml(nodes.list, historyItems.map(item =>
+ ''
+ ).join(''));
+ Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => {
+ card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => {
+ setText(nodes.status, error.message || 'Human Gate history is unavailable.');
+ }));
+ });
+ setHtml(nodes.detail, 'Decision historyOpen a candidate to review its durable receipt.
');
+ }
+
+ async function showHistory() {
+ if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
+ if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
+ const result = validSnapshot(await fetchJson('api/v1/human-gates?state=all'));
+ if (!result) throw new Error('Human Gate history response is invalid.');
+ historyItems = result.items.filter(item => item.state !== 'pending');
+ renderHistory();
+ return JSON.parse(JSON.stringify(historyItems));
+ }
+
+ async function selectHistory(gateId) {
+ const summary = historyItems.find(item => item.id === gateId);
+ if (!summary) throw new Error('Gate is not in the current history.');
+ const detail = await fetchJson('api/v1/human-gates/' + encodeURIComponent(gateId));
+ if (!detail || detail.id !== gateId) throw new Error('Gate history detail is invalid.');
+ let receipt = null;
+ if (detail.receipt_id) receipt = await fetchJson('api/v1/human-gate-receipts/' + encodeURIComponent(detail.receipt_id));
+ const checklist = receipt?.checklist || {};
+ const confirmations = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
+ .filter(key => checklist[key] === true).map(key => '' + escape(key.replaceAll('_', ' ')) + '').join('');
+ setHtml(nodes.detail,
+ '' + escape(detail.state.toUpperCase()) + '
' +
+ '' + escape(detail.title) + '
Project ' + escape(detail.project) + '
' +
+ 'Exact candidate ' + escape(detail.candidate_hash) + '
' +
+ (receipt ? 'Decision receipt ' + escape(receipt.receipt_id) + '
' +
+ 'Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '
' +
+ (receipt.reason ? 'Reason
' + escape(receipt.reason) + '
' : '') +
+ (receipt.override_reason ? 'Override
' + escape(receipt.override_reason) + '
' : '') +
+ (confirmations ? 'Confirmed
' : '') :
+ 'No decision receipt exists because this candidate was superseded.
') + '');
+ return {detail, receipt};
+ }
+
+ function showPending() {
+ setView('pending');
+ render();
+ renderDetail(current());
+ return JSON.parse(JSON.stringify(queue));
+ }
+
function renderDetail(item) {
if (!item) {
setHtml(nodes.detail, 'Inbox zeroFixed review snapshot complete.
');
@@ -301,6 +368,7 @@ function createHumanGates(options = {}) {
if (nodes.panel) nodes.panel.hidden = false;
if (openFlight) return openFlight;
const operation = (async () => {
+ setView('pending');
await load();
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
@@ -315,7 +383,8 @@ function createHumanGates(options = {}) {
}
return {
- load, open, reviewNext, select, decideAndNext, submitDecision, current, saveProgress,
+ load, open, reviewNext, select, showHistory, selectHistory, showPending,
+ decideAndNext, submitDecision, current, saveProgress,
setOnChange(callback) { onChange = callback; },
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
diff --git a/frontend/index.html b/frontend/index.html
index d77ea2c..9fabd15 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -194,6 +194,10 @@
+
+
+
+
diff --git a/frontend/progressive-human-gates.js b/frontend/progressive-human-gates.js
index 3789706..31e27ac 100644
--- a/frontend/progressive-human-gates.js
+++ b/frontend/progressive-human-gates.js
@@ -23,6 +23,7 @@ function createProgressiveHumanGates(options = {}) {
count:query('#human-gates-count'), list:query('#human-gates-list'),
status:query('#human-gates-status'), panel:query('#human-gates'),
detail:query('#human-gate-detail'),
+ pendingTab:query('#human-gates-pending'), historyTab:query('#human-gates-history'),
};
let login = '';
let accountKey = '';
@@ -54,6 +55,8 @@ function createProgressiveHumanGates(options = {}) {
if (!error?.targetSelector) showError(error);
});
});
+ nodes.pendingTab?.addEventListener?.('click', () => controller.showPending());
+ nodes.historyTab?.addEventListener?.('click', () => controller.showHistory().catch(showError));
async function start(force = false) {
if (!force && location.hash !== '#/my-work/human-gates') return false;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index fca8dae..0ce708b 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v147';
+const CACHE = 'stackchain-dashboard-shell-v148';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
diff --git a/src/human_gate_store.py b/src/human_gate_store.py
index 6932284..6df825e 100644
--- a/src/human_gate_store.py
+++ b/src/human_gate_store.py
@@ -182,6 +182,12 @@ class HumanGateStore:
"reason": row["decision_reason"], "override_reason": row["override_reason"],
"checklist": json.loads(row["checklist_json"]),
})
+ receipt = connection.execute(
+ "SELECT receipt_json FROM human_gate_receipts WHERE login=? AND gate_id=? ORDER BY rowid DESC LIMIT 1",
+ (row["login"], row["id"]),
+ ).fetchone()
+ if receipt:
+ item["receipt_id"] = json.loads(receipt[0])["receipt_id"]
if history:
item["history"] = self._history(connection, row["id"])
return item
@@ -264,7 +270,14 @@ class HumanGateStore:
with self._connect() as connection:
pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0]
where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state])
- rows = connection.execute(f"SELECT * FROM human_gates WHERE {where} ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, priority DESC, created_at, id LIMIT ?", (*args, limit)).fetchall()
+ rows = connection.execute(
+ f"SELECT * FROM human_gates WHERE {where} "
+ "ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, "
+ "CASE WHEN state='pending' THEN priority END DESC, "
+ "CASE WHEN state='pending' THEN created_at END, "
+ "CASE WHEN state!='pending' THEN updated_at END DESC, id LIMIT ?",
+ (*args, limit),
+ ).fetchall()
return {"pending_count": pending_count, "items": [self._present(connection, row) for row in rows]}
def detail(self, login: str, gate_id: str) -> dict:
diff --git a/tests/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py
index 3409dab..b63ebc2 100644
--- a/tests/e2e/test_human_gates_reopen_release.py
+++ b/tests/e2e/test_human_gates_reopen_release.py
@@ -154,3 +154,84 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+
+
+def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(tmp_path: Path):
+ archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
+ assert len(archives) == 1
+ fake = FakeGiteaServer(("127.0.0.1", 0))
+ fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
+ fake_thread.start()
+ browser_errors: list[str] = []
+ history_requests: list[str] = []
+ held = {
+ "id": "held", "title": "Held candidate", "project": "stackchain/stackchain-dashboard",
+ "candidate_hash": "bbb222", "state": "held", "revision": 2, "priority": 5,
+ "created_at": 100, "updated_at": 200, "reason": "Needs mobile evidence",
+ "override_reason": "", "checklist": {}, "receipt_id": "receipt-2",
+ "checks": [], "artifacts": [], "links": [], "provenance": {},
+ "history": [{"action": "held", "at": 200, "receipt_id": "receipt-2"}],
+ }
+ released = {
+ **held, "id": "released", "title": "Released candidate", "candidate_hash": "aaa111",
+ "state": "released", "updated_at": 150, "reason": "", "receipt_id": "receipt-1",
+ }
+ try:
+ with release_server(
+ archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
+ ) as origin, sync_playwright() as playwright:
+ browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
+ context = browser.new_context(
+ viewport={"width": 390, "height": 844}, ignore_https_errors=True
+ )
+ page = context.new_page()
+ page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
+
+ def gates_route(route):
+ history_requests.append(route.request.url)
+ if route.request.url.endswith("state=all"):
+ payload = {"pending_count": 0, "items": [held, released]}
+ elif route.request.url.endswith("/held"):
+ payload = held
+ else:
+ payload = {"pending_count": 0, "items": []}
+ route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
+
+ page.route("**/api/v1/human-gates**", gates_route)
+ page.route("**/api/v1/human-gates/**", gates_route)
+ page.route("**/api/v1/human-gate-receipts/receipt-2", lambda route: route.fulfill(
+ status=200, content_type="application/json", body=json.dumps({
+ "receipt_id": "receipt-2", "gate_id": "held", "candidate_hash": "bbb222",
+ "state": "held", "decided_at": 200, "reason": "Needs mobile evidence",
+ "override_reason": "", "checklist": {}, "unmet_required_checks": [],
+ })
+ ))
+ page.goto(origin + "/", wait_until="networkidle")
+ page.locator('input[name="device_label"]').fill("Human Gate history phone")
+ page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
+ page.locator("#submit-sign-in").click()
+ page.wait_for_url(origin + "/", wait_until="networkidle")
+ page.evaluate("document.querySelector('#open-human-gates').click()")
+ expect(page.locator("#human-gates")).to_be_visible()
+ cached_before = page.evaluate("Object.keys(localStorage).sort()")
+
+ page.locator("#human-gates-history").click()
+ expect(page.locator("#human-gates-status")).to_have_text("2 past Human Gate decisions.")
+ expect(page.locator('[data-human-gate-history-id="held"]')).to_contain_text("Held")
+ expect(page.locator('[data-human-gate-history-id="released"]')).to_contain_text("Released")
+ page.locator('[data-human-gate-history-id="held"]').click()
+ expect(page.locator("#human-gate-detail")).to_contain_text("Needs mobile evidence")
+ expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
+ expect(page.locator("#human-gate-detail [data-gate-decision]")).to_have_count(0)
+ assert page.evaluate("Object.keys(localStorage).sort()") == cached_before
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ for selector in ("#human-gates-pending", "#human-gates-history"):
+ bounds = page.locator(selector).bounding_box()
+ assert bounds and bounds["height"] >= 44
+ assert any(url.endswith("state=all") for url in history_requests)
+ assert not browser_errors
+ browser.close()
+ finally:
+ fake.shutdown()
+ fake.server_close()
+ fake_thread.join(timeout=5)
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 39ed95e..eea6a3c 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v147" in worker
+ assert "stackchain-dashboard-shell-v148" in worker
diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py
index 3c7e8fe..0e20938 100644
--- a/tests/test_following_frontend.py
+++ b/tests/test_following_frontend.py
@@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
- assert "stackchain-dashboard-shell-v147" in service_worker
+ assert "stackchain-dashboard-shell-v148" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
diff --git a/tests/test_human_gate_api.py b/tests/test_human_gate_api.py
index dd3d909..16bd639 100644
--- a/tests/test_human_gate_api.py
+++ b/tests/test_human_gate_api.py
@@ -69,6 +69,38 @@ async def test_decision_requires_revision_and_returns_durable_receipt(gate_api):
assert all(response.headers["cache-control"] == "no-store" for response in (decided, repeated, receipt, stale))
+@pytest.mark.anyio
+async def test_decision_history_is_newest_first_receipt_linked_and_principal_bound(gate_api):
+ first = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="history-first")
+ gate_api.decide(
+ "1:timmy", first["id"], expected_revision=1, decision="release", reason="",
+ override_reason="", checklist=CHECKLIST, idempotency_key="history-release",
+ )
+ second_candidate = {**CANDIDATE, "candidate_hash": "def456", "title": "New candidate"}
+ second = gate_api.intake("1:timmy", second_candidate, idempotency_key="history-second")
+ second_receipt = gate_api.decide(
+ "1:timmy", second["id"], expected_revision=1, decision="hold",
+ reason="Needs another mobile pass", override_reason="", checklist={},
+ idempotency_key="history-hold",
+ )
+
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ listing = await client.get("/api/v1/human-gates?state=all")
+ receipt = await client.get(
+ f"/api/v1/human-gate-receipts/{second_receipt['receipt_id']}"
+ )
+
+ assert listing.status_code == 200
+ assert listing.headers["cache-control"] == "no-store"
+ assert [item["state"] for item in listing.json()["items"]] == ["held", "released"]
+ assert listing.json()["items"][0]["receipt_id"] == second_receipt["receipt_id"]
+ assert receipt.json()["reason"] == "Needs another mobile pass"
+ assert gate_api.list("2:timmy", state="all")["items"] == []
+ with pytest.raises(LookupError):
+ gate_api.receipt("2:timmy", second_receipt["receipt_id"])
+
+
@pytest.mark.anyio
async def test_decision_requires_fresh_authorization_bound_to_the_exact_gate(monkeypatch, gate_api):
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-authorized")
diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py
index 3f1ec2c..c21e68b 100644
--- a/tests/test_human_gates_frontend.py
+++ b/tests/test_human_gates_frontend.py
@@ -136,6 +136,60 @@ const gates=createHumanGates({storage,getLogin:()=> 'timmy',isOnline:()=>true,no
assert "Inbox zero" in output["zero"]["html"]
+def test_live_history_lists_decisions_and_opens_the_durable_receipt_without_caching():
+ output = run_node(r"""
+const writes=[]; const requests=[];
+const nodes={
+ count:{},list:{innerHTML:''},status:{textContent:''},panel:{},detail:{innerHTML:''},
+ pendingTab:{setAttribute(){},disabled:false},historyTab:{setAttribute(){},disabled:false},
+};
+const pending={pending_count:1,items:[{id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1,checks:[]}]};
+const history={pending_count:1,items:[
+ {id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1},
+ {id:'held',title:'Held candidate',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2'},
+ {id:'released',title:'Released candidate',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-1'},
+]};
+const detail={id:'held',title:'Held candidate',project:'stackchain/dashboard',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2',history:[{action:'held',at:200}]};
+const receipt={receipt_id:'receipt-2',gate_id:'held',candidate_hash:'bbb',state:'held',decided_at:200,reason:'Needs mobile evidence',override_reason:'',checklist:{}};
+const gates=createHumanGates({
+ storage:{getItem:()=>null,setItem:(key,value)=>writes.push({key,value})},
+ getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''},
+ fetchJson:async path=>{
+ requests.push(path);
+ if(path==='api/v1/human-gates?state=all') return history;
+ if(path==='api/v1/human-gates/held') return detail;
+ if(path==='api/v1/human-gate-receipts/receipt-2') return receipt;
+ return pending;
+ },
+});
+(async()=>{
+ await gates.load(); const writesAfterPending=writes.length;
+ const decisions=await gates.showHistory(); const historyHtml=nodes.list.innerHTML;
+ await gates.selectHistory('held');
+ process.stdout.write(JSON.stringify({
+ writesAfterPending,writesAfterHistory:writes.length,requests,decisions,
+ historyHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent,
+ }));
+})();
+""")
+ assert output["writesAfterPending"] == 1
+ assert output["writesAfterHistory"] == 1
+ assert output["requests"] == [
+ "api/v1/human-gates",
+ "api/v1/human-gates?state=all",
+ "api/v1/human-gates/held",
+ "api/v1/human-gate-receipts/receipt-2",
+ ]
+ assert [item["id"] for item in output["decisions"]] == ["held", "released"]
+ assert "Waiting" not in output["historyHtml"]
+ assert "Held" in output["historyHtml"]
+ assert "Released" in output["historyHtml"]
+ assert "Needs mobile evidence" in output["detailHtml"]
+ assert "receipt-2" in output["detailHtml"]
+ assert "data-gate-decision" not in output["detailHtml"]
+ assert output["status"] == "2 past Human Gate decisions."
+
+
def test_queue_changes_publish_mobile_counts_and_authoritative_decision_completion():
output = run_node(r"""
const changes=[];
@@ -332,7 +386,7 @@ def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
worker = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in worker
+ assert "stackchain-dashboard-shell-v148" in worker
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
@@ -569,7 +623,27 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
assert "counts.gate = queueCounts.gate" in dashboard
assert "gate:preparationItems.gate || []" in dashboard
- assert "stackchain-dashboard-shell-v147" in WORKER.read_text()
+ assert "stackchain-dashboard-shell-v148" in WORKER.read_text()
+
+
+def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell():
+ index = INDEX.read_text()
+ dashboard = DASHBOARD.read_text()
+ progressive = PROGRESSIVE.read_text()
+ css = CSS.read_text()
+
+ assert 'class="human-gate-views" role="group" aria-label="Human Gate view"' in index
+ assert 'id="human-gates-pending"' in index
+ assert 'id="human-gates-history"' in index
+ assert "pendingTab:qs('#human-gates-pending')" in dashboard
+ assert "historyTab:qs('#human-gates-history')" in dashboard
+ assert "humanGates.showHistory()" in dashboard
+ assert "card.addEventListener('click', () => selectHistory" in MODULE.read_text()
+ assert "pendingTab:query('#human-gates-pending')" in progressive
+ assert "historyTab:query('#human-gates-history')" in progressive
+ assert ".human-gate-views button{min-height:44px" in css
+ assert ".human-gate-history-card time" in css
+ assert "stackchain-dashboard-shell-v148" in WORKER.read_text()
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 5f8b9af..b61f4c4 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index dc5994a..e974e4b 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v147" in worker
+ assert "stackchain-dashboard-shell-v148" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 2e74685..fa57e35 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v147" in worker
+ assert "stackchain-dashboard-shell-v148" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index e25a48e..5d25484 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
- assert "stackchain-dashboard-shell-v147" in worker
+ assert "stackchain-dashboard-shell-v148" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py
index 7395860..3816479 100644
--- a/tests/test_mobile_insights.py
+++ b/tests/test_mobile_insights.py
@@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v147" in worker
+ assert "stackchain-dashboard-shell-v148" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py
index b9d508b..7547d29 100644
--- a/tests/test_mobile_start_day.py
+++ b/tests/test_mobile_start_day.py
@@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
- assert "stackchain-dashboard-shell-v147" in service_worker
+ assert "stackchain-dashboard-shell-v148" in service_worker
@pytest.mark.anyio
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 715c674b..15a12ab 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index a7c5897..9652147 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/progressive-live-snapshot.js'" in source
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -258,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -292,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1390,7 +1390,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index 42639b9..e3c0455 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
- assert "const CACHE = 'stackchain-dashboard-shell-v147';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v148';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 50e66f9..291bfd3 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v147" in source
+ assert "stackchain-dashboard-shell-v148" in source
assert "BASE + 'static/today-sync.js'" in source