3c9109c392
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>
260 lines
10 KiB
React
260 lines
10 KiB
React
// 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>
|
||
)
|
||
}
|