Checks keep their width and refresh in place; live parts share one fetch a2ef53c5db26
Check summaries go through the addresses filter (a hash by its two ends, the whole on hover) and side panels wrap anything unbroken. The Checks section is a live fragment (the proposal's and the repository's topics); the inline script that reloaded the whole page on any check event is gone (a reload of a slow repository's page was in flight when a restart gave Andy a 502). hub.js gathers stale parts for a moment and fetches the page once to swap them all, so one event costs one render. Also: a Diffusion test read the timeline before the landed event was written. Closes #232
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 08:50 UTC
- Signature
- ✔ signed by Claude Code
- Hash
a2ef53c5db269fa8bc86bb8abb13bb10ccba916129a301ca8738cb4829ff6680- Depends on
- 26d0d091deb7 42fa95540468 67be8ac00c24 8e96499f2705 b64e2537d702 d23464c0ac60 de505df5cf8d f4d6ef418a96
- Depended on by
- nothing yet
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 13 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified assets/hub.js +36 −21
| … | 711 unchanged lines |
||
| 712 | 712 | })(); |
|
| 713 | 713 | ||
| 714 | 714 | // Live pages (docs/live.md, oxygenhub#204): elements marked |
|
| 715 | - | // data-live="<topic>" data-fragment="<name>" are fetched again from this |
|
| 716 | - | // page's own route (?fragment=<name>) when the hub says their topic |
|
| 717 | - | // changed, and swapped in. Someone typing inside one is not interrupted: |
|
| 715 | + | // data-live="<topic>" data-fragment="<name>" are refreshed from this page's |
|
| 716 | + | // own route when the hub says their topic changed (one fetch for all of |
|
| 717 | + | // them; ?fragment=<name> serves a single part to anyone else). Someone typing inside one is not interrupted: |
|
| 718 | 718 | // it is refreshed when they leave it. Without JavaScript or a stream the |
|
| 719 | 719 | // page is as it was; a reload shows everything. |
|
| 720 | 720 | (function () { |
|
| … | 1 unchanged lines |
||
| 722 | 722 | if (!marked.length || !window.EventSource || !window.fetch) return; |
|
| 723 | 723 | var topics = {}; |
|
| 724 | 724 | Array.prototype.forEach.call(marked, function (el) { topics[el.getAttribute("data-live")] = true; }); |
|
| 725 | - | var busy = {}; |
|
| 726 | 725 | var typingIn = function (el) { |
|
| 727 | 726 | var a = document.activeElement; |
|
| 728 | 727 | return a && el.contains(a) && (a.tagName === "TEXTAREA" || (a.tagName === "INPUT" && a.type !== "hidden")) && a.value; |
|
| 729 | 728 | }; |
|
| 730 | - | var refresh = function (el) { |
|
| 731 | - | var name = el.getAttribute("data-fragment"); |
|
| 732 | - | if (busy[name]) return; |
|
| 733 | - | if (typingIn(el)) { |
|
| 734 | - | el.addEventListener("focusout", function again() { |
|
| 735 | - | el.removeEventListener("focusout", again); |
|
| 736 | - | setTimeout(function () { refresh(el); }, 0); |
|
| 737 | - | }); |
|
| 738 | - | return; |
|
| 739 | - | } |
|
| 740 | - | busy[name] = true; |
|
| 741 | - | var url = new URL(location.href); |
|
| 742 | - | url.searchParams.set("fragment", name); |
|
| 743 | - | fetch(url.toString(), { credentials: "same-origin", headers: { "X-Fragment": name } }) |
|
| 729 | + | // Stale parts are gathered for a moment, then the page is fetched once |
|
| 730 | + | // and each part swapped from it: one render however many parts and |
|
| 731 | + | // events (a slow repository's page costs the server seconds each time). |
|
| 732 | + | var stale = [], timer = null, fetching = false; |
|
| 733 | + | var swapFrom = function (doc) { |
|
| 734 | + | var todo = stale; |
|
| 735 | + | stale = []; |
|
| 736 | + | todo.forEach(function (el) { |
|
| 737 | + | if (typingIn(el)) { |
|
| 738 | + | el.addEventListener("focusout", function again() { |
|
| 739 | + | el.removeEventListener("focusout", again); |
|
| 740 | + | refresh(el); |
|
| 741 | + | }); |
|
| 742 | + | return; |
|
| 743 | + | } |
|
| 744 | + | var name = el.getAttribute("data-fragment"); |
|
| 745 | + | var fresh = doc.querySelector('[data-fragment="' + name + '"]'); |
|
| 746 | + | if (fresh) el.innerHTML = fresh.innerHTML; |
|
| 747 | + | }); |
|
| 748 | + | }; |
|
| 749 | + | var flush = function () { |
|
| 750 | + | timer = null; |
|
| 751 | + | if (fetching || !stale.length) return; |
|
| 752 | + | fetching = true; |
|
| 753 | + | fetch(location.href, { credentials: "same-origin", headers: { "X-Fragment": "page" } }) |
|
| 744 | 754 | .then(function (r) { return r.ok ? r.text() : null; }) |
|
| 745 | 755 | .then(function (html) { |
|
| 746 | - | busy[name] = false; |
|
| 747 | - | if (html !== null && !typingIn(el)) el.innerHTML = html; |
|
| 756 | + | fetching = false; |
|
| 757 | + | if (html !== null) swapFrom(new DOMParser().parseFromString(html, "text/html")); |
|
| 758 | + | if (stale.length) timer = setTimeout(flush, 300); |
|
| 748 | 759 | }) |
|
| 749 | - | .catch(function () { busy[name] = false; }); |
|
| 760 | + | .catch(function () { fetching = false; }); |
|
| 761 | + | }; |
|
| 762 | + | var refresh = function (el) { |
|
| 763 | + | if (stale.indexOf(el) < 0) stale.push(el); |
|
| 764 | + | if (!timer) timer = setTimeout(flush, 300); |
|
| 750 | 765 | }; |
|
| 751 | 766 | var es = new EventSource("/live?topics=" + encodeURIComponent(Object.keys(topics).join(","))); |
|
| 752 | 767 | es.addEventListener("change", function (e) { |
|
| … | 11 unchanged lines |
modified crates/hub/tests/api.rs +17 −0
| … | 333 unchanged lines |
||
| 334 | 334 | assert_eq!(v["landable"], true, "{v}"); |
|
| 335 | 335 | let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&andy), None, None).await; |
|
| 336 | 336 | assert!(page.body.contains("42 tests"), "{}", page.body); |
|
| 337 | + | // A summary carrying whole hashes shows them by their ends, so the |
|
| 338 | + | // Checks panel keeps its width (a pin mismatch's message, 2026-10-16). |
|
| 339 | + | let (a, b) = ( |
|
| 340 | + | "6ea27b1d35037a4e4d0f6208b1ca99630df40888bd21fd55c8f8aba51dcdd7cb", |
|
| 341 | + | "f7495d7689da3ac5b68684f26fce28b91ce55e6b8131c5e484ad2aab90c3020f", |
|
| 342 | + | ); |
|
| 343 | + | let summary = format!("could not run: flow-init has BLAKE3 {a}, but {b} is pinned"); |
|
| 344 | + | let body = serde_json::json!({ "state": state, "name": "lint", "status": "failed", "summary": summary }).to_string(); |
|
| 345 | + | let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/checks", Some(&bob_rw), Some(&body)).await; |
|
| 346 | + | assert_eq!(st, StatusCode::CREATED, "{v}"); |
|
| 347 | + | let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&andy), None, None).await.body; |
|
| 348 | + | assert!(page.contains("6ea27b1d…1dcdd7cb") && page.contains(&format!("data-full=\"{a}\"")), "{page}"); |
|
| 349 | + | assert!(!page.contains(&format!("BLAKE3 {a},")), "never the whole hash inline"); |
|
| 350 | + | // The Checks section refreshes itself; nothing reloads the whole page. |
|
| 351 | + | assert!(!page.contains("location.reload()"), "no full reload on a check"); |
|
| 352 | + | let part = call(app, "GET", "/andy/demo/proposal/2?fragment=checks", None, Some(&andy), None, None).await.body; |
|
| 353 | + | assert!(part.contains("<h3>Checks</h3>") && part.contains("6ea27b1d…1dcdd7cb") && !part.contains("<html"), "{part}"); |
|
| 337 | 354 | // The check and the proposal reached the webhook too. |
|
| 338 | 355 | sqlx::query("UPDATE deliveries SET next_ms = 0").execute(&app.db).await.unwrap(); |
|
| 339 | 356 | oxygenhub::hooks::deliver_due(app).await; |
|
| … | 1136 unchanged lines |
modified crates/hub/tests/flow.rs +9 −1
| … | 3905 unchanged lines |
||
| 3906 | 3906 | } |
|
| 3907 | 3907 | let p = landed.expect("landed by itself once both halves were green"); |
|
| 3908 | 3908 | assert!(p.body.contains("lands its bumps itself"), "{}", p.body); |
|
| 3909 | - | let events = db::proposals::events(&h.app.db, p.id).await.unwrap(); |
|
| 3909 | + | // The state is written a moment before the timeline's event: wait for it. |
|
| 3910 | + | let mut events = vec![]; |
|
| 3911 | + | for _ in 0..100 { |
|
| 3912 | + | events = db::proposals::events(&h.app.db, p.id).await.unwrap(); |
|
| 3913 | + | if events.iter().any(|e| e.event == "landed") { |
|
| 3914 | + | break; |
|
| 3915 | + | } |
|
| 3916 | + | tokio::time::sleep(std::time::Duration::from_millis(20)).await; |
|
| 3917 | + | } |
|
| 3910 | 3918 | let landing = events.iter().find(|e| e.event == "landed").expect("a landing on the timeline"); |
|
| 3911 | 3919 | assert_eq!(landing.actor, "diffusion"); |
|
| 3912 | 3920 | assert!(landing.detail.contains("Diffusion, both halves green"), "{}", landing.detail); |
|
| … | 529 unchanged lines |
modified templates/proposal.html +3 −4
| … | 230 unchanged lines |
||
| 231 | 231 | {% if projects.is_empty() %}<p class="side-empty">None yet</p>{% else %}<ul class="plain small side-list">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %} <form class="inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 232 | 232 | {% if let Some(choices) = project_choices %}{% if choices.is_empty() && projects.is_empty() %}<p class="side-note"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% endif %} |
|
| 233 | 233 | </section> |
|
| 234 | - | <section class="side-sec"> |
|
| 234 | + | <section class="side-sec" data-live="{{ live_topic }} {{ repo_topic }}" data-fragment="checks"><!--frag:checks--> |
|
| 235 | 235 | <div class="side-head"><h3>Checks</h3></div> |
|
| 236 | 236 | <div id="checks"> |
|
| 237 | 237 | {% if checks.is_empty() %}<p class="side-empty">None reported on the projected state{% if !required_checks.is_empty() %}; required: {{ required_checks.join(", ") }}{% endif %}.</p>{% else %} |
|
| 238 | - | <ul class="plain">{% for c in checks %}<li><span class="sig{% if c.status == "passed" %} sig-valid{% else if c.status == "failed" %} sig-invalid{% else %} sig-unsigned{% endif %}">{% if c.status == "passed" %}✔{% else if c.status == "failed" %}✘{% else if c.status == "running" %}…{% else %}○{% endif %}</span> {% if c.url.is_empty() %}{{ c.name }}{% else %}<a href="{{ c.url }}">{{ c.name }}</a>{% endif %} <span class="mute small">{{ c.status }}{% if !c.summary.is_empty() %} · {{ c.summary }}{% endif %}{% if !c.seal.is_empty() %} · sealed{% endif %}</span></li>{% endfor %}</ul>{% endif %} |
|
| 238 | + | <ul class="plain">{% for c in checks %}<li><span class="sig{% if c.status == "passed" %} sig-valid{% else if c.status == "failed" %} sig-invalid{% else %} sig-unsigned{% endif %}">{% if c.status == "passed" %}✔{% else if c.status == "failed" %}✘{% else if c.status == "running" %}…{% else %}○{% endif %}</span> {% if c.url.is_empty() %}{{ c.name }}{% else %}<a href="{{ c.url }}">{{ c.name }}</a>{% endif %} <span class="mute small">{{ c.status }}{% if !c.summary.is_empty() %} · {{ c.summary|addresses }}{% endif %}{% if !c.seal.is_empty() %} · sealed{% endif %}</span></li>{% endfor %}</ul>{% endif %} |
|
| 239 | 239 | </div> |
|
| 240 | - | <script>(function(){if(!window.EventSource)return;var es=new EventSource("{{ repo.url }}/proposal/{{ p.number }}/events");es.addEventListener("check",function(){es.close();location.reload();});})();</script> |
|
| 241 | - | </section> |
|
| 240 | + | <!--/frag:checks--></section> |
|
| 242 | 241 | <section class="side-sec"> |
|
| 243 | 242 | <div class="side-head"><h3>Approvals</h3></div> |
|
| 244 | 243 | {% if approvals.is_empty() %}<p class="side-empty">None yet{% if requirements.required_approvals > 0 %}; {{ requirements.required_approvals }} sealed required{% endif %}.</p>{% else %} |
|
| … | 9 unchanged lines |
modified templates/style.css +3 −0
| … | 722 unchanged lines |
||
| 723 | 723 | placed in the card's grid. */ |
|
| 724 | 724 | .side-sections .side-sec { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 0 12px 10px; } |
|
| 725 | 725 | .side-sections .side-sec > * { grid-column: 1; } |
|
| 726 | + | /* A long hash or path in a side panel (a check's summary) wraps rather |
|
| 727 | + | than pushing the panel wider. */ |
|
| 728 | + | .side-sections .side-sec { min-width: 0; overflow-wrap: anywhere; } |
|
| 726 | 729 | .side-sections .side-sec > .side-head { display: contents; } |
|
| 727 | 730 | .side-sections .side-sec > .side-head > h3 { grid-column: 1 / -1; grid-row: 1; margin: 0 -12px 10px; padding: 8px 12px; background: var(--soft); border-bottom: 1px solid var(--line); border-radius: 6px 6px 0 0; } |
|
| 728 | 731 | .side-sections .side-sec > .side-head > details.menu { grid-column: 2; grid-row: 2; margin-left: 8px; } |
|
| … | 576 unchanged lines |