Side A is a small, focused fix that removes duplicate/dead UI code (the toolbar + ExpandNewThreadForm action) and unifies the new-thread compose flow, directly simplifying the codebase with tests updated accordingly. Side B is a large, unfocused commit ('nice') that deletes a legacy parser file while duplicating nearly the same 1800-line reddit-URL parser under new module names (parser.rs, parser_action.rs) and bolts on an unrelated, seemingly experimental 'parser-panel' navigation demo feature, adding bulk and complexity without clear lasting value or coherent purpose.
constitution · epochs · watch · epoch 3
c_f515f8a12d7a (tommy-mor) vs c_b3cb2762569d (tommy-mor)
download prompt · raw event · cmp_43f6517e6e3647
council reasoning
A is a precise design cleanup: SSR the home compose slot like rooms, delete the toolbar/+ dual path, and remove ExpandNewThreadForm plus its handler/tests—less surface and one consistent flow. B mostly relocates ~1.8k lines of legacy parser (UIAction→ParserAction rename), plus .gitignore noise; the real new value is wiring (ParseQuery, parser_render, JS tab/input), which matters but is thinner relative to the churn.
Side A makes a focused architectural simplification: it removes the now-redundant `ExpandNewThreadForm` UI action and server handler, and changes the home page to server-render `#new-thread-ui-slot` directly with the same compose/login state used on room pages. This eliminates duplicate interaction flow and dead code, whereas Side B is dominated by importing a very large parser subsystem and deleting a legacy file, adding substantial complexity whose lasting value is less certain despite the new demo UI and tests.
sides
A — c_f515f8a12d7a (tommy-mor)
message
[601d3a05] fix(html): drop home toolbar + and ExpandNewThreadForm (single + flow) Public home now SSRs #new-thread-ui-slot like room pages: collapsed compose for signed-in users, login hint when logged out. Removes the extra toolbar that morphed the same collapsed state and the expand_new_thread_form action. Made-with: Cursor
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index e979053ff1ba8c0e2bf55add0a32b7de11cf1e56..f3ce5cb2ab2f923440a8479d0f1fb4acbba166ca 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -139,51 +139,6 @@ async fn dispatch_ui_action(
}
}
}
- HtmlUiAction::ExpandNewThreadForm { room_wire } => {
- let room_wire = room_wire.trim().to_string();
- if room_wire.is_empty() {
- return ui_js_warn("missing room").into_response();
- }
- if room_wire == "public" {
- let reduced = state.reduced.read().await;
- let user = session.map(|s| s.username.as_str());
- drop(reduced);
- let markup = if user.is_some() {
- fragment_new_thread_slot(&ThreadNav::public(), true, false)
- } else {
- login_to_post_hint_markup()
- };
- return JsBuilder::new()
- .morph_inner_selector("#new-thread-ui-slot", markup)
- .into_response();
- }
- let reduced = state.reduced.read().await;
- let user = session.map(|s| s.username.as_str());
- 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) {
- drop(reduced);
- return ui_js_warn("forbidden").into_response();
- }
- let can_post = session
- .as_ref()
- .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))
- .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_new_thread_slot(&nav, true, false)
- } else {
- login_to_post_hint_markup()
- };
- JsBuilder::new()
- .morph_inner_selector("#new-thread-ui-slot", markup)
- .into_response()
- }
HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => {
let room_wire = room_wire.trim().to_string();
if room_wire.is_empty() {
diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs
index 1b4ae7baa3ad4757b74172d7b67f3f2b33d1075d..945bdd6c48bc164e2cf91fd0996c4321b75f5abf 100644
--- a/server/src/html/forum/feed.rs
+++ b/server/src/html/forum/feed.rs
@@ -14,6 +14,7 @@ use crate::timeago;
use super::ingest::ingest_entry_markup;
use super::nav::ThreadNav;
+use super::new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};
use super::page::auth_strip;
use super::paginator::{render_thread_paginator, PAGE_SIZE};
use crate::html::{
@@ -217,9 +218,6 @@ pub async fn home(
let strip = auth_strip(&headers, &jar, &reduced_read);
drop(reduced_read);
- use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD};
- use crate::form_template::template_json_compact;
-
let page = layout(
"slug.social",
"view-thread",
@@ -243,15 +241,13 @@ pub async fn home(
}
}
p class="muted" { "dark = time-ordered · light = vote-ranked" }
- div class="thread-feed-toolbar" {
- form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandNewThreadForm {
- room_wire: "public".into(),
- }).expect("static json"));
- button type="submit" class="section-add-btn" { "+" }
+ div id="new-thread-ui-slot" {
+ @if user.is_some() {
+ (fragment_new_thread_slot(&nav, true, false))
+ } @else {
+ (login_to_post_hint_markup())
}
}
- div id="new-thread-ui-slot" {}
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
(cli_panel(&["npx slugsocial public forum list"]))
},
diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs
index 5031ebfeb928f28e471f23210b8c644654adb7c7..da0c9b3541e8e4988a78768cddef22324755c3f2 100644
--- a/server/src/html/ui_action.rs
+++ b/server/src/html/ui_action.rs
@@ -38,11 +38,6 @@ pub enum HtmlUiAction {
RedactPost {
post_id: String,
},
- /// Morph `#new-thread-ui-slot` inner to the collapsed compose toggle (or login hint).
- /// Use `room_wire: "public"` for the public forum home; otherwise a private room id (`short/slug`).
- ExpandNewThreadForm {
- room_wire: String,
- },
/// Morph `#room-members-section` — members list open or collapsed (server-rendered).
SetRoomMembersExpanded {
room_wire: String,
@@ -131,26 +126,6 @@ mod tests {
);
}
- #[test]
- fn expand_new_thread_form_public() {
- let template = serde_json::json!({
- "action": "expand_new_thread_form",
- "room_wire": "public",
- });
- let mut form = HashMap::new();
- form.insert(
- UI_RPC_FIELD.to_string(),
- serde_json::to_string(&template).unwrap(),
- );
- let a = parse_html_ui_from_form(&form).unwrap();
- assert_eq!(
- a,
- HtmlUiAction::ExpandNewThreadForm {
- room_wire: "public".into(),
- }
- );
- }
-
#[test]
fn expand_post_full_round_trip() {
let template = serde_json::json!({
B — c_b3cb2762569d (tommy-mor)
message
[604a14ad] nice
diff preview
diff --git a/.gitignore b/.gitignore
index 16de5edb7185b04ef5bc64512814d7dfe2c1f50c..73e8f22cf0d39c706e7cdce5e39f1903a0f9181b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@
/.lsp/
*.swp
.DS_Store
+data/
+repomix-output.xml
diff --git a/legacy/parser.rs b/legacy/parser.rs
deleted file mode 100644
index 2b87b974f8d1dd93bee35681d87668a94e4ef349..0000000000000000000000000000000000000000
--- a/legacy/parser.rs
+++ /dev/null
@@ -1,1808 +0,0 @@
-use std::collections::HashMap;
-use std::rc::Rc;
-use std::cell::RefCell;
-use crate::ui::action::UIAction;
-use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};
-
-// --- Core Abstractions ---
-
-/// Unique identifier for nodes in the graph
-type NodeId = &'static str;
-
-/// Pattern matching for edges
-#[derive(Debug, Clone)]
-pub enum EdgePattern {
- /// Matches exact literal string
- Literal(&'static str),
-
- /// Matches any prefix of a string and suggests the full string
- /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
- PrefixOf(&'static str),
-
- /// Captures a variable segment (e.g., subreddit name, username)
- Variable(&'static str),
-
- /// Matches any string (wildcard)
- Any,
-}
-
-impl EdgePattern {
- /// Try to match this pattern against input, return (consumed_chars, captured_value)
- fn matches(&self, input: &str) -> Option<(usize, Option<String>)> {
- match self {
- EdgePattern::Literal(lit) => {
- if input.starts_with(lit) {
- Some((lit.len(), None))
- } else {
- None
- }
- }
- EdgePattern::PrefixOf(target) => {
- // Check if input is a prefix of target
- if target.starts_with(input) && !input.is_empty() {
- // It's a valid prefix
- Some((input.len(), None))
- } else if input.starts_with(target) {
- // Full match
- Some((target.len(), None))
- } else {
- None
- }
- }
- EdgePattern::Variable(var_name) => {
- // Consume until next '/' or end of string
- let end = input.find('/').unwrap_or(input.len());
- if end > 0 {
- let captured = input[..end].to_string();
- // Validate based on variable type
- if is_valid_variable(var_name, &captured) {
- Some((end, Some(captured)))
- } else {
- None
- }
- } else {
- None
- }
- }
- EdgePattern::Any => {
- // Match everything until next '/' or end
- let end = input.find('/').unwrap_or(input.len());
- if end > 0 {
- Some((end, Some(input[..end].to_string())))
- } else {
- None
- }
- }
- }
- }
-
- /// Get the completion suggestion for this pattern
- fn completion(&self, partial: &str) -> Option<String> {
- match self {
- EdgePattern::PrefixOf(target) => {
- if target.starts_with(partial) && partial != *target {
- Some(target.to_string())
- } else {
- None
- }
- }
- _ => None,
- }
- }
-}
-
-/// Edge in the graph
-pub struct Edge {
- pattern: EdgePattern,
- target: NodeId,
- /// Optional description for autocomplete
- description: Option<&'static str>,
-}
-
-/// Handler function for generating UI actions
-type Handler = Box<dyn Fn(&str, &str, &HashMap<String, String>) -> UIAction>;
-
-/// Node in the graph
-pub struct Node {
- #[allow(dead_code)]
- id: NodeId,
- edges: Vec<Edge>,
- handler: Option<Handler>,
-}
-
-/// The composable parser graph
-pub struct Graph {
- nodes: HashMap<NodeId, Rc<RefCell<Node>>>,
- root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
- nodes: HashMap<NodeId, Rc<RefCell<Node>>>,
- current_node: Option<NodeId>,
- root: NodeId,
-}
-
-impl GraphBuilder {
- pub fn new() -> Self {
- let mut nodes = HashMap::new();
- let root_node = Rc::new(RefCell::new(Node {
- id: "root",
- edges: Vec::new(),
- handler: None,
- }));
- nodes.insert("root", root_node);
-
- GraphBuilder {
- nodes,
- current_node: Some("root"),
- root: "root",
- }
- }
-
- /// Select a node to add edges to
- pub fn at(mut self, node_id: NodeId) -> Self {
- // Create node if it doesn't exist
- if !self.nodes.contains_key(node_id) {
- let node = Rc::new(RefCell::new(Node {
- id: node_id,
- edges: Vec::new(),
- handler: None,
- }));
- self.nodes.insert(node_id, node);
- }
- self.current_node = Some(node_id);
- self
- }
-
- /// Add an edge from the current node
- pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
- self.edge_with_desc(pattern, target, None)
- }
-
- /// Add an edge with description
- pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {
- let current = self.current_node.expect("No current node selected");
-
- // Create target node if it doesn't exist
- if !self.nodes.contains_key(target) {
- let node = Rc::new(RefCell::new(Node {
- id: target,
- edges: Vec::new(),
- handler: None,
- }));
- self.nodes.insert(target, node);
- }
-
- // Add edge to current node
- if let Some(node) = self.nodes.get(current) {
- node.borrow_mut().edges.push(Edge {
- pattern,
- target,
- description: desc,
- });
- }
-
- self
- }
-
- /// Set handler for current node
- pub fn handler<F>(self, handler: F) -> Self
- where
- F: Fn(&str, &str, &HashMap<String, String>) -> UIAction + 'static
- {
- let current = self.current_node.expect("No current node selected");
- if let Some(node) = self.nodes.get(current) {
- node.borrow_mut().handler = Some(Box::new(handler));
- }
- self
- }
-
- /// Build the final graph
- pub fn build(self) -> Graph {
- Graph {
- nodes: self.nodes,
- root: self.root,
- }
- }
-}
-
-// --- Parser Implementation ---
-
-impl Graph {
- pub fn parse(&self, input: &str) -> UIAction {
- let normalized = input.trim().to_lowercase();
- let mut state = ParserState {
- input: &normalized,
- cursor: 0,
- current_node_id: self.root,
- context: HashMap::new(),
- original_query: input.to_string(),
- current_prefix: String::new(),
- };
-
- self.parse_recursive(&mut state)
- }
-
- fn parse_recursive(&self, state: &mut ParserState) -> UIAction {
- let node = self.nodes.get(state.current_node_id)
- .expect("Node not found in graph");
- let node_ref = node.borrow();
-
- // If we've consumed all input, check for handler or suggestions
- if state.cursor >= state.input.len() {
- if let Some(handler) = &node_ref.handler {
- return handler(&state.original_query, &state.current_prefix, &state.context);
- }
-
- // No handler, try to suggest based on available edges
- return self.suggest_from_edges(&node_ref, state);
- }
-
- let remaining = &state.input[state.cursor..];
-
- // Try to match each edge
- for edge in &node_ref.edges {
- if let Some((consumed, captured)) = edge.pattern.matches(remaining) {
- // Save state for potential backtracking
- let saved_cursor = state.cursor;
- let saved_node = state.current_node_id;
- let saved_prefix = state.current_prefix.clone();
-
- // Update state
- state.cursor += consumed;
- state.current_node_id = edge.target;
- state.current_prefix.push_str(&remaining[..consumed]);
-
- // Store captured variable if any
- if let Some(value) = captured {
- if let EdgePattern::Variable(var_name) = &edge.pattern {
- state.context.insert(var_name.to_string(), value);
- }
- }
-
- // Check if this is a partial match that needs completion
- if state.cursor == state.input.len() {
- if let Some(completion_suffix) = edge.pattern.completion(remaining) {
- // Use the current_prefix plus the completion suffix
- let full_completion = format!("{}{}",
- state.current_prefix,
- completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)
- );
- return UIAction::suggest(
- state.original_query.clone(),
- Some(Suggestion {
- text: full_completion.clone(),
- completion: full_completion,
- description: edge.description.map(|d| d.to_string()),
- score: 1.0,
- })
- );
- }
- }
-
- // Continue parsing from the target node
- let result = self.parse_recursive(state);
-
- // If we got a valid response, return it
- if !matches!(result, UIAction::ShowError(_)) {
- return result;
- }
-
- // Otherwise, restore state and try next edge
- state.cursor = saved_cursor;
- state.current_node_id = saved_node;
- state.current_prefix = saved_prefix;
- }
- }
-
- // No edges matched - try to provide suggestions
- self.suggest_from_edges(&node_ref, state)
- }
-
- fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {
- let remaining = &state.input[state.cursor..];
-
- // Find edges that could match with more input
- for edge in &node.edges {
- match &edge.pattern {
- EdgePattern::PrefixOf(target) => {
- if target.starts_with(remaining) && !remaining.is_empty() {
- // Use current_prefix instead of rebuilding from input
- let full_completion = format!("{}{}", state.current_prefix, target);
- return UIAction::suggest(
- state.original_query.clone(),
- Some(Suggestion {
- text: full_completion.clone(),
- completion: full_completion,
- description: edge.description.map(|d| d.to_string()),
- score: 1.0,
- })
- );
- }
- }
- EdgePattern::Literal(lit) => {
-
… preview truncated; 152,463 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.