OXYGENHUB

Live pages L3: the proposal page updates itself b5a0a69b3622

The timeline (comments, reviews, assistant answers and a pending
'... is answering' placeholder from the assistant gate, events, the
outcome) listens on the proposal's topic; the Landing section on it and the
repository's (checks). ?fragment=timeline|landing renders the page through
its own route and returns the part between <!--frag:NAME--> markers
(web::fragment), so permissions and markup are the page's. Comments, an
assistant asked / answering / not answering now publish on the proposal's
topic too. hub.js takes several topics per element. Tests: fragment
cutting; the page's markers, a comment heard on the topic, both fragments
and an unknown one. docs/live.md 5a.

Closes #205
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 05:50 UTC
Signature
✔ signed by Claude Code
Hash
b5a0a69b36221da082d37cbad7b01384f24dfa203a5ae6cc22e8c212d077d358
Depends on
38833ed78707 4f37afcc3583 67be8ac00c24 7d2eb590051f 8e96499f2705 9df855f29948 a1b9869565aa c0e39717895d d23464c0ac60 de505df5cf8d e71846aa07b9 ffb67a81188c
Depended on by
33d98147511b 8a48dd830178
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
27 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified assets/hub.js +4 −2
…
720 unchanged lines
721 721
  var marked = document.querySelectorAll("[data-live][data-fragment]");
722 722
  if (!marked.length || !window.EventSource || !window.fetch) return;
723 723
  var topics = {};
724 -
  Array.prototype.forEach.call(marked, function (el) { topics[el.getAttribute("data-live")] = true; });
724 +
  // data-live may name several topics, separated by spaces.
725 +
  var topicsOf = function (el) { return el.getAttribute("data-live").split(/\s+/).filter(Boolean); };
726 +
  Array.prototype.forEach.call(marked, function (el) { topicsOf(el).forEach(function (t) { topics[t] = true; }); });
725 727
  var busy = {};
726 728
  var typingIn = function (el) {
727 729
    var a = document.activeElement;
…
25 unchanged lines
753 755
    var d;
754 756
    try { d = JSON.parse(e.data); } catch (err) { return; }
755 757
    Array.prototype.forEach.call(marked, function (el) {
756 -
      if (el.getAttribute("data-live") === d.topic) refresh(el);
758 +
      if (topicsOf(el).indexOf(d.topic) >= 0) refresh(el);
757 759
    });
758 760
  });
759 761
  // Missed more than the hub keeps: everything marked is fetched again.
…
4 unchanged lines
modified crates/hub/src/assistants.rs +12 −0
…
736 unchanged lines
737 737
    fn release(&mut self, proposal_id: i64, name: &str) {
738 738
        self.busy.remove(&(proposal_id, name.to_lowercase()));
739 739
    }
740 +
741 +
    /// The assistants answering on a proposal now (lower-case names, sorted).
742 +
    pub fn answering(&self, proposal_id: i64) -> Vec<String> {
743 +
        let mut v: Vec<String> = self.busy.iter().filter(|(p, _)| *p == proposal_id).map(|(_, n)| n.clone()).collect();
744 +
        v.sort();
745 +
        v
746 +
    }
740 747
}
741 748
742 749
/// What a reviewer is told about a proposal: its title, description,
…
120 unchanged lines
863 870
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
864 871
    let pool = &app.db;
865 872
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} to review", a.name), "").await.ok();
873 +
    app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
866 874
    crate::db::accounts::audit(
867 875
        pool,
868 876
        Some(by),
…
24 unchanged lines
893 901
            }
894 902
            Err(why) => {
895 903
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
904 +
                app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
896 905
            }
897 906
        }
898 907
    });
…
46 unchanged lines
945 954
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
946 955
    let pool = &app.db;
947 956
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} for a summary", a.name), "").await.ok();
957 +
    app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
