constitution · epochs · watch · epoch 3

comparison

c_b00c261f06c2 (tommy-mor) vs c_b21d271b1b82 (tommy-mor)

download prompt · raw event · cmp_6af8a6a59f828e

council reasoning

openai/gpt-chat-latest · winner B · 6:1 · permalink

Commit B introduces a substantial new capability and supporting infrastructure: an extensible external URL foundation with on-demand GitHub resolution, new UI actions, backend write-path support for system ingests, URL normalization improvements, resolver state management, garden UI enhancements, recursive child ranking depth, extensive tests (including browser integration), and related documentation. It changes many components cohesively to enable a new feature set. Commit A is a focused bug fix that corrects vote comparison highlighting, adjusts ranking gradient behavior to operate per group instead of globally, improves vote history visualization, and adds targeted regression tests. It is valuable but much narrower in scope and impact than the architectural additions in B.

openai/gpt-5.3-chat · winner B · 9:1 · permalink

Commit B introduces a substantial new capability: an extensible external URL system with a full GitHub resolver, backend ingestion pipeline, UI controls, normalization logic, and extensive tests (including browser automation). It touches many core areas (state, API, DSL, HTML, URL identity, and async workers) and adds meaningful product functionality. Commit A is a focused UI/logic fix for vote highlighting with some styling and tests—useful but comparatively small in scope and impact.

openai/gpt-5.2-chat · winner B · 9:1 · permalink

Side B introduces a substantial new extensible URL foundation and GitHub external resolver system: new resolver module with API integration, cooldown logic, system ingest pathway, write command, UI actions, garden UI controls, normalization changes, extensive tests (unit + browser), and state wiring. This meaningfully expands platform capabilities. Side A mainly fixes vote compare highlighting and slider polarity with related CSS/JS tweaks and tests—valuable but comparatively incremental. Therefore B contributes far more overall functionality and architectural impact.

sides

A — c_b00c261f06c2 (tommy-mor)

message

