// ProposeModal.jsx — §9.1. // // Title (required) and pitch (required textarea), with a slug field // that auto-fills from the title via the same deterministic kebab-case // the backend uses. Tags are chip-input (free-form), with the §9.1 // Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills // in, the backend asks Claude Haiku for tags drawn from the corpus's // existing tag set, surfaced as clickable suggestion chips. The assist // is best-effort — it stays silent when unavailable. // // The submit button drives the §17 POST /api/rfcs/propose endpoint; // success navigates the proposer to the pending-idea view per §9.3. import { useEffect, useRef, useState } from 'react' import { proposeRFC, suggestTags } from '../api' import { EVENTS, track } from '../lib/analytics' // How long the draft must sit unchanged before we ask for suggestions — // long enough to fire on typing pauses / field-blur, not on every // keystroke (the backend is also per-user rate-limited as a backstop). const SUGGEST_DEBOUNCE_MS = 700 function slugify(title) { return title .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') } export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) { // #28 Part 2: a "create RFC for ''" affordance pre-fills the title // (App passes the `?propose=` value here); the slug derives from it // via the same effect that drives manual typing. const [title, setTitle] = useState(initialTitle) const [slug, setSlug] = useState('') const [slugEdited, setSlugEdited] = useState(false) const [pitch, setPitch] = useState('') // #26: optional ground-truth use case, sibling to the required pitch. const [useCase, setUseCase] = useState('') const [tagInput, setTagInput] = useState('') const [tags, setTags] = useState([]) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) // #27: Claude Haiku tag suggestions. `suggestions` is the latest // ranked list from the backend ({ tag, confidence }); we render the // subset not already chosen. `suggestedOnce` gates the disclosure + // row so they only appear after the assist has actually run. const [suggestions, setSuggestions] = useState([]) const [suggestedOnce, setSuggestedOnce] = useState(false) useEffect(() => { if (!slugEdited) setSlug(slugify(title)) }, [title, slugEdited]) // #27: debounced tag-suggestion fetch. Fires after the draft sits // unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go // on (a title). A stale-response guard keeps an earlier in-flight // request from clobbering a newer one. const suggestSeq = useRef(0) useEffect(() => { if (!title.trim()) { setSuggestions([]) return } const handle = setTimeout(async () => { const seq = ++suggestSeq.current const result = await suggestTags({ title, pitch, useCase }) if (seq !== suggestSeq.current) return // a newer request superseded us setSuggestions(Array.isArray(result) ? result : []) if (result && result.length) setSuggestedOnce(true) }, SUGGEST_DEBOUNCE_MS) return () => clearTimeout(handle) }, [title, pitch, useCase]) // Suggestions the user hasn't already added. const freshSuggestions = suggestions.filter(s => !tags.includes(s.tag)) function addTag() { const t = tagInput.trim() if (t && !tags.includes(t)) setTags([...tags, t]) setTagInput('') } function addSuggested(tag) { if (!tags.includes(tag)) setTags([...tags, tag]) } async function handleSubmit(e) { e.preventDefault() if (!title.trim() || !slug || !pitch.trim()) return setSubmitting(true) setError(null) try { const result = await proposeRFC({ title: title.trim(), slug, pitch: pitch.trim(), tags, proposedUseCase: useCase.trim() || null, }) // v0.15.0 — analytics: fire on the §9.1 propose-RFC submit. // Slug is a stable, low-cardinality identifier (kebab-case // ascii); title and pitch stay out of the event body. track(EVENTS.RFC_PROPOSED, { rfc_slug: slug }) onSubmitted?.(result) } catch (err) { setError(err.message || 'Submission failed.') } finally { setSubmitting(false) } } return (
{ if (e.target === e.currentTarget) onClose() }}>

Propose a New RFC

setTitle(e.target.value)} placeholder="e.g. Open Human Model" autoFocus required />

The word or topic this RFC will define.

{ setSlug(slugify(e.target.value)); setSlugEdited(true) }} placeholder="open-human-model" required />

Auto-derived from the title; edit if needed.