948 958
    crate::db::accounts::audit(
949 959
        pool,
950 960
        Some(by),
…
10 unchanged lines
961 971
        match outcome {
962 972
            Ok(body) => {
963 973
                crate::db::proposals::add_comment_via(&app.db, p.id, by.id, &by.username, &body, &a.name).await.ok();
974 +
                app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "comment");
964 975
            }
965 976
            Err(why) => {
966 977
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
978 +
                app.events.live.publish(&crate::events::topic::proposal(row.id, p.number), "assistant");
967 979
            }
968 980
        }
969 981
    });
…
73 unchanged lines
modified crates/hub/src/web/mod.rs +25 −0
…
785 unchanged lines
786 786
        }
787 787
    }
788 788
}
789 +
790 +
/// The part of a rendered page between `<!--frag:NAME-->` and
791 +
/// `<!--/frag:NAME-->` (live pages, docs/live.md): what a page fetches again
792 +
/// when its topic changes. `None` when the page has no such part.
793 +
pub fn fragment(html: &str, name: &str) -> Option<String> {
794 +
    if !name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-') || name.is_empty() {
795 +
        return None;
796 +
    }
797 +
    let open = format!("<!--frag:{name}-->");
798 +
    let close = format!("<!--/frag:{name}-->");
799 +
    let start = html.find(&open)? + open.len();
800 +
    let end = start + html[start..].find(&close)?;
801 +
    Some(html[start..end].to_string())
802 +
}
803 +
804 +
#[cfg(test)]
805 +
mod fragment_tests {
806 +
    #[test]
807 +
    fn a_fragment_is_cut_from_the_page_by_its_markers() {
808 +
        let page = "<p>a</p><div data-fragment=\"x\"><!--frag:x--><b>inside</b><!--/frag:x--></div>";
809 +
        assert_eq!(super::fragment(page, "x").as_deref(), Some("<b>inside</b>"));
810 +
        assert_eq!(super::fragment(page, "y"), None);
811 +
        assert_eq!(super::fragment(page, "x--><b"), None, "names are plain");
812 +
    }
813 +
}
modified crates/hub/src/web/proposals.rs +26 −2
…
661 unchanged lines
662 662
    shell: Shell,
663 663
    repo: RepoShell,
664 664
    p: pdb::ProposalRow,
665 +
    /// Live topics (oxygenhub#205): the proposal's and its repository's
666 +
    /// (checks arrive on the latter).
667 +
    live_topic: String,
668 +
    repo_topic: String,
669 +
    /// Assistants answering now: shown as pending until they do.
670 +
    pending: Vec<String>,
665 671
    /// Issues it closes, from its description and its changes.
666 672
    closes: Vec<super::chain::Link>,
667 673
    /// Projects the viewer may add it to.
…
64 unchanged lines
732 738
    error: Option<String>,
733 739
    #[serde(default)]
734 740
    w: Option<String>,
741 +
    /// Only this part of the page (live pages, oxygenhub#205).
742 +
    #[serde(default)]
743 +
    fragment: Option<String>,
735 744
}
736 745
737 746
/// The page assembler every action ends in; its arguments are the route
…
15 unchanged lines
753 762
        .await
754 763
        .map_err(|e| Error::from_db(shell.clone(), e))?
755 764
        .ok_or_else(|| Error::not_found(shell.clone()))?;
765 +
    let p_id = p.id;
756 766
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
757 767
    // A proposal whose changes all reached the target landed by another route.
758 768
    if loaded.landed_already && loaded.row.is_open() {
…
91 unchanged lines
850 860
    }
851 861
    dated.sort_by_key(|(at, _)| *at);
852 862
    let timeline: Vec<Entry> = dated.into_iter().map(|(_, e)| e).collect();
863 +
    let pending = app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).answering(p_id);