[04b81f91] Fix vote compare highlighting

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 588cf62bcea608ed4ede363810f022a67b43b960..e180a0ca542a33e2300c0a4809e6b9cfee07ecfe 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -291,18 +291,17 @@ fn rank_list(
     label: &str,
     items: &[RankedItem],
     start_rank: usize,
-    total_ranked: usize,
-    ordinal_offset: usize,
     highlighted: &HashSet<ItemId>,
     tree: &GlobalTree,
 ) -> Markup {
+    let group_len = items.len();
     html! {
         @if !items.is_empty() {
             h3 class="rank-heading muted small" { (label) }
             ol class="rank-list" {
                 @for (i, r) in items.iter().enumerate() {
                     @let href = item_href(&r.item);
-                    @let style = rank_row_style(parent, ordinal_offset + i, total_ranked);
+                    @let style = rank_row_style(parent, i, group_len);
                     @let class = rank_row_class(&r.item, highlighted);
                     li class=(class)
                         data-rank-item=(r.item.as_str())
@@ -420,8 +419,6 @@ pub fn ranking_panel_with_highlights(
 
     let has_ranked = !ranked_groups.is_empty();
     let multi = ranked_groups.len() > 1;
-    let total_ranked: usize = ranked_groups.iter().map(|g| g.len()).sum();
-    let mut ordinal_offset = 0usize;
 
     html! {
         section id="ranking-panel" class="demo-panel" {
@@ -436,8 +433,7 @@ pub fn ranking_panel_with_highlights(
             } @else {
                 @for (gi, ranked) in ranked_groups.iter().enumerate() {
                     @let label = if multi { format!("Ranking group {}", gi + 1) } else { "Ranking".to_string() };
-                    (rank_list(item, &label, ranked, 1, total_ranked, ordinal_offset, highlighted, tree))
-                    @let _ = { ordinal_offset += ranked.len(); };
+                    (rank_list(item, &label, ranked, 1, highlighted, tree))
                 }
                 (unranked_list("Unranked", &unranked, highlighted, tree))
             }
@@ -520,7 +516,20 @@ pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoRespons
 
 #[cfg(test)]
 mod tests {
-    use super::SORTER_UI_JS;
+    use super::{rank_row_style, SORTER_UI_JS};
+    use crate::path_types::ItemId;
+
+    #[test]
+    fn rank_row_style_gradients_per_group_not_globally() {
+        let parent = ItemId::opaque("test-scope");
+        let first_in_four = rank_row_style(&parent, 0, 4);
+        let last_in_four = rank_row_style(&parent, 3, 4);
+        let first_in_two = rank_row_style(&parent, 0, 2);
+        let last_in_two = rank_row_style(&parent, 1, 2);
+        assert_eq!(first_in_four, first_in_two);
+        assert_eq!(last_in_four, last_in_two);
+        assert_ne!(first_in_four, last_in_four);
+    }
 
     #[test]
     fn vote_slider_left_position_favors_left_item() {
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
index 483c7ff8cd35266744957cf0087f2b5f76f1a237..30dab303b9d1e763a5b9261dffff13a778e3e4e3 100644
--- a/server/src/html/vote.rs
+++ b/server/src/html/vote.rs
@@ -19,7 +19,7 @@ use crate::{
     ui_action::UI_RPC_FIELD,
 };
 
-use super::{breadcrumb_path, item_href, layout};
+use super::{breadcrumb_path, layout};
 
 #[derive(Debug, Deserialize)]
 pub struct VoteQuery {
@@ -59,17 +59,6 @@ fn child_title(tree: &GlobalTree, id: &ItemId) -> String {
         .unwrap_or_else(|| display_label(id))
 }
 
-fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {
-    let l = ratio_left.max(0) as f64;
-    let r = ratio_right.max(0) as f64;
-    let sum = l + r;
-    if sum <= 0.0 {
-        50.0
-    } else {
-        (l / sum) * 100.0
-    }
-}
-
 fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {
     match (v.a.as_str(), v.b.as_str()) {
         (a, b) if a == page_left.as_str() && b == page_right.as_str() => {
@@ -94,6 +83,37 @@ fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec<VoteData
         .collect()
 }
 
+/// HUD `data-winner` value: which side the ratio favours on this page.
+fn winner_side(r_left: i32, r_right: i32) -> &'static str {
+    if r_left > r_right {
+        "left"
+    } else if r_right > r_left {
+        "right"
+    } else {
+        "even"
+    }
+}
+
+fn winner_text(r_left: i32, r_right: i32) -> &'static str {
+    match winner_side(r_left, r_right) {
+        "left" => "left wins",
+        "right" => "right wins",
+        _ => "tie",
+    }
+}
+
+/// Map stored ratios to the live slider position (0 = full left, 100 = full right).
+/// Matches `sorter_ui.js`: `left = 100 - v`, `right = v`.
+fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 {
+    let l = r_left.max(0) as f64;
+    let r = r_right.max(0) as f64;
+    let sum = l + r;
+    if sum <= 0.0 {
+        return 50;
+    }
+    ((r / sum) * 100.0).round().clamp(0.0, 100.0) as i32
+}
+
 fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {
     let mut votes = edge_votes(group, left, right);
     votes.sort_by(|a, b| b.ts.cmp(&a.ts));
@@ -106,17 +126,26 @@ fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right
             h3 class="vote-edge-history-title" {
                 "votes on this pair"
             }
+            p class="muted small vote-edge-legend" {
+                (format!("left: {legend_left} — right: {legend_right}"))
+            }
             ul class="vote-edge-history" {
                 @for v in &votes {
                     @let (r_left, r_right) = ratios_for_page(v, left, right);
-                    @let pct = ratio_pct(r_left, r_right);
+                    @let slider_val = slider_value_from_ratios(r_left, r_right);
+                    @let side = winner_side(r_left, r_right);
+                    @let label = winner_text(r_left, r_right);
                     li class="vote-edge-history-row" {
                         div class="vote-edge-meta" {
                             span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) }
+                            span class="vote-edge-winner muted small" { " · " (label) }
                         }
-                        div class="ratio-bar vote-edge-bar" aria-hidden="true" {
-                            div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {}
-                            div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {}
+                        label class="vote-hud-slider vote-edge-slider" aria-hidden="true" {
+                            input type="range" class="vote-edge-range" min="0" max="100" value=(slider_val)
+                                data-winner=(side)
+                                style={(format!("--vote-slider-pct: {}%;", slider_val))}
+                                disabled
+                                tabindex="-1";
                         }
                     }
                 }
@@ -283,7 +312,6 @@ pub async fn vote_page(
                         span class="vote-compare-vs" { "vs" }
                         (vote_compare_item_card(&tree, &right, "vote-compare-right"))
                     }
-                    (vote_back_nav(&parent))
                     div id="vote-edge-history-region" {
                         (edge_history)
                     }
@@ -308,3 +336,66 @@ pub async fn vote_page(
     )
     .into_response()
 }
+
+#[cfg(test)]
+mod polarity_tests {
+    use super::*;
+    use crate::ranking::ranked_items;
+    use crate::reducer::GlobalTree;
+
+    fn id(s: &str) -> ItemId {
+        ItemId::parse(s).unwrap()
+    }
+
+    /// The page's left number must always equal the vote's weight for the
+    /// item shown on the left, regardless of which order the vote stored a/b.
+    #[test]
+    fn ratios_for_page_orients_to_page_left() {
+        let left = id("left_item");
+        let right = id("right_item");
+
+        // Stored a == page left: keep order.
+        let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+        assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));
+
+        // Stored a == page right: swap so left stays left.
+        let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();
+        assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));
+    }
+
+    #[test]
+    fn winner_side_follows_larger_ratio() {
+        assert_eq!(winner_side(9, 1), "left");
+        assert_eq!(winner_side(1, 9), "right");
+        assert_eq!(winner_side(1, 1), "even");
+    }
+
+    #[test]
+    fn slider_value_matches_hud_mapping() {
+        assert_eq!(slider_value_from_ratios(9, 1), 10);
+        assert_eq!(slider_value_from_ratios(1, 4), 80);
+        assert_eq!(slider_value_from_ratios(1, 1), 50);
+    }
+
+    /// End-to-end polarity invariant: a vote that favours the LEFT item (higher
+    /// `ratio_left`, recorded as the RPC's `a`) must make that item rank #1.
+    /// This is the property the UI must preserve: sliding left => left wins.
+    #[test]
+    fn sliding_left_makes_left_item_win_ranking() {
+        let parent = id("scope");
+        let left = id("left_item");
+        let right = id("right_item");
+
+        // Slider dragged left yields e.g. 9:1 with a = left item.
+        let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+        let mut tree = GlobalTree::new();
+        tree.apply_vote(&parent, vote);
+
+        let group = &tree.get(&parent).unwrap().local_ranking;
+        let ranked = ranked_items(group);
+        assert_eq!(
+            ranked[0].item, left,
+            "left item should rank first when ratio favours the left"
+        );
+    }
+}
diff --git a/server/static/sorter.css b/server/static/sorter.css
index 5da967e8bfddc2bc34662fced841860f63f1ef36..bdd3d931365703705be08537a07f3ccaf975d3ea 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -124,7 +124,7 @@ code {
   z-index: 1;
   box-shadow:
     inset 0 0 0 3px var(--accent),
-    inset 0 0 0 5px color-mix(in oklch, var(--rank-fg) 70%, transparent);
+    inset 0 0 0 5px color-mix(in oklch, var(--rank-fg, var(--fg)) 70%, transparent);
   filter: saturate(1.15) brightness(1.04);
 }
 
@@ -320,7 +320,7 @@ h1 {
   grid-template-columns: var(--vote-ratio-col) minmax(0, 1fr) var(--vote-actions-col);
   gap: 0.5rem 0.65rem;
   align-items: center;
-  --vote-ratio-col: 3.25rem;
+  --vote-ratio-col: 12.5rem;
   --vote-actions-col: 11.5rem;
 }
 
@@ -332,7 +332,7 @@ h1 {
   margin: 0;
   width: var(--vote-ratio-col);
   min-width: var(--vote-ratio-col);
-  text-align: center;
+  text-align: right;
   white-space: nowrap;
 }
 
@@ -474,6 +474,16 @@ h1 {
   --vote-track-muted: color-mix(in oklch, var(--muted) 55%, var(--bg));
 }
 
+/* Center-anchored fill: the accent grows from the midpoint toward the thumb,
+   so the highlighted mass always sits on the *winning* side (the side the
+   thumb was dragged toward). Left winning => accent on the left half. */
+.vote-hud-slider input[type="range"][data-winner="even"]::-webkit-slider-runnable-track {
+  height: 0.65rem;
+  border-radius: 0;
+  border: 2px solid var(--border);
+  background: var(--vote-track-muted);
+}
+
 .vote-hud-slider input[type="range"][data-winner="left"]::-webkit-slider-runnable-track {
   height: 0.65rem;
   border-radius: 0;
@@ -483,7 +493,9 @@ h1 {
     var(--vote-track-muted) 0%,
     var(--vote-track-muted) var(--vote-slider-pct, 50%),
     var(--accent) var(--vote-slider-pct, 50%),
-    var(--accent) 100%
+    var(--accent) 50%,
+    var(--vote-track-muted) 50%,
+    var(--vote-track-muted) 100%
   );
 }
 
@@ -493,7 +505,9 @@ h1 {
   border: 2px solid var(--border);
   background: linear-gradient(
     to right,
-    var(--accent) 0%,
+    var(--vote-track-muted) 0%,
+    var(--vote-track-muted) 50%,
+    var(--accent) 50%,
     var(--accent) var(--vote-slider-pct, 50%),
     var(--vote-track-muted) va

… preview truncated; 3,536 characters omitted

download full diff A

B — c_b21d271b1b82 (tommy-mor)

message

[06b48801] Implement extensible URL foundation (#145)

* Implement extensible URL foundation

Co-authored-by: tommy <thmorriss@gmail.com>

* Make query params non-identity by default

Co-authored-by: tommy <thmorriss@gmail.com>

* Fix external href helper test scope

Co-authored-by: tommy <thmorriss@gmail.com>

* Avoid broken external previews for blocked hosts

Co-authored-by: tommy <thmorriss@gmail.com>

* Clarify external empty state copy

Co-authored-by: tommy <thmorriss@gmail.com>

* Add on-demand GitHub external resolver

Co-authored-by: tommy <thmorriss@gmail.com>

* Add fenced JSON bodies for GitHub resolver

Co-authored-by: tommy <thmorriss@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

diff preview

diff --git a/agents.md b/agents.md
index 59bd2174f4d2f972a45123fe10d408aa5881ee93..c66f33789441eea193b4354fce4c03b7fffdd639 100644
--- a/agents.md
+++ b/agents.md
@@ -55,6 +55,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma
 | Ingests, grants, rooms, identity tokens, agent binds, redactions, etc. | **JSONL** | Appended in `server/src/api/rpc.rs`, `server/src/api/auth.rs` (and related paths) before updating `ReducerState` |
 | **`RoomMintInvite` links** | **RAM only** | `AppState.invites` — not appended as `InviteMinted` today; **lost on restart** (`server/src/state.rs`, `server/src/api/rpc.rs`). Event types `InviteMinted` / `InviteRedeemed` exist for replay and a possible future persisted mint (`server/src/reducer.rs`). |
 | **OAuth / pending sessions** | **RAM only** | `AppState.pending_sessions` (`server/src/state.rs`, `server/src/api/auth.rs`) |
+| **External resolver cooldowns** | **RAM only** | `AppState.resolver_runs` — debounce/rate-limit guard for on-demand resolver buttons. Resolver results themselves are durable synthetic `Ingest` events in `events.jsonl`. |
 | **Reducer projection** | **Derived** | Rebuilt from log on startup; not separately persisted |
 
 If you add a new ephemeral map or start persisting something that was RAM-only, **update this table and the code comments** (`server/src/state.rs` is a good anchor).
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 7cbda3876451687aa7a55547fc06bbe86ac9d260..cd501ba6d4d656eabad03afed4583efdf885695d 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -18,14 +18,15 @@ use crate::{
         rpc::{rpc_post_redact, rpc_post_with_bearer, rpc_room_delete},
     },
     canonical_path::canonicalize_tag,
+    external_resolver::resolve_github_children,
+    html::vote_compare_post_success_js,
     html::{
-        fragment_new_thread_slot, login_to_post_hint_markup,
-        parse_html_ui_from_form, room_members_section_markup, thread_feed_html,
-        thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post,
-        thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room,
-        user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,
+        fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form,
+        room_members_section_markup, thread_feed_html, thread_feed_html_for_room,
+        thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full,
+        thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room,
+        HtmlUiAction, JsBuilder, ThreadNav,
     },
-    html::vote_compare_post_success_js,
     reducer::{scope_from_room_wire, ScopeId},
     state::AppState,
 };
@@ -104,26 +105,35 @@ async fn dispatch_ui_action(
                 )
                 .into_response();
             }
-            match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
-                Ok(RpcResult::PostOk { .. }) => {
-                    post_success_response(
-                        state,
-                        &room,
-                        &thread_tag,
-                        error_target.as_ref(),
-                        form_id.as_ref(),
-                        Some(session.username.as_str()),
-                    )
-                    .await
-                    .into_response()
-                }
+            match rpc_post_with_bearer(
+                state,
+                &session.bearer,
+                room.clone(),
+                thread_tag.clone(),
+                text,
+            )
+            .await
+            {
+                Ok(RpcResult::PostOk { .. }) => post_success_response(
+                    state,
+                    &room,
+                    &thread_tag,
+                    error_target.as_ref(),
+                    form_id.as_ref(),
+                    Some(session.username.as_str()),
+                )
+                .await
+                .into_response(),
                 Ok(_) => form_js_error(
                     error_target.as_ref(),
                     "unexpected response",
                     "Post did not return PostOk.",
                 )
                 .into_response(),
-                Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(),
+                Err((msg, hint)) => {
+                    form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(""))
+                        .into_response()
+                }
             }
         }
         HtmlUiAction::CheckIngest {
@@ -150,9 +160,19 @@ async fn dispatch_ui_action(
                 return js_clear_errors(&form_error_target(error_target.as_ref())).into_response();
             }
             match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await {
-                Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(),
-                Ok(_) => form_js_error(error_target.as_ref(), "unexpected response", "Check did not return CheckOk.").into_response(),
-                Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(),
+                Ok(RpcResult::CheckOk { .. }) => {
+                    js_clear_errors(&form_error_target(error_target.as_ref())).into_response()
+                }
+                Ok(_) => form_js_error(
+                    error_target.as_ref(),
+                    "unexpected response",
+                    "Check did not return CheckOk.",
+                )
+                .into_response(),
+                Err((msg, hint)) => {
+                    form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(""))
+                        .into_response()
+                }
             }
         }
         HtmlUiAction::VoteComparePost {
@@ -167,7 +187,11 @@ async fn dispatch_ui_action(
             form_action,
         } => {
             if form_action != "/ui" {
-                return (StatusCode::BAD_REQUEST, "invalid vote_compare_post form_action").into_response();
+                return (
+                    StatusCode::BAD_REQUEST,
+                    "invalid vote_compare_post form_action",
+                )
+                    .into_response();
             }
             let Some(session) = session else {
                 return js_redirect("/login").into_response();
@@ -195,23 +219,15 @@ async fn dispatch_ui_action(
             let left_id = match crate::path_types::ItemId::parse(left_item.trim()) {
                 Some(i) => i.normalized_storage(),
                 None => {
-                    return form_js_error(
-                        err_tgt.as_ref(),
-                        "bad item",
-                        "Invalid left item path.",
-                    )
-                    .into_response();
+                    return form_js_error(err_tgt.as_ref(), "bad item", "Invalid left item path.")
+                        .into_response();
                 }
             };
             let right_id = match crate::path_types::ItemId::parse(right_item.trim()) {
                 Some(i) => i.normalized_storage(),
                 None => {
-                    return form_js_error(
-                        err_tgt.as_ref(),
-                        "bad item",
-                        "Invalid right item path.",
-                    )
-                    .into_response();
+                    return form_js_error(err_tgt.as_ref(), "bad item", "Invalid right item path.")
+                        .into_response();
                 }
             };
             let mut rl = ratio_left.trim().parse::<i32>().unwrap_or(0).max(0);
@@ -231,7 +247,15 @@ async fn dispatch_ui_action(
                 right_id.as_str()
             );
 
-            match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
+            match rpc_post_with_bearer(
+                state,
+                &session.bearer,
+                room.clone(),
+                thread_tag.clone(),
+                text,
+            )
+            .await
+            {
                 Ok(RpcResult::PostOk {
                     post_id,
                     post_index,
@@ -277,7 +301,10 @@ async fn dispatch_ui_action(
                     "Post did not return PostOk.",
                 )
                 .into_response(),
-                Err((msg, hint)) => form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(),
+                Err((msg, hint)) => {
+                    form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or(""))
+                        .into_response()
+                }
             }
         }
         HtmlUiAction::SetGardenPin {
@@ -288,7 +315,11 @@ async fn dispatch_ui_action(
             form_action,
         } => {
             if form_action != "/ui" {
-                return (StatusCode::BAD_REQUEST, "invalid set_garden_pin form_action").into_response();
+                return (
+                    StatusCode::BAD_REQUEST,
+                    "invalid set_garden_pin form_action",
+                )
+                    .into_response();
             }
             let next_path = sanitize_garden_pin_next(&next);
             use crate::html::{encode_pin_cookie_value, GARDEN_PIN_COOKIE};
@@ -301,7 +332,11 @@ async fn dispatch_ui_action(
             if room.is_empty() {
                 return (StatusCode::BAD_REQUEST, "missing room").into_response();
             }
-            let Some(raw) = item_storage.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) else {
+            let Some(raw) = item_storage
+                .as_ref()
+                .map(|s| s.trim().to_string())
+                .filter(|s| !s.is_empty())
+            else {
                 return (StatusCode::BAD_REQUEST, "missing item").into_response();
             };
             let Some(item) = ItemId::parse(&raw) else {
@@ -309,16 +344,65 @@ async fn dispatch_ui_action(
             };
             let item = item.normalized_storage();
             let val = encode_pin_cookie_value(&room, item.as_str());
-            let cookie = format!("{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000");
+            let cookie =
+                format!("{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000");
             redirect_with_pin_cookie(&cookie, &next_path)
         }
+        HtmlUiAction::ResolveExternal {
+            room_wire,
+            item_storage,
+            mode,
+            next,
+            form_action,
+        } => {
+            if form_action != "/ui" {
+                return (
+                    StatusCode::BAD_REQUEST,
+                    "invalid resolve_external form_action",
+                )
+                    .into_response();
+            }
+            let Some(session) = session else {
+                return js_redirect("/login").into_response();
+            };
+            let room = room_wire.trim();
+            if room.is_empty() {
+                return ui_js_warn("missing room").into_response();
+            }
+            let reduced = state.reduced.read().await;
+            if matches!(scope_from_room_wire(room), ScopeId::Room(_)) {
+                if !user_can_post_room(&reduced, room, &session.username) {
+                    drop(reduced);
+                    return ui_js_warn("forbidden").into_response();
+                }
+            }
+            drop(reduced);
+
+            let Some(item) = crate::path_types::ItemId::parse(item_storage.trim()) else {
+                return ui_js_warn("bad item").into_response();
+            };
+            let target = if mode.trim() == "siblings" {
+                m

… preview truncated; 74,788 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.