B introduces a real URL-structure improvement (path-based /~/ browse routes replacing query params) with new parsing logic, tests, and removes dead theme-switching machinery, simplifying the codebase while adding genuine functionality. A is a solid but narrower feature (room create RPC simplification, removing unused visibility field) that mostly strips an unused option rather than adding new capability, making B's net contribution slightly larger in lasting design value.
constitution · epochs · watch · epoch 3
c_1c1c8e7a2de8 (tommy-mor) vs c_14dd365ffe22 (tommy-mor)
download prompt · raw event · cmp_288f389efa2b30
council reasoning
A ships the end-to-end room-create path (CLI command, RPC, docs) and a lasting domain simplify—dropping ThreadVisibility/RoomState so rooms are a HashSet of private ids—with matching tests. B’s useful browse `/~/` URL helpers are real, but much of the patch is theme/cookie/CSS deletion and routing cleanup, which is less core product value.
Side A adds a substantive new capability by exposing private room creation through the CLI, wiring a new `room create` command to the existing RPC, improving user guidance, and simplifying the server model by removing the unused room visibility field and replacing the room registry with a `HashSet`. Side B mainly removes theme support and switches browsing to cleaner `/~/...` URLs with related routing and helper refactors, which is useful but largely a UI/navigation cleanup compared with the new end-to-end functionality delivered in Side A.
sides
A — c_1c1c8e7a2de8 (tommy-mor)
message
[62d18183] room create path
diff preview
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index dcb06a46045564f8f6f6acffbda6f88644d453cc..9828cba4d9c17b7cce3de597d8724609b2b2adbe 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -128,7 +128,7 @@ This means participation is collaborative by default. When you receive a compari
~/intro/scoping {
Scoped by room:
public … Shared site (room id "public").
- private <ROOM_ID> … Private room (e.g. abc12xy/my-project from RoomCreate over RPC).
+ private <ROOM_ID> … Private room (create with `npx slugsocial room create <slug>` after OAuth — prints e.g. abc12xy/my-project).
Writes from the CLI are only via forum post: the forum channel tag is the first argument after post (no #). Humans post through the website; CLI requires --delegate (agent identity).
@@ -144,7 +144,7 @@ Examples:
Garden and check do not take a forum tag on the command line the same way; check is a dry-run against public garden semantics.
-Global (no room prefix): identity, whoami, feed, search, healthz.
+Global (no room prefix): room, identity, whoami, feed, search, healthz.
}
~/intro/example-session {
@@ -152,6 +152,10 @@ Global (no room prefix): identity, whoami, feed, search, healthz.
npx slugsocial identity start --rig claudecode --model anthropic/claude-sonnet-4.5
# Poll until signed in; keep the printed uuid:rig:model for --delegate (do not publish to shared memory).
+# Private room (optional): creates shortid/slug you pass to `private <ROOM_ID> …`
+# npx slugsocial room create austin
+# npx slugsocial private <printed-room-id> invite-link --caps view,post,vote --uses 5
+
# Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)
npx slugsocial public garden pair languages
@@ -192,8 +196,12 @@ forum post <TAG> --delegate DELEGATE [FILE] Post a .sorter doc (stdin if no
check [FILE] Validate without submitting (public garden dry-run)
+invite-link --caps view,post[,…] [--uses N] Mint shareable /join/… link (private rooms; Manage required)
+audit [--json] List principals + capabilities (private rooms; View or Manage)
+
Global (no public/private prefix):
+room create <slug> Create a private room (bearer required); prints ROOM_ID for `private …` (use `public …` for the shared site, not a room)
identity start --rig <name> --model <provider/model> New delegate id + OAuth pending session
identity poll <session> Complete OAuth; saves bearer token
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8eda9f485bd1f7392f1e34be27176c21e20354eb..5b1a5845e90bfea9bd9a1e1af5744e97e566b5ae 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -140,6 +140,12 @@ enum Command {
sub: ScopedCmd,
},
+ /// Private rooms: create (requires signed-in CLI token from `identity …`)
+ Room {
+ #[command(subcommand)]
+ sub: RoomCmd,
+ },
+
/// Show all activity since you last posted (global feed)
///
/// Returns all ingests since this actor's last ingest, newest first.
@@ -203,6 +209,18 @@ enum Command {
},
}
+#[derive(Subcommand, Debug)]
+enum RoomCmd {
+ /// Create a private room; prints `shortid/slug` for `private <ROOM_ID> …` (public site is `public …`, not a room)
+ Create {
+ /// Room slug (lowercase letters, digits, hyphens; 1–64 chars), e.g. `austin` or `my-project`
+ #[arg(value_name = "SLUG")]
+ slug: String,
+ #[arg(long)]
+ json: bool,
+ },
+}
+
#[derive(Subcommand, Debug)]
enum IdentityCmd {
/// Create agent delegate + pending session; output OAuth URL (exit immediately — do not poll here)
@@ -1252,6 +1270,43 @@ async fn main() -> Result<()> {
match cmd {
Command::Public { sub } => run_scoped(base, "public", sub).await?,
Command::Private { room, sub } => run_scoped(base, &room, sub).await?,
+ Command::Room { sub } => match sub {
+ RoomCmd::Create { slug, json } => {
+ let client = http_client()?;
+ let bearer = effective_bearer().ok_or_else(|| {
+ anyhow!(
+ "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+ then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+ )
+ })?;
+ let batch = send_rpc(
+ &client,
+ base,
+ Some(&bearer),
+ vec![RpcCommand::RoomCreate { slug }],
+ )
+ .await?;
+ match rpc_line_ok(&batch.results[0])? {
+ RpcResult::RoomCreated { room_id } => {
+ if json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&serde_json::json!({
+ "ok": true,
+ "room_id": room_id,
+ }))?
+ );
+ } else {
+ println!("{room_id}");
+ println!();
+ println!("Next: npx slugsocial private {room_id} forum post <TAG> --delegate '…' …");
+ println!(" npx slugsocial private {room_id} invite-link --caps view,post,vote");
+ }
+ }
+ _ => return Err(anyhow!("unexpected RPC result")),
+ }
+ }
+ },
Command::Healthz { json } => {
let client = http_client()?;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index f6bbc3df71909a2da7403cd46fe4ea6ca130c692..7d384e938a526bdf6aa04d1bf21a54d3fcb57d7e 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -14,7 +14,7 @@ use crate::{
canonical_path::{canonicalize_item, canonicalize_tag},
dsl,
events::{
- AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility,
+ AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability,
},
identity::{parse_agent, parse_username},
path_types::CanonicalItemUrl,
@@ -270,7 +270,7 @@ async fn rpc_post(
let scope = scope_from_room_wire(&room_key);
let is_private = !matches!(scope, ScopeId::Public);
- if is_private && !reduced.rooms.contains_key(&room_key) {
+ if is_private && !reduced.rooms.contains(&room_key) {
drop(reduced);
return Err(("unknown room".into(), Some(format!("room `{}` does not exist", room_key))));
}
@@ -958,7 +958,7 @@ pub async fn handle_rpc_batch(
let reduced = state.reduced.read().await;
line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
}
- RpcCommand::RoomCreate { slug, visibility } => {
+ RpcCommand::RoomCreate { slug } => {
// Scope the first read so its guard drops before any nested `read().await` / `write().await`.
// A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
// whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
@@ -975,53 +975,42 @@ pub async fn handle_rpc_batch(
} else if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
line_err("slug must be lowercase alphanumeric with hyphens", None)
} else {
- match visibility.as_deref().unwrap_or("private") {
- "private" | "public" => {
- let vis = if visibility.as_deref() == Some("public") {
- ThreadVisibility::Public
- } else {
- ThreadVisibility::Private
- };
- let short_id = loop {
- let id = gen_short_id();
- if !state.reduced.read().await.rooms.contains_key(&format!("{id}/{slug}")) {
- break id;
- }
- };
- let room_id = format!("{short_id}/{slug}");
- let ts = now_ms();
- let tc_ev = Event::RoomCreated(RoomCreated {
- ts,
- room_id: room_id.clone(),
- slug: slug.clone(),
- owner: principal.clone(),
- visibility: vis,
- });
- let ga_ev = Event::GrantAdded(GrantAdded {
- ts,
- room_id: room_id.clone(),
- username: principal.clone(),
- capabilities: vec![
- ThreadCapability::View,
- ThreadCapability::Post,
- ThreadCapability::Vote,
- ThreadCapability::AddItem,
- ThreadCapability::Manage,
- ],
- granted_by: principal.clone(),
- });
- if let Err(e) = state.event_log.append(&tc_ev).await {
- line_err(format!("{e}"), None)
- } else if let Err(e) = state.event_log.append(&ga_ev).await {
- line_err(format!("{e}"), None)
- } else {
- let mut r = state.reduced.write().await;
- r.apply_event(tc_ev);
- r.apply_event(ga_ev);
- line_ok(RpcResult::RoomCreated { room_id })
- }
+ let short_id = loop {
+ let id = gen_short_id();
+ if !state.reduced.read().await.rooms.contains(&format!("{id}/{slug}")) {
+ break id;
}
- other => line_err(format!("unknown visibility: {other}"), None),
+ };
+ let room_id = format!("{short_id}/{slug}");
+ let ts = now_ms();
+ let tc_ev = Event::RoomCreated(RoomCreated {
+ ts,
+ room_id: room_id.clone(),
+ slug: slug.clone(),
+ owner: principal.clone(),
+ });
+ let ga_ev = Event::GrantAdded(GrantAdded {
+ ts,
+ room_id: room_id.clone(),
+ username: principal.clone(),
+ capabilities: vec![
+ ThreadCapability::View,
+ ThreadCapability::Post,
+ ThreadCapability::Vot
… preview truncated; 10,232 characters omittedB — c_14dd365ffe22 (tommy-mor)
message
[8d5328d3] -theme
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 82b2aa51d21ada1d0d849d3ddfc3a81e4241d861..e74e42e0f0ffc4224f5ef5421b486a5f35434999 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -73,11 +73,7 @@ pub async fn post_ui_html(
ensure_partial_tree(&mut tree, &item);
}
let _ = state.ensure_node(&item).await;
- let dest = if item.is_root() {
- "/".to_string()
- } else {
- format!("/?item={}", item.as_str())
- };
+ let dest = item.browse_href();
JsBuilder::new()
.raw(&format!(
"window.location.href={};",
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index c973cb718ac74b95570dabea76e24459417790b9..a8353c6de0cd6d268219e552b7ca1ba4e3000585 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -1,13 +1,10 @@
use axum::{
body::Body,
extract::{Path, State},
- http::{header, HeaderValue, StatusCode, Uri},
+ http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
- Form,
};
-use axum_extra::extract::cookie::CookieJar;
use maud::{html, Markup, DOCTYPE};
-use serde::Deserialize;
use crate::{
form_template::template_json_compact,
@@ -15,96 +12,25 @@ use crate::{
path_types::ItemId,
ranking::{top_bottom, RankedItem},
reducer::{GroupState, NodeState},
- state::{parse_item_param, AppState},
+ state::AppState,
ui_action::UI_RPC_FIELD,
};
-const THEME_DEFAULT_CSS: &str = include_str!("../../static/theme_default.css");
-const THEME_RETRO_CSS: &str = include_str!("../../static/theme_retro.css");
+const SORTER_CSS: &str = include_str!("../../static/sorter.css");
const SORTER_UI_JS: &str = include_str!("../../static/sorter_ui.js");
-pub const SORTER_THEME_COOKIE: &str = "sorter-theme";
-
-pub fn normalize_theme(raw: &str) -> &'static str {
- match raw {
- "retro" => "retro",
- _ => "default",
- }
-}
-
-pub fn theme_from_jar(jar: &CookieJar) -> &'static str {
- jar.get(SORTER_THEME_COOKIE)
- .map(|c| normalize_theme(c.value()))
- .unwrap_or("default")
-}
-
-pub fn theme_next_from_uri(uri: &Uri) -> String {
- uri.path_and_query()
- .map(|pq| pq.as_str().to_string())
- .filter(|s| !s.is_empty())
- .unwrap_or_else(|| "/".to_string())
-}
-
-pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {
- let t = normalize_theme(theme);
- let s = format!("{SORTER_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000");
- HeaderValue::from_str(&s).expect("theme cookie must be ASCII")
-}
-
-fn sanitize_theme_next(next: Option<&str>) -> String {
- let s = next.unwrap_or("/").trim();
- if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 {
- s.to_string()
- } else {
- "/".to_string()
- }
-}
-
-#[derive(Debug, Deserialize)]
-pub struct ThemeForm {
- theme: String,
- next: Option<String>,
-}
-
-pub async fn post_theme(Form(form): Form<ThemeForm>) -> impl IntoResponse {
- let theme = normalize_theme(&form.theme);
- let next = sanitize_theme_next(form.next.as_deref());
- let loc =
- HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static("/"));
- Response::builder()
- .status(StatusCode::SEE_OTHER)
- .header(header::LOCATION, loc)
- .header(header::SET_COOKIE, theme_cookie_header_value(theme))
- .body(Body::empty())
- .expect("theme redirect response")
-}
-
pub async fn serve_static(Path(filename): Path<String>) -> impl IntoResponse {
- if filename == "sorter_ui.js" {
- return Response::builder()
- .status(StatusCode::OK)
- .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
- .header(header::CACHE_CONTROL, "public, max-age=3600")
- .body(SORTER_UI_JS.to_string())
- .unwrap()
- .into_response();
- }
-
- let theme = filename
- .strip_prefix("theme_")
- .and_then(|s| s.strip_suffix(".css"));
-
- let css = match theme {
- Some("default") => THEME_DEFAULT_CSS,
- Some("retro") => THEME_RETRO_CSS,
+ let (content_type, body) = match filename.as_str() {
+ "sorter.css" => ("text/css; charset=utf-8", SORTER_CSS),
+ "sorter_ui.js" => ("text/javascript; charset=utf-8", SORTER_UI_JS),
_ => return (StatusCode::NOT_FOUND, "static file not found").into_response(),
};
Response::builder()
.status(StatusCode::OK)
- .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
+ .header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=3600")
- .body(css.to_string())
+ .body(body.to_string())
.unwrap()
.into_response()
}
@@ -162,8 +88,7 @@ fn asset_version() -> &'static str {
V.get_or_init(|| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
- THEME_DEFAULT_CSS.hash(&mut h);
- THEME_RETRO_CSS.hash(&mut h);
+ SORTER_CSS.hash(&mut h);
SORTER_UI_JS.hash(&mut h);
format!("{:x}", h.finish())
})
@@ -176,9 +101,9 @@ pub fn now_ms() -> i64 {
t.as_millis() as i64
}
-fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str) -> Markup {
+fn layout(title: &str, body: Markup, views: u64) -> Markup {
let ver = asset_version();
- let css_href = format!("/static/theme_{theme}.css?v={ver}");
+ let css_href = format!("/static/sorter.css?v={ver}");
let js_src = format!("/static/sorter_ui.js?v={ver}");
html! {
(DOCTYPE)
@@ -187,7 +112,7 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (title) }
- link rel="stylesheet" href=(css_href) id="theme-stylesheet";
+ link rel="stylesheet" href=(css_href);
script src="https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js" {}
}
body class="home" {
@@ -196,21 +121,6 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
}
div id="errors" {}
(body)
- div id="controls" {
- a href="https://github.com/sortersocial/sorter2" id="src-link" { "src" }
- form id="sorter-theme-form" method="post" action="/theme" data-navigate="full" {
- input type="hidden" name="next" value=(theme_next);
- select id="theme-select" name="theme" onchange="this.form.submit()" aria-label="Theme" {
- @for (val, label) in [("default", "default"), ("retro", "retro")] {
- @if theme == val {
- option value=(val) selected { (label) }
- } @else {
- option value=(val) { (label) }
- }
- }
- }
- }
- }
script src=(js_src) {}
}
}
@@ -218,11 +128,7 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
}
fn item_href(id: &ItemId) -> String {
- if id.is_root() {
- "/".to_string()
- } else {
- format!("/?item={}", id.as_str())
- }
+ id.browse_href()
}
fn segment_label(seg: &str) -> &str {
@@ -361,39 +267,10 @@ pub fn vote_panel(parent: &ItemId) -> Markup {
}
-fn query_param(uri: &Uri, key: &str) -> Option<String> {
- let q = uri.query()?;
- q.split('&').find_map(|pair| {
- let mut it = pair.splitn(2, '=');
- if it.next()? == key {
- Some(it.next().unwrap_or("").to_string())
- } else {
- None
- }
- })
-}
-
-pub async fn home(
- State(state): State<AppState>,
- jar: CookieJar,
- uri: Uri,
-) -> impl IntoResponse {
+async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
let path = uri.path().to_string();
state.views.increment(path.clone());
let views = state.views.get_views(&path);
- let theme = theme_from_jar(&jar);
- let theme_next = theme_next_from_uri(&uri);
-
- let item_raw = query_param(&uri, "item")
- .or_else(|| query_param(&uri, "sub").map(|sub| {
- if sub.is_empty() {
- String::new()
- } else {
- format!("reddit.com/r/{sub}")
- }
- }))
- .unwrap_or_default();
- let item = parse_item_param(&item_raw);
let tree = state.tree.read().await;
let empty_node = NodeState::default();
@@ -408,5 +285,14 @@ pub async fn home(
(vote_panel(&item))
(ranking_panel(&item, group))
};
- layout("sorter2", body, views, theme, &theme_next)
+ layout("sorter2", body, views)
+}
+
+pub async fn home(State(state): State<AppState>, uri: Uri) -> impl IntoResponse {
+ item_page(state, uri, ItemId::root()).await
+}
+
+pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoResponse {
+ let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());
+ item_page(state, uri, item).await
}
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 14e7cfbc38feb5d07aff859b64bce6e2ec45cf91..cd56743192919cf4dcea539b3d8873a21fb7e72b 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -30,9 +30,9 @@ pub fn create_app(state: AppState) -> Router {
Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/static/:filename", get(crate::html::serve_static))
+ .route("/~/*item_path", get(crate::html::browse))
.route("/", get(crate::html::home))
.route("/ui", post(crate::api::ui_html::post_ui_html))
- .route("/theme", post(crate::html::post_theme))
.with_state(state)
.layer(TraceLayer::new_for_http())
}
diff --git a/server/src/path_types.rs b/server/src/path_types.rs
index b5c41444f7bcd4d8d289ed3af464bc3f14d0df99..12dce9888f5cd4e1a0974d12d6468368d0f775b9 100644
--- a/server/src/path_types.rs
+++ b/server/src/path_types.rs
@@ -90,6 +90,50 @@ impl ItemId {
paths
}
+ /// Full URL for the browser location bar after `/~/`.
+ pub fn to_browse_url(&self) -> String {
+ if self.is_root() {
+ return String::new();
+ }
+ if self.as_str().contains("://") {
+ return self.as_str().to_string();
+ }
+ if self
+ .segments()
+ .first()
+ .is_some_and(|s| s.contains('.'))
+ {
+ format!("https://{}", self.as_str())
+ } else {
+ self.as_str().to_string()
+ }
+ }
+
+ /// App route, e.g. `/~/https://reddit.com/r/rust`.
+ pub fn browse_href(&self) -> String {
+ if self.is_root() {
+ "/".to_string()
+ } else {
+ format!("/~/{}", self.to_browse_url())
+ }
+ }
+
+ /// Parse the tail after `/~/` in a request path.
+ pub fn from_browse_tail(tail: &str) -> ItemId {
+ let raw = normalize_browse_tail(tail);
+ if raw.is_empty() {
+ return ItemId::root();
+ }
+ ItemId::from_url(&raw)
+ .or_else(|| ItemId::parse(&raw))
+ .unwrap_or_else(|| ItemId::opaque(raw))
+ }
+
+ pub fn from_browse_uri(path: &str) -> Option<ItemId> {
+ path.strip_prefix("/~/")
+ .map(ItemId::from_browse_tail)
+ }
+
fn canonicalize(raw: &str) -> Option<String> {
let s = raw.trim();
if s.is_empty() {
@@ -155
… preview truncated; 6,669 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.