feat: add AI idea placement triage
This commit is contained in:
+419
-12
@@ -2,10 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import './index.css'
|
||||
import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
|
||||
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
|
||||
import { fetchBackendHealth, fetchRemoteState, pushRemoteState } from './store/remote'
|
||||
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
|
||||
import type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
|
||||
import { loadAppState, normalizeAppState, replaceAppState, saveAppState } from './store/storage'
|
||||
import { fetchBackendHealth, fetchRemoteState, pushRemoteState, triageIdeaWithAi } from './store/remote'
|
||||
import { AI_PLACEMENTS, FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
|
||||
import type { AiPlacement, AiRecommendation, AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
|
||||
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
|
||||
|
||||
const TABS: Array<{ key: TabKey; label: string }> = [
|
||||
@@ -46,6 +46,27 @@ const initialPulseDraft = {
|
||||
traceId: '',
|
||||
}
|
||||
|
||||
const initialTriageDraft = {
|
||||
rawIdea: '',
|
||||
optionalContext: '',
|
||||
}
|
||||
|
||||
const placementLabels: Record<AiPlacement, string> = {
|
||||
now: 'Now',
|
||||
next: 'Next',
|
||||
later: 'Later',
|
||||
parking_lot: 'Parking Lot',
|
||||
rejected: 'Rejected',
|
||||
duplicate: 'Duplicate',
|
||||
needs_clarification: 'Needs Clarification',
|
||||
}
|
||||
|
||||
const placementToFeatureColumn = (placement: AiPlacement): FeatureColumn =>
|
||||
placement === 'now' || placement === 'next' || placement === 'later' ? placement : 'next'
|
||||
|
||||
const riskToPriority = (risk: RiskLevel): (typeof FEATURE_PRIORITIES)[number] =>
|
||||
risk === 'low' ? 'should' : risk === 'medium' ? 'could' : 'later'
|
||||
|
||||
const columnLabels: Record<FeatureColumn, string> = {
|
||||
now: 'Now',
|
||||
next: 'Next',
|
||||
@@ -83,6 +104,19 @@ function App() {
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
||||
const [syncAction, setSyncAction] = useState<'idle' | 'refreshing' | 'pushing'>('idle')
|
||||
const [selectedStatusCardTitle, setSelectedStatusCardTitle] = useState('Project Cockpit')
|
||||
const [triageOpen, setTriageOpen] = useState(false)
|
||||
const [triageDraft, setTriageDraft] = useState(initialTriageDraft)
|
||||
const [triageRecommendation, setTriageRecommendation] = useState<AiRecommendation | null>(null)
|
||||
const [triageEditable, setTriageEditable] = useState({
|
||||
placement: 'parking_lot' as AiPlacement,
|
||||
risk: 'medium' as RiskLevel,
|
||||
title: '',
|
||||
description: '',
|
||||
acceptanceCriteria: '',
|
||||
parkingReason: '',
|
||||
})
|
||||
const [triageStatus, setTriageStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const [triageError, setTriageError] = useState('')
|
||||
const hasHydratedRemote = useRef(false)
|
||||
const initialLocalStateRef = useRef(appState)
|
||||
|
||||
@@ -98,9 +132,10 @@ function App() {
|
||||
const remoteState = await fetchRemoteState()
|
||||
if (cancelled) return
|
||||
|
||||
if (remoteState && validateAppState(remoteState)) {
|
||||
setAppState(remoteState)
|
||||
saveAppState(remoteState)
|
||||
const normalizedRemoteState = normalizeAppState(remoteState)
|
||||
if (normalizedRemoteState) {
|
||||
setAppState(normalizedRemoteState)
|
||||
saveAppState(normalizedRemoteState)
|
||||
setStatusMessage('Loaded state from Appwrite on the Unraid server.')
|
||||
} else {
|
||||
await pushRemoteState(initialLocalStateRef.current)
|
||||
@@ -330,9 +365,10 @@ function App() {
|
||||
|
||||
if (!health.ok) throw new Error('Backend health check failed.')
|
||||
|
||||
if (remoteState && validateAppState(remoteState)) {
|
||||
setAppState(remoteState)
|
||||
saveAppState(remoteState)
|
||||
const normalizedRemoteState = normalizeAppState(remoteState)
|
||||
if (normalizedRemoteState) {
|
||||
setAppState(normalizedRemoteState)
|
||||
saveAppState(normalizedRemoteState)
|
||||
setBackendMode('appwrite')
|
||||
setSyncStatus('synced')
|
||||
setLastSyncedAt(nowIso())
|
||||
@@ -370,6 +406,241 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const openTriage = (seed = '') => {
|
||||
setTriageOpen(true)
|
||||
setTriageError('')
|
||||
setTriageStatus(triageRecommendation ? 'ready' : 'idle')
|
||||
if (seed) {
|
||||
setTriageDraft((current) => ({ ...current, rawIdea: seed }))
|
||||
}
|
||||
}
|
||||
|
||||
const buildAiTriageContext = () => ({
|
||||
project: {
|
||||
name: appState.project.name,
|
||||
one_line_pitch: appState.project.one_line_pitch,
|
||||
current_goal: appState.project.current_goal,
|
||||
},
|
||||
current_scope: {
|
||||
in_scope: [
|
||||
'AI classifies new ideas into Now, Next, Later, Parking Lot, Rejected, Duplicate, or Needs Clarification.',
|
||||
'User accepts, edits, or rejects the recommendation.',
|
||||
'Every accepted or rejected decision is logged as a DECISION pulse.',
|
||||
],
|
||||
out_of_scope: [
|
||||
'phases',
|
||||
'releases',
|
||||
'agent integration',
|
||||
'OpenClaw or Hermes integration',
|
||||
'local/cloud model router',
|
||||
'multi-project support',
|
||||
'automatic feature building',
|
||||
],
|
||||
},
|
||||
features: FEATURE_COLUMNS.reduce<Record<FeatureColumn, Array<{ id: string; title: string; description: string; scope_notes: string }>>>((acc, column) => {
|
||||
acc[column] = groupedFeatures[column].map((feature) => ({
|
||||
id: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
scope_notes: feature.scope_notes,
|
||||
}))
|
||||
return acc
|
||||
}, { now: [], next: [], later: [], done: [] }),
|
||||
parking_lot: appState.parking_lot.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
reason_parked: item.reason_parked,
|
||||
})),
|
||||
recent_decisions: [...appState.pulses]
|
||||
.filter((pulse) => pulse.pulse_type === 'DECISION')
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.slice(0, 5)
|
||||
.map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })),
|
||||
})
|
||||
|
||||
const runAiTriage = async () => {
|
||||
const rawIdea = triageDraft.rawIdea.trim()
|
||||
if (!rawIdea) {
|
||||
setTriageError('Write the idea first. The scope goblin needs bait.')
|
||||
setTriageStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
setTriageStatus('loading')
|
||||
setTriageError('')
|
||||
|
||||
try {
|
||||
const recommendation = await triageIdeaWithAi({
|
||||
raw_idea: rawIdea,
|
||||
optional_context: triageDraft.optionalContext.trim(),
|
||||
app_context: buildAiTriageContext(),
|
||||
})
|
||||
const timestamp = nowIso()
|
||||
const fullRecommendation: AiRecommendation = {
|
||||
id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`,
|
||||
created_at: timestamp,
|
||||
raw_idea: rawIdea,
|
||||
optional_context: triageDraft.optionalContext.trim(),
|
||||
context_summary: `${appState.project.name}: ${appState.project.current_goal}`,
|
||||
...recommendation,
|
||||
user_decision: 'pending',
|
||||
created_feature_id: null,
|
||||
created_parking_item_id: null,
|
||||
decision_pulse_id: null,
|
||||
}
|
||||
|
||||
setTriageRecommendation(fullRecommendation)
|
||||
saveTriageRecommendation(fullRecommendation)
|
||||
setTriageEditable({
|
||||
placement: fullRecommendation.suggested_placement,
|
||||
risk: fullRecommendation.scope_risk,
|
||||
title: fullRecommendation.suggested_title,
|
||||
description: fullRecommendation.suggested_description,
|
||||
acceptanceCriteria: fullRecommendation.suggested_acceptance_criteria.join('\n'),
|
||||
parkingReason: fullRecommendation.suggested_parking_reason || fullRecommendation.reason,
|
||||
})
|
||||
setTriageStatus('ready')
|
||||
setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`)
|
||||
} catch (error) {
|
||||
setTriageStatus('error')
|
||||
setTriageError(error instanceof Error ? error.message : 'AI triage failed.')
|
||||
setStatusMessage('AI triage failed. Manual planning still works.')
|
||||
}
|
||||
}
|
||||
|
||||
const createTriageDecisionPulse = (recommendation: AiRecommendation, decision: string, featureId?: string) => {
|
||||
const timestamp = nowIso()
|
||||
return {
|
||||
id: `pulse_${Date.now().toString(36)}`,
|
||||
timestamp,
|
||||
project_id: appState.project.id,
|
||||
feature_id: featureId,
|
||||
source: 'ai_triage',
|
||||
agent_id: 'buildpulse_ai',
|
||||
pulse_type: 'DECISION' as const,
|
||||
message: `AI triaged idea “${recommendation.raw_idea}” as ${placementLabels[recommendation.suggested_placement]} with ${recommendation.scope_risk} scope risk. User decision: ${decision}.`,
|
||||
confidence_score: recommendation.confidence_score,
|
||||
evidence_refs: [
|
||||
'BuildPulse v0.2 AI Idea Placement',
|
||||
appState.project.current_goal || 'Current project goal',
|
||||
recommendation.reason,
|
||||
`Smallest safe version: ${recommendation.smallest_safe_version}`,
|
||||
],
|
||||
trace_id: recommendation.id,
|
||||
}
|
||||
}
|
||||
|
||||
const saveTriageRecommendation = (recommendation: AiRecommendation) => {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
ai_recommendations: [recommendation, ...current.ai_recommendations.filter((entry) => entry.id !== recommendation.id)],
|
||||
}))
|
||||
}
|
||||
|
||||
const acceptTriageAsFeature = () => {
|
||||
if (!triageRecommendation) return
|
||||
const title = triageEditable.title.trim() || triageRecommendation.suggested_title
|
||||
const timestamp = nowIso()
|
||||
const featureId = `feature_${slugify(title)}_${Date.now().toString(36)}`
|
||||
const pulse = createTriageDecisionPulse(triageRecommendation, 'accepted_as_feature', featureId)
|
||||
const updatedRecommendation: AiRecommendation = {
|
||||
...triageRecommendation,
|
||||
suggested_placement: triageEditable.placement,
|
||||
scope_risk: triageEditable.risk,
|
||||
suggested_title: title,
|
||||
suggested_description: triageEditable.description.trim(),
|
||||
suggested_acceptance_criteria: linesToArray(triageEditable.acceptanceCriteria),
|
||||
user_decision: 'accepted_as_feature',
|
||||
created_feature_id: featureId,
|
||||
created_parking_item_id: null,
|
||||
decision_pulse_id: pulse.id,
|
||||
}
|
||||
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
features: [
|
||||
{
|
||||
id: featureId,
|
||||
title,
|
||||
description: triageEditable.description.trim(),
|
||||
column: placementToFeatureColumn(triageEditable.placement),
|
||||
priority: riskToPriority(triageEditable.risk),
|
||||
status: 'idea',
|
||||
acceptance_criteria: linesToArray(triageEditable.acceptanceCriteria),
|
||||
scope_notes: [`AI triage: ${triageRecommendation.reason}`, `Smallest safe version: ${triageRecommendation.smallest_safe_version}`].join('\n'),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
},
|
||||
...current.features,
|
||||
],
|
||||
pulses: [pulse, ...current.pulses],
|
||||
ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)],
|
||||
}))
|
||||
setSelectedFeatureId(featureId)
|
||||
setActiveTab('feature-plan')
|
||||
setStatusMessage(`Accepted AI triage as feature “${title}”. DECISION pulse logged.`)
|
||||
}
|
||||
|
||||
const acceptTriageAsParkingLot = () => {
|
||||
if (!triageRecommendation) return
|
||||
const title = triageEditable.title.trim() || triageRecommendation.suggested_title
|
||||
const timestamp = nowIso()
|
||||
const parkingId = `parked_${slugify(title)}_${Date.now().toString(36)}`
|
||||
const pulse = createTriageDecisionPulse(triageRecommendation, 'accepted_as_parking_lot')
|
||||
const updatedRecommendation: AiRecommendation = {
|
||||
...triageRecommendation,
|
||||
suggested_placement: 'parking_lot',
|
||||
scope_risk: triageEditable.risk,
|
||||
suggested_title: title,
|
||||
suggested_description: triageEditable.description.trim(),
|
||||
suggested_parking_reason: triageEditable.parkingReason.trim(),
|
||||
user_decision: 'accepted_as_parking_lot',
|
||||
created_feature_id: null,
|
||||
created_parking_item_id: parkingId,
|
||||
decision_pulse_id: pulse.id,
|
||||
}
|
||||
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
parking_lot: [
|
||||
{
|
||||
id: parkingId,
|
||||
title,
|
||||
description: triageEditable.description.trim(),
|
||||
reason_parked: triageEditable.parkingReason.trim() || triageRecommendation.reason,
|
||||
possible_future_placement: triageEditable.placement === 'later' ? 'Later' : 'v0.3+',
|
||||
risk_level: triageEditable.risk,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
},
|
||||
...current.parking_lot,
|
||||
],
|
||||
pulses: [pulse, ...current.pulses],
|
||||
ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)],
|
||||
}))
|
||||
setSelectedParkingId(parkingId)
|
||||
setActiveTab('parking-lot')
|
||||
setStatusMessage(`Accepted AI triage as Parking Lot item “${title}”. DECISION pulse logged.`)
|
||||
}
|
||||
|
||||
const rejectTriageRecommendation = () => {
|
||||
if (!triageRecommendation) return
|
||||
const pulse = createTriageDecisionPulse(triageRecommendation, 'rejected')
|
||||
const updatedRecommendation: AiRecommendation = {
|
||||
...triageRecommendation,
|
||||
user_decision: 'rejected',
|
||||
decision_pulse_id: pulse.id,
|
||||
}
|
||||
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
pulses: [pulse, ...current.pulses],
|
||||
ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)],
|
||||
}))
|
||||
setStatusMessage('Rejected AI triage recommendation. DECISION pulse logged; no feature created.')
|
||||
}
|
||||
|
||||
const openFeatureHandoff = (featureId: string) => {
|
||||
setPromptFeatureId(featureId)
|
||||
setPromptTarget('OpenClaw')
|
||||
@@ -597,11 +868,12 @@ function App() {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const parsed = JSON.parse(text)
|
||||
if (!validateAppState(parsed)) {
|
||||
const normalized = normalizeAppState(parsed)
|
||||
if (!normalized) {
|
||||
setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.')
|
||||
return
|
||||
}
|
||||
setAppState(replaceAppState(parsed))
|
||||
setAppState(replaceAppState(normalized))
|
||||
setStatusMessage('Import complete. State replaced cleanly.')
|
||||
} catch {
|
||||
setStatusMessage('Import failed: invalid JSON.')
|
||||
@@ -728,6 +1000,135 @@ function App() {
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{triageOpen && (
|
||||
<section className="card triage-panel" aria-label="AI Idea Placement">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<p className="eyebrow">BuildPulse v0.2</p>
|
||||
<h2>AI Idea Placement</h2>
|
||||
<p>AI advises where a raw idea belongs. You decide what gets saved.</p>
|
||||
</div>
|
||||
<button type="button" className="ghost small" onClick={() => setTriageOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="triage-layout">
|
||||
<div className="triage-input">
|
||||
<label>
|
||||
Raw idea
|
||||
<textarea
|
||||
rows={4}
|
||||
value={triageDraft.rawIdea}
|
||||
onChange={(event) => setTriageDraft((current) => ({ ...current, rawIdea: event.target.value }))}
|
||||
placeholder="Example: Add live WebSocket agent telemetry"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Optional context
|
||||
<textarea
|
||||
rows={3}
|
||||
value={triageDraft.optionalContext}
|
||||
onChange={(event) => setTriageDraft((current) => ({ ...current, optionalContext: event.target.value }))}
|
||||
placeholder="Anything the scope guardian should know. Keep it short."
|
||||
/>
|
||||
</label>
|
||||
<div className="button-inline-row">
|
||||
<button type="button" onClick={() => void runAiTriage()} disabled={triageStatus === 'loading'}>
|
||||
{triageStatus === 'loading' ? 'Analyzing…' : 'Analyze Idea'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
setTriageDraft(initialTriageDraft)
|
||||
setTriageRecommendation(null)
|
||||
setTriageStatus('idle')
|
||||
setTriageError('')
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{triageStatus === 'error' && <p className="triage-error">{triageError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="triage-result">
|
||||
{triageRecommendation ? (
|
||||
<>
|
||||
<div className="triage-result-header">
|
||||
<span className="pill status-healthy">{placementLabels[triageRecommendation.suggested_placement]}</span>
|
||||
<span className={`pill risk-${triageRecommendation.scope_risk}`}>{triageRecommendation.scope_risk} risk</span>
|
||||
<span className="pill">{Math.round(triageRecommendation.confidence_score * 100)}% confidence</span>
|
||||
</div>
|
||||
<div className="triage-callout">
|
||||
<strong>Reason</strong>
|
||||
<p>{triageRecommendation.reason}</p>
|
||||
<strong>Smallest safe version</strong>
|
||||
<p>{triageRecommendation.smallest_safe_version}</p>
|
||||
</div>
|
||||
{triageRecommendation.duplicate_check.is_duplicate && (
|
||||
<div className="triage-callout warning">
|
||||
<strong>Possible duplicate</strong>
|
||||
<p>{triageRecommendation.duplicate_check.reason || 'AI detected overlap with an existing item.'}</p>
|
||||
<small>{triageRecommendation.duplicate_check.duplicate_type}: {triageRecommendation.duplicate_check.duplicate_id}</small>
|
||||
</div>
|
||||
)}
|
||||
{triageRecommendation.clarifying_question && (
|
||||
<div className="triage-callout warning">
|
||||
<strong>Clarifying question</strong>
|
||||
<p>{triageRecommendation.clarifying_question}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Placement
|
||||
<select value={triageEditable.placement} onChange={(event) => setTriageEditable((current) => ({ ...current, placement: event.target.value as AiPlacement }))}>
|
||||
{AI_PLACEMENTS.map((placement) => (
|
||||
<option key={placement} value={placement}>{placementLabels[placement]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Scope risk
|
||||
<select value={triageEditable.risk} onChange={(event) => setTriageEditable((current) => ({ ...current, risk: event.target.value as RiskLevel }))}>
|
||||
{RISK_LEVELS.map((risk) => (
|
||||
<option key={risk} value={risk}>{risk}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Suggested title
|
||||
<input value={triageEditable.title} onChange={(event) => setTriageEditable((current) => ({ ...current, title: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Suggested description
|
||||
<textarea rows={3} value={triageEditable.description} onChange={(event) => setTriageEditable((current) => ({ ...current, description: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Acceptance criteria, if saved as feature
|
||||
<textarea rows={4} value={triageEditable.acceptanceCriteria} onChange={(event) => setTriageEditable((current) => ({ ...current, acceptanceCriteria: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Parking reason, if parked
|
||||
<textarea rows={3} value={triageEditable.parkingReason} onChange={(event) => setTriageEditable((current) => ({ ...current, parkingReason: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={acceptTriageAsFeature}>Accept as Feature</button>
|
||||
<button type="button" onClick={acceptTriageAsParkingLot}>Accept as Parking Lot</button>
|
||||
<button type="button" className="danger" onClick={rejectTriageRecommendation}>Reject + Log Decision</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">No recommendation yet. Feed the scope guardian one idea, not the whole haunted roadmap.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'feature-plan' && (
|
||||
<>
|
||||
<header className="hero-card">
|
||||
@@ -969,6 +1370,9 @@ function App() {
|
||||
<h2>Feature Plan</h2>
|
||||
<p>Lead with focus, not overview. This is the calm “what now?” screen.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => openTriage()}>
|
||||
Triage Idea with AI
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFeature && (
|
||||
@@ -1205,6 +1609,9 @@ function App() {
|
||||
<h2>Parking Lot</h2>
|
||||
<p>This is where useful distractions go so they do not hijack the build.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => openTriage()}>
|
||||
Triage Idea with AI
|
||||
</button>
|
||||
</div>
|
||||
<div className="editor-grid">
|
||||
<section className="card editor-card">
|
||||
|
||||
Reference in New Issue
Block a user