Files
rfc-app/frontend/src/components/ProposeModal.jsx
T
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:13 -07:00

260 lines
10 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 '<term>'" affordance pre-fills the title
// (App passes the `?propose=<term>` 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 (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Propose a New RFC</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<label htmlFor="propose-title">Title</label>
<input
id="propose-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. Open Human Model"
autoFocus
required
/>
<p className="field-help">The word or topic this RFC will define.</p>
<label htmlFor="propose-slug">Slug</label>
<input
id="propose-slug"
value={slug}
onChange={e => { setSlug(slugify(e.target.value)); setSlugEdited(true) }}
placeholder="open-human-model"
required
/>
<p className="field-help">Auto-derived from the title; edit if needed.</p>
<label htmlFor="propose-pitch">Why is this RFC needed?</label>
<textarea
id="propose-pitch"
value={pitch}
onChange={e => setPitch(e.target.value)}
placeholder="One or two paragraphs answering 'why this RFC is needed.'"
rows={5}
required
/>
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
<textarea
id="propose-use-case"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
rows={3}
/>
<p className="field-help">
The concrete ground-truth use case distinct from "why it's
needed" above. Leave blank if you'd rather not say.
</p>
<label htmlFor="propose-tag">Tags (optional)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<input
id="propose-tag"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag() }
}}
placeholder="identity, schema"
style={{ marginBottom: 0 }}
/>
<button type="button" className="btn-secondary" onClick={addTag} style={{ padding: '6px 12px' }}>
Add
</button>
</div>
{tags.length > 0 && (
<div style={{ marginTop: 4, marginBottom: 14 }}>
{tags.map(t => (
<span key={t} className="entry-tag" style={{ display: 'inline-block', marginRight: 4 }}>
{t}{' '}
<button
type="button"
onClick={() => setTags(tags.filter(x => x !== t))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#999' }}
>×</button>
</span>
))}
</div>
)}
{/* #27: Claude Haiku suggested tags. Clickable chips that add
to the tag list; nothing auto-applies. The disclosure is
required — the draft text is sent to Anthropic to generate
these — so it renders whenever the suggestion row does. */}
{(freshSuggestions.length > 0 || (suggestedOnce && suggestions.length > 0)) && (
<div style={{ marginTop: 4, marginBottom: 14 }}>
{freshSuggestions.length > 0 && (
<>
<p className="field-help" style={{ marginTop: 0, marginBottom: 4 }}>
Suggested tags — click to add:
</p>
<div>
{freshSuggestions.map(s => (
<button
key={s.tag}
type="button"
className="entry-tag"
onClick={() => addSuggested(s.tag)}
title={`Add "${s.tag}"`}
style={{
display: 'inline-block',
marginRight: 4,
marginBottom: 4,
border: '1px dashed var(--color-border, #ccc)',
background: 'none',
cursor: 'pointer',
}}
>+ {s.tag}</button>
))}
</div>
</>
)}
<p className="field-help" style={{ marginTop: 4, marginBottom: 0, fontStyle: 'italic' }}>
Suggestions are generated by Claude (Anthropic). The text you've
entered above is sent to Anthropic to produce them.
</p>
</div>
)}
{viewer && (
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
</p>
)}
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={!title.trim() || !slug || !pitch.trim() || submitting}
>
{submitting ? 'Opening PR' : 'Open proposal PR'}
</button>
</div>
</form>
</div>
</div>
)
}