853 864
    Ok(Page(ProposalPage {
865 +
        live_topic: crate::events::topic::proposal(row.id, number),
866 +
        repo_topic: crate::events::topic::repo(row.id),
867 +
        pending,
854 868
        can_triage: role >= Role::Triage,
855 869
        timeline,
856 870
        closes,
…
62 unchanged lines
919 933
    viewer: Viewer,
920 934
    Path((owner, name, number)): Path<(String, String, i64)>,
921 935
    Query(q): Query<DetailQuery>,
922 -
) -> Result<Page<ProposalPage>, Error> {
936 +
) -> Result<Response, Error> {
923 937
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
924 938
    let mut page =
925 939
        detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), q.notice, q.error).await?;
…
2 unchanged lines
928 942
        page.0.files =
929 943
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
930 944
        page.0.ignore_ws = true;
945 +
    }
946 +
    // A live page asks for one part again (oxygenhub#205): rendered by the
947 +
    // same route, so the same access, and cut from the whole.
948 +
    if let Some(name) = q.fragment.as_deref() {
949 +
        let html = page.0.render().map_err(|e| Error::bad(page.0.shell.clone(), e.to_string()))?;
950 +
        return Ok(match super::fragment(&html, name) {
951 +
            Some(part) => ([(axum::http::header::CACHE_CONTROL, "no-store")], axum::response::Html(part)).into_response(),
952 +
            None => (axum::http::StatusCode::NOT_FOUND, "no such fragment").into_response(),
953 +
        });
931 954
    }
932 -
    Ok(page)
955 +
    Ok(page.into_response())
933 956
}
934 957
935 958
// ---------------------------------------------------------------- actions
…
46 unchanged lines
982 1005
    )
983 1006
    .await
984 1007
    .map_err(|e| Error::from_db(shell.clone(), e))?;
1008 +
    app.events.live.publish(&crate::events::topic::proposal(row.id, number), "comment");
985 1009
    Ok(back(&owner, &name, number, tab, None, None))
986 1010
}
987 1011
…
775 unchanged lines
modified crates/hub/tests/proposals.rs +25 −0
…
183 unchanged lines
184 184
    let c = db::proposals::comments(&app.db, 1).await.unwrap();
185 185
    assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced");
186 186
187 +
    // Live (oxygenhub#205): the page marks what updates itself; a comment
188 +
    // is heard on the proposal's topic; each part can be fetched alone.
189 +
    let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
190 +
    let topic = oxygenhub::events::topic::proposal(demo.id, 1);
191 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
192 +
    assert!(page.contains(&format!("data-live=\"{topic}\" data-fragment=\"timeline\"")), "the timeline is live");
193 +
    assert!(page.contains(&format!("data-live=\"{topic} repo:{}\" data-fragment=\"landing\"", demo.id)), "landing hears checks too");
194 +
    let csrf_c = csrf_of(&page);
195 +
    act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_c}&body=heard+live")).await;
196 +
    let heard = match app.events.live.since(0, std::slice::from_ref(&topic)) {
197 +
        oxygenhub::events::Replay::Events(e) => e.into_iter().map(|e| e.kind).collect::<Vec<_>>(),
198 +
        oxygenhub::events::Replay::Reload => vec![],
199 +
    };
200 +
    assert!(heard.contains(&"comment".to_string()), "{heard:?}");
201 +
    let part = get(app, "/andy/demo/proposal/1?fragment=timeline", Some(&bob)).await;
202 +
    assert_eq!(part.status, StatusCode::OK);
203 +
    assert!(
204 +
        part.body.contains("heard live") && !part.body.contains("<html") && !part.body.contains("Review this proposal"),
205 +
        "{}",
206 +
        part.body
207 +
    );
208 +
    let landing = get(app, "/andy/demo/proposal/1?fragment=landing", Some(&bob)).await;
209 +
    assert!(landing.body.contains("<h3>Landing</h3>") && !landing.body.contains("heard live"), "{}", landing.body);
210 +
    assert_eq!(get(app, "/andy/demo/proposal/1?fragment=nope", Some(&bob)).await.status, StatusCode::NOT_FOUND);
211 +
187 212
    // Requirements: one sealed approval and code owners.
188 213
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
189 214
    let csrf_a = csrf_of(&settings.body);
…
536 unchanged lines
modified docs/live.md +10 −0
…
111 unchanged lines
112 112
  `?fragment=<name>` from the page's own route and swap it in, waiting
113 113
  while someone types inside one.
114 114
115 +
- **L3 (done, #205):** the proposal page updates itself. Its timeline
116 +
  (comments, reviews, assistant answers, events, the landed or closed
117 +
  outcome, and a "… is answering" placeholder while an assistant works)
118 +
  listens on `repo:<id>/proposal:<n>`, and its Landing section on that and
119 +
  `repo:<id>` (check results). `?fragment=timeline|landing` renders the page
120 +
  through the same route and returns the part between
121 +
  `<!--frag:NAME-->` markers (`web::fragment`), so the update is the page.
122 +
  A comment, an assistant asked, answering or not answering, reviews, state
123 +
  changes and landings publish on the proposal's topic.
124 +
115 125
## 6. Order
116 126
117 127
| Step | What | Issue |
…
8 unchanged lines
modified templates/proposal.html +5 −2
…
99 unchanged lines
100 100
      {% endif %}
101 101
    </div>
102 102
  </div>
103 +
  <div class="tl-live" data-live="{{ live_topic }}" data-fragment="timeline"><!--frag:timeline-->
103 104
  {% for t in timeline %}
104 105
  {% if t.kind == "comment" %}{% let c = comments[t.index] %}
105 106
  <div class="tl-item">
…
30 unchanged lines
136 137
    <div class="tl-card outcome-card"><div><strong>Proposal closed</strong><p class="small">It was not landed{% if can_edit %}; reopen it below to carry on{% endif %}.</p></div></div>
137 138
  </div>
138 139
  {% endif %}
140 +
  {% for a in pending %}<p class="tl-event tl-pending" role="status"><span class="tl-dot"></span><img class="tl-mini" src="/avatar/agent/{{ a }}" alt="" width="18" height="18"> <strong>{{ a }}</strong> is answering… <span class="mute small">this updates by itself</span></p>{% endfor %}
141 +
  <!--/frag:timeline--></div>
139 142
  {% if let Some(me) = shell.user %}
140 143
  {% if can_edit && (p.state == "draft" || p.is_open() || p.state == "closed") %}<form id="pstate" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state" hidden><input type="hidden" name="_csrf" value="{{ shell.csrf }}"></form>{% endif %}
141 144
  <div class="tl-item">
…
37 unchanged lines
179 182
  {% endif %}
180 183
</div>
181 184
<aside class="side side-sections">
182 -
  <section class="side-sec">
185 +
  <section class="side-sec" data-live="{{ live_topic }} {{ repo_topic }}" data-fragment="landing"><!--frag:landing-->
183 186
    <div class="side-head"><h3>Landing</h3></div>
184 187
  {% if p.state == "landed" %}<p><span class="sig sig-valid">✔</span> landed{% if !p.landed_state.is_empty() %} at <code>{{ p.landed_state|short }}</code>{% endif %}</p>
185 188
  {% if !p.landed_by.is_empty() %}<p class="small mute">by <a href="/{{ p.landed_by }}">{{ p.landed_by }}</a>, {{ p.landed_via }}, {{ p.landed_ms|ago }}</p>{% endif %}
…
7 unchanged lines
193 196
      {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %}
194 197
    {% endif %}
195 198
  {% endif %}
196 -
  </section>
199 +
  <!--/frag:landing--></section>
197 200
  {% if !closes.is_empty() %}<section class="side-sec"><div class="side-head"><h3>Closes</h3></div><ul class="plain small side-list">{% for c in closes %}<li><a href="{{ c.url }}" class="kind-issue">issue #{{ c.number }}</a> {{ c.title }} <span class="mute">{{ c.state }}</span></li>{% endfor %}</ul></section>{% endif %}
198 201
  <section class="side-sec">
199 202
    <div class="side-head"><h3>Reviewers</h3>
…
54 unchanged lines