constitution · epochs · watch · epoch 3

comparison

c_48edc893c5b0 (tommy-mor) vs c_06fce70179bc (tommy-mor)

download prompt · raw event · cmp_aa4ed0e35f417b

council reasoning

~anthropic/claude-sonnet-latest · winner B · 6:4 · permalink

Both are substantial infra additions, but B fixes a real correctness/UX issue (fetch_entity returning immediately without waiting for the actual reddit fetch, replaced with a proper SSE stream with fetching/complete/error states and oneshot completion signaling), adds meaningful observability (tracing::debug throughout reddit worker), and updates the integration test to verify actual behavior end-to-end. A is a solid new feature (POST /ui plumbing and form templating) but is more scaffolding/wiring without fixing a functional gap, and B's SSE mechanism is the more durable, correctness-improving design.

~x-ai/grok-latest · winner A · 2:1 · permalink

A introduces lasting core design: form_template hole-filling, typed HtmlUiAction, a unified POST /ui dispatcher, and shared run_* web_post paths, plus the expandable new-thread UI—reusable infrastructure with tests. B’s SSE fetch pipeline and oneshot FetchJobResult are real improvements to one flow, but much of the diff is logging, module moves, lockfile/deps, and trimmed tests rather than broader foundational value.

openai/gpt-chat-latest · winner A · 3:2 · permalink

Side A introduces a substantial new browser UI command architecture: a unified POST /ui endpoint, structured HtmlUiAction parsing with JSON form templates, reusable web-post handlers, and lazy expansion of new-thread forms with permission checks. Side B adds useful SSE-based entity fetch streaming and refactors fetch UI into its own module, but much of the patch is infrastructure, logging, and code movement around an existing fetch feature, making its lasting architectural impact somewhat smaller.

sides

A — c_48edc893c5b0 (tommy-mor)

message

[3f35edab] progress

diff preview

diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index a10ce662105cff8fad949c6b83f7035ce79bed18..a986f706ea4b261cbaf004c02b4cf84184b41371 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -3,6 +3,7 @@ mod helpers;
 mod rpc;
 mod stream;
 mod validate;
+mod ui_html;
 mod web_post;
 
 pub use auth::{
@@ -33,6 +34,7 @@ pub use stream::{get_html_stream, get_stream};
 
 pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};
 
+pub use ui_html::post_ui_html;
 pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};
 
 #[cfg(test)]
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2b40a72059981d558768f73d189b991f3448c257
--- /dev/null
+++ b/server/src/api/ui_html.rs
@@ -0,0 +1,139 @@
+//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).
+
+use axum::{
+    body::Body,
+    extract::State,
+    http::{header, HeaderMap, StatusCode},
+    response::{IntoResponse, Response},
+    Form,
+};
+use axum_extra::extract::cookie::CookieJar;
+use std::collections::HashMap;
+
+use crate::{
+    api::{
+        auth::optional_principal,
+        web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},
+    },
+    html::{
+        fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,
+        parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,
+        ThreadNav,
+    },
+    state::AppState,
+};
+
+pub async fn post_ui_html(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    jar: CookieJar,
+    Form(form): Form<HashMap<String, String>>,
+) -> impl IntoResponse {
+    let action = match parse_html_ui_from_form(&form) {
+        Ok(a) => a,
+        Err(e) => return ui_js_warn(&e.to_string()).into_response(),
+    };
+
+    match action {
+        HtmlUiAction::PostIngest {
+            room,
+            thread_tag,
+            text,
+            error_target,
+            form_id,
+        } => {
+            run_post_web_ingest(
+                &state,
+                &headers,
+                &jar,
+                WebPostForm {
+                    room,
+                    thread_tag,
+                    text,
+                    error_target,
+                    form_id,
+                },
+            )
+            .await
+        }
+        HtmlUiAction::CheckIngest {
+            room,
+            thread_tag,
+            text,
+            error_target,
+            form_id,
+        } => {
+            run_check_web_ingest(
+                &state,
+                &headers,
+                &jar,
+                WebPostForm {
+                    room,
+                    thread_tag,
+                    text,
+                    error_target,
+                    form_id,
+                },
+            )
+            .await
+        }
+        HtmlUiAction::RedactPost { post_id } => {
+            run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await
+        }
+        HtmlUiAction::ExpandPublicNewThreadForm => {
+            let reduced = state.reduced.read().await;
+            let user = optional_principal(&headers, &jar, &reduced);
+            drop(reduced);
+            let markup = if user.is_some() {
+                fragment_public_new_thread_form(true)
+            } else {
+                login_to_post_hint_markup()
+            };
+            JsBuilder::new()
+                .morph_selector("#public-new-thread-ui-slot", markup)
+                .into_response()
+        }
+        HtmlUiAction::ExpandRoomNewThreadForm { room_wire } => {
+            let room_wire = room_wire.trim().to_string();
+            if room_wire.is_empty() {
+                return ui_js_warn("missing room").into_response();
+            }
+            let reduced = state.reduced.read().await;
+            let user = optional_principal(&headers, &jar, &reduced);
+            if !reduced.rooms.contains(&room_wire) {
+                drop(reduced);
+                return ui_js_warn("room not found").into_response();
+            }
+            if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {
+                drop(reduced);
+                return ui_js_warn("forbidden").into_response();
+            }
+            let can_post = user
+                .as_ref()
+                .map(|u| user_can_post_room(&reduced, &room_wire, u))
+                .unwrap_or(false);
+            drop(reduced);
+            let Some(nav) = ThreadNav::from_room_id(&room_wire) else {
+                return ui_js_warn("bad room").into_response();
+            };
+            let markup = if can_post {
+                fragment_room_new_thread_form(&nav, true)
+            } else {
+                login_to_post_hint_markup()
+            };
+            JsBuilder::new()
+                .morph_selector("#room-new-thread-ui-slot", markup)
+                .into_response()
+        }
+    }
+}
+
+fn ui_js_warn(msg: &str) -> Response {
+    use crate::html::js_string_literal;
+    let js = format!("console.warn({});", js_string_literal(msg));
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+        .body(Body::from(js))
+        .unwrap()
+}
diff --git a/server/src/api/web_post.rs b/server/src/api/web_post.rs
index a64010e382d3039821c836a5529adad0fe67cce5..265025f41ff1548d05b2d2d5d84202245388053f 100644
--- a/server/src/api/web_post.rs
+++ b/server/src/api/web_post.rs
@@ -222,8 +222,18 @@ pub async fn post_web_redact(
     jar: CookieJar,
     Form(form): Form<WebRedactForm>,
 ) -> impl IntoResponse {
+    run_post_web_redact(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`].
+pub(crate) async fn run_post_web_redact(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebRedactForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -239,8 +249,8 @@ pub async fn post_web_redact(
         return js_redirect("/login").into_response();
     };
 
-    match rpc_post_redact(&state, &headers, form.post_id).await {
-        Ok(RpcResult::RedactPostOk {}) => redact_success_response(&state).await.into_response(),
+    match rpc_post_redact(state, headers, form.post_id).await {
+        Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),
         Ok(_) => (StatusCode::BAD_REQUEST, "unexpected response").into_response(),
         Err((msg, hint)) => {
             let detail = hint.as_deref().unwrap_or("");
@@ -255,8 +265,18 @@ pub async fn post_web_ingest(
     jar: CookieJar,
     Form(form): Form<WebPostForm>,
 ) -> impl IntoResponse {
+    run_post_web_ingest(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).
+pub(crate) async fn run_post_web_ingest(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebPostForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -282,8 +302,8 @@ pub async fn post_web_ingest(
             .into_response();
     }
 
-    match rpc_post_with_bearer(&state, &bearer, room.clone(), thread_tag.clone(), text).await {
-        Ok(RpcResult::PostOk { .. }) => post_success_response(&state, &form, &headers, &jar)
+    match rpc_post_with_bearer(state, &bearer, room.clone(), thread_tag.clone(), text).await {
+        Ok(RpcResult::PostOk { .. }) => post_success_response(state, &form, headers, jar)
             .await
             .into_response(),
         Ok(_) => form_js_error(&form, "unexpected response", "Post did not return PostOk.").into_response(),
@@ -297,8 +317,18 @@ pub async fn check_web_ingest(
     jar: CookieJar,
     Form(form): Form<WebPostForm>,
 ) -> impl IntoResponse {
+    run_check_web_ingest(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).
+pub(crate) async fn run_check_web_ingest(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebPostForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -324,7 +354,7 @@ pub async fn check_web_ingest(
         return js_clear_errors(&form_error_target(&form)).into_response();
     }
 
-    match rpc_check_with_bearer(&state, &bearer, room, form.text.clone()).await {
+    match rpc_check_with_bearer(state, &bearer, room, form.text.clone()).await {
         Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(&form)).into_response(),
         Ok(_) => form_js_error(&form, "unexpected response", "Check did not return CheckOk.").into_response(),
         Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or("")).into_response(),
diff --git a/server/src/form_template.rs b/server/src/form_template.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3709c2c09a859da006e4af173413d5d235bc19be
--- /dev/null
+++ b/server/src/form_template.rs
@@ -0,0 +1,142 @@
+//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from
+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**
+//! deserializing into a typed struct.
+//!
+//! # Wire format
+//!
+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty
+//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string
+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not
+//! bespoke encodings.
+//!
+//! # Power vs flat hidden fields
+//!
+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),
+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,
+//! arrays, and optional fields without inventing a new naming scheme each time.
+//!
+//! # Security
+//!
+//! Substitution runs **before** `serde` into your command type. It does not fix
+//! authorization: if the client can replace the hidden `__rpc__` value, they can
+//! change the command shape unless you validate (signed blob, server-side session
+//! context, or treat the blob as hints only). Same threat model as any hidden field.
+
+use serde::Serialize;
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.
+pub fn template_json_compact<T: Serialize>(v: &T) -> serde_json::Result<String> {
+    serde_json::to_string(v)
+}
+
+/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
+/// string for `key` (empty if missing). Other keys are unchanged.
+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
+    match val {
+        Value::Object(map) => {
+            if map.len() == 1 {
+                if let Some(Value::String(field_name)) = map.get("$form") {
+                    let submitted = form_data
+                        .get(field_name.as_str())
+                        .map(|s| s.as_str())
+                        .unwrap_or("");
+   

… preview truncated; 13,273 characters omitted

download full diff A

B — c_06fce70179bc (tommy-mor)

message

[6d04afc2] refactor

diff preview

diff --git a/Cargo.lock b/Cargo.lock
index 2cea973082716e761ef6f5dd5886acc08ff9aac0..8c43fb75c472b102e6e1d3b837dce3355be898f2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,28 @@ version = "1.0.102"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
 
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "async-trait"
 version = "0.1.89"
@@ -1242,9 +1264,11 @@ dependencies = [
 name = "sorter2-server"
 version = "0.0.1"
 dependencies = [
+ "async-stream",
  "axum",
  "axum-extra",
  "dotenvy",
+ "futures-util",
  "maud",
  "reqwest",
  "serde",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index bd600138b613bd0f546bdec217a5334cdcb20aa5..c940acb687fb141d21760a3d6656172013cf6f41 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -18,6 +18,8 @@ tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 reqwest = { version = "0.12", features = ["json"] }
 dotenvy = "0.15"
+async-stream = "0.3"
+futures-util = { version = "0.3", default-features = false, features = ["std"] }
 
 [dev-dependencies]
 reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index b33a84e8bb5e817b26592868d88090e6d664d950..7af6527d03c483f33f3469ce6766c01a554c5fe3 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -6,7 +6,8 @@ use axum::{
 use std::collections::HashMap;
 
 use crate::{
-    html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},
+    fetch,
+    html::{input_panel, js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
     path_types::ItemId,
     reddit::ensure_partial_tree,
@@ -89,18 +90,8 @@ pub async fn post_ui_html(
         },
         HtmlUiAction::FetchEntity { item } => {
             let id = parse_item_param(&item);
-            if id.is_root() {
-                return ui_js_warn("nothing to fetch for the root").into_response();
-            }
-            state.queue_entity_fetch(id.clone());
-            let tree = state.tree.read().await;
-            let empty = crate::reducer::NodeState::default();
-            let node = tree.get(&id).unwrap_or(&empty);
-            let panel = entity_section(&id, node, true);
-            JsBuilder::new()
-                .morph_selector("#entity-section", panel)
-                .into_response()
-        },
+            fetch::fetch_entity_stream(state, id).into_response()
+        }
     }
 }
 
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
new file mode 100644
index 0000000000000000000000000000000000000000..63634508496e224c38b9ec0308b7a6086462f925
--- /dev/null
+++ b/server/src/fetch/html.rs
@@ -0,0 +1,67 @@
+//! Markup for entity import / “Fetch from Reddit” (`POST /ui`, SSE response).
+
+use maud::{html, Markup};
+
+use crate::{
+    form_template::template_json_compact,
+    path_types::ItemId,
+    reddit::is_fetchable,
+    reducer::NodeState,
+    ui_action::UI_RPC_FIELD,
+};
+
+fn entity_panel(node: &NodeState) -> Markup {
+    html! {
+        @if let Some(data) = &node.data {
+            div id="entity-panel" class="entity-card" {
+                h2 { (data.title) }
+                @if let Some(author) = &data.author {
+                    p class="muted small" { "by " (author) }
+                }
+                @if let Some(body) = &data.body_html {
+                    div class="entity-body" { (maud::PreEscaped(body)) }
+                }
+            }
+        }
+    }
+}
+
+/// Reddit/API import — `POST /ui` with `fetch_entity` returns an SSE stream.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+    if !is_fetchable(item) {
+        return html! {};
+    }
+    let label = if fetching {
+        "Fetching…"
+    } else if has_data {
+        "Fetch more"
+    } else {
+        "Fetch from Reddit"
+    };
+    let rpc = template_json_compact(&serde_json::json!({
+        "action": "fetch_entity",
+        "item": item.as_str(),
+    }))
+    .expect("fetch_entity rpc template");
+    html! {
+        form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+            input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
+            @if fetching {
+                button type="submit" class="btn-secondary" disabled { (label) }
+            } @else {
+                button type="submit" class="btn-secondary" { (label) }
+            }
+        }
+    }
+}
+
+/// Entity card + fetch control (target `#entity-section` for Idiomorph / SSE).
+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
+    let has_data = node.data.is_some();
+    html! {
+        section id="entity-section" class="demo-panel" {
+            (entity_panel(node))
+            (fetch_entity_panel(item, has_data, fetching))
+        }
+    }
+}
diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2290f9d3a0f1cbf1806c6339f82a4515c11cc3d3
--- /dev/null
+++ b/server/src/fetch/mod.rs
@@ -0,0 +1,115 @@
+//! Entity import over `POST /ui` as SSE (Reddit worker in [`crate::reddit`]).
+
+pub mod html;
+
+use std::convert::Infallible;
+use std::time::Duration;
+
+use async_stream::stream;
+use axum::response::sse::{Event, KeepAlive, Sse};
+use futures_util::Stream;
+use serde::Serialize;
+use tokio::sync::oneshot;
+
+use crate::{
+    path_types::ItemId,
+    reddit::FetchJobResult,
+    reducer::NodeState,
+    state::AppState,
+};
+
+pub fn now_ms() -> i64 {
+    let t = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap_or_default();
+    t.as_millis() as i64
+}
+
+#[derive(Serialize)]
+struct SseMorphPayload {
+    selector: &'static str,
+    html: String,
+}
+
+fn morph_complete_event(html: maud::Markup) -> Event {
+    let payload = SseMorphPayload {
+        selector: "#entity-section",
+        html: html.into_string(),
+    };
+    let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
+    Event::default().event("complete").data(data)
+}
+
+/// Stream `fetching` → `complete` / `error` for [`crate::ui_action::HtmlUiAction::FetchEntity`].
+pub fn fetch_entity_stream(
+    state: AppState,
+    id: ItemId,
+) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
+    tracing::debug!(item = %id, "fetch entity stream opened");
+
+    let stream = stream! {
+        if id.is_root() {
+            yield Ok(Event::default().event("error").data("{\"message\":\"nothing to fetch for the root\"}"));
+            return;
+        }
+
+        if !crate::reddit::is_fetchable(&id) {
+            tracing::debug!(item = %id, "fetch stream: not fetchable");
+            yield Ok(Event::default().event("error").data("{\"message\":\"this page cannot be fetched from Reddit\"}"));
+            return;
+        }
+
+        let fetching_html = {
+            let tree = state.tree.read().await;
+            let empty = NodeState::default();
+            let node = tree.get(&id).unwrap_or(&empty);
+            html::entity_section(&id, node, true).into_string()
+        };
+        let fetching_payload = serde_json::json!({
+            "selector": "#entity-section",
+            "html": fetching_html,
+        });
+        yield Ok(Event::default().event("fetching").data(fetching_payload.to_string()));
+
+        let (tx, rx) = oneshot::channel();
+        state.reddit.request_fetch(id.clone(), true, Some(tx));
+        tracing::debug!(item = %id, "fetch stream: queued reddit job");
+
+        let result = match rx.await {
+            Ok(r) => r,
+            Err(_) => {
+                tracing::warn!(item = %id, "fetch stream: worker dropped oneshot");
+                FetchJobResult::Failed("reddit worker stopped".into())
+            }
+        };
+
+        tracing::debug!(item = %id, ?result, "fetch stream: job finished");
+
+        match result {
+            FetchJobResult::Imported | FetchJobResult::NotFound => {
+                let tree = state.tree.read().await;
+                let empty = NodeState::default();
+                let node = tree.get(&id).unwrap_or(&empty);
+                yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
+            }
+            FetchJobResult::SkippedCached | FetchJobResult::SkippedDuplicate => {
+                let tree = state.tree.read().await;
+                let empty = NodeState::default();
+                let node = tree.get(&id).unwrap_or(&empty);
+                yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
+            }
+            FetchJobResult::RateLimited { reset_secs } => {
+                yield Ok(Event::default().event("error").data(
+                    serde_json::json!({"message": format!("Reddit rate limit — retry in {reset_secs}s")}).to_string(),
+                ));
+            }
+            FetchJobResult::Failed(msg) => {
+                yield Ok(Event::default().event("error").data(
+                    serde_json::json!({"message": msg}).to_string(),
+                ));
+            }
+        }
+    };
+
+    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
+}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index db5b4c7f06b0be64603981166835cde268234f67..9314a7556306ddab969b896dbf4126b542a46722 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -7,10 +7,10 @@ use axum::{
 use maud::{html, Markup, DOCTYPE};
 
 use crate::{
+    fetch::html::entity_section,
     form_template::template_json_compact,
     path_types::ItemId,
     ranking::{top_bottom, RankedItem},
-    reddit::is_fetchable,
     reducer::{GroupState, NodeState},
     state::AppState,
     ui_action::UI_RPC_FIELD,
@@ -149,62 +149,6 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {
     }
 }
 
-fn entity_panel(node: &NodeState) -> Markup {
-    html! {
-        @if let Some(data) = &node.data {
-            div id="entity-panel" class="entity-card" {
-                h2 { (data.title) }
-                @if let Some(author) = &data.author {
-                    p class="muted small" { "by " (author) }
-                }
-                @if let Some(body) = &data.body_html {
-                    div class="entity-body" { (maud::PreEscaped(body)) }
-                }
-            }
-        }
-    }
-}
-
-/// Reddit/API import control — only shown on fetchable pages; never auto-fires.
-pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
-    if !is_fetchable(item) {
-        return html! {};
-    }
-    let label = if fetching {
-        "Fetching…"
-    } else if has_data {
-        "Fetch more"
-    } else {
-        "Fetch from Reddit"
-    };
-    let rpc = template_json_compact(&serde_json::json!({
-        "action": "fetch_entity",
-        "item": item.as_str(),
-    }))
-    .expect("fetch_entity rpc template");
-    html! {
-        form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
-            input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
-            @if fetching {
-                button type="submit" class="btn-secondary" disabled { (label) }
-            } @else {
-                button type="submit" class="btn-secondary" { (label) }
-            }
-        }
-    }
-}
-
-/// Entity 

… preview truncated; 22,618 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.