OXYGENHUB

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