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 |