1946 lines
81 KiB
TypeScript
1946 lines
81 KiB
TypeScript
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, 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 }> = [
|
||
{ key: 'feature-plan', label: 'Feature Plan' },
|
||
{ key: 'parking-lot', label: 'Parking Lot' },
|
||
{ key: 'pulse-log', label: 'Pulse Log' },
|
||
{ key: 'export', label: 'Export' },
|
||
]
|
||
|
||
const PROMPT_TARGETS = ['OpenClaw', 'Claude Code', 'Codex', 'Generic Agent'] as const
|
||
|
||
const initialFeatureDraft = {
|
||
title: '',
|
||
description: '',
|
||
column: 'now' as FeatureColumn,
|
||
priority: 'must' as (typeof FEATURE_PRIORITIES)[number],
|
||
status: 'idea' as (typeof FEATURE_STATUSES)[number],
|
||
acceptanceCriteria: '',
|
||
scopeNotes: '',
|
||
}
|
||
|
||
const initialParkingDraft = {
|
||
title: '',
|
||
description: '',
|
||
reasonParked: '',
|
||
futurePlacement: '',
|
||
riskLevel: 'medium' as RiskLevel,
|
||
}
|
||
|
||
const initialPulseDraft = {
|
||
featureId: '',
|
||
source: 'manual',
|
||
agentId: 'jimmi',
|
||
pulseType: 'INTENT' as (typeof PULSE_TYPES)[number],
|
||
message: '',
|
||
confidence: '0.9',
|
||
evidenceRefs: '',
|
||
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',
|
||
later: 'Later',
|
||
done: 'Done',
|
||
}
|
||
|
||
const downloadText = (filename: string, text: string, contentType = 'text/plain;charset=utf-8') => {
|
||
const blob = new Blob([text], { type: contentType })
|
||
const url = URL.createObjectURL(blob)
|
||
const anchor = document.createElement('a')
|
||
anchor.href = url
|
||
anchor.download = filename
|
||
anchor.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
function App() {
|
||
const [appState, setAppState] = useState<AppState>(() => loadAppState())
|
||
const [activeTab, setActiveTab] = useState<TabKey>('feature-plan')
|
||
const [statusMessage, setStatusMessage] = useState('Seeded with BuildPulse so you can dogfood it immediately.')
|
||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null)
|
||
const [selectedParkingId, setSelectedParkingId] = useState<string | null>(null)
|
||
const [selectedPulseId, setSelectedPulseId] = useState<string | null>(null)
|
||
const [featureDraft, setFeatureDraft] = useState(initialFeatureDraft)
|
||
const [parkingDraft, setParkingDraft] = useState(initialParkingDraft)
|
||
const [pulseDraft, setPulseDraft] = useState(initialPulseDraft)
|
||
const [pulseTypeFilter, setPulseTypeFilter] = useState('all')
|
||
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
|
||
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
|
||
const [promptTarget, setPromptTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw')
|
||
const [promptFeatureId, setPromptFeatureId] = useState('')
|
||
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
|
||
const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting')
|
||
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)
|
||
|
||
useEffect(() => {
|
||
saveAppState(appState)
|
||
}, [appState])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
const hydrate = async () => {
|
||
try {
|
||
const remoteState = await fetchRemoteState()
|
||
if (cancelled) return
|
||
|
||
const normalizedRemoteState = normalizeAppState(remoteState)
|
||
if (normalizedRemoteState) {
|
||
setAppState(normalizedRemoteState)
|
||
saveAppState(normalizedRemoteState)
|
||
setStatusMessage('Loaded state from Appwrite on the Unraid server.')
|
||
} else {
|
||
await pushRemoteState(initialLocalStateRef.current)
|
||
if (cancelled) return
|
||
setStatusMessage('Seeded Appwrite on Unraid from the local BuildPulse state.')
|
||
}
|
||
setBackendMode('appwrite')
|
||
setSyncStatus('synced')
|
||
setLastSyncedAt(nowIso())
|
||
} catch {
|
||
if (cancelled) return
|
||
setBackendMode('local-cache')
|
||
setSyncStatus('degraded')
|
||
setStatusMessage('Appwrite backend unavailable, so BuildPulse is using the local cache for now.')
|
||
} finally {
|
||
hasHydratedRemote.current = true
|
||
}
|
||
}
|
||
|
||
void hydrate()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!hasHydratedRemote.current || backendMode !== 'appwrite') return
|
||
|
||
setSyncStatus('pending')
|
||
const timer = window.setTimeout(() => {
|
||
setSyncStatus('syncing')
|
||
void pushRemoteState(appState)
|
||
.then(() => {
|
||
setSyncStatus('synced')
|
||
setLastSyncedAt(nowIso())
|
||
})
|
||
.catch(() => {
|
||
setBackendMode('local-cache')
|
||
setSyncStatus('degraded')
|
||
setStatusMessage('Saved locally. Appwrite sync tripped over itself, so the cache is carrying the load.')
|
||
})
|
||
}, 350)
|
||
|
||
return () => window.clearTimeout(timer)
|
||
}, [appState, backendMode])
|
||
|
||
const groupedFeatures = useMemo(
|
||
() =>
|
||
FEATURE_COLUMNS.reduce<Record<FeatureColumn, Feature[]>>((acc, column) => {
|
||
acc[column] = appState.features.filter((feature) => feature.column === column)
|
||
return acc
|
||
}, { now: [], next: [], later: [], done: [] }),
|
||
[appState.features],
|
||
)
|
||
|
||
const selectedFeature = useMemo(
|
||
() => appState.features.find((feature) => feature.id === selectedFeatureId) ?? null,
|
||
[appState.features, selectedFeatureId],
|
||
)
|
||
|
||
const selectedParkingItem = useMemo(
|
||
() => appState.parking_lot.find((item) => item.id === selectedParkingId) ?? null,
|
||
[appState.parking_lot, selectedParkingId],
|
||
)
|
||
|
||
const selectedPulse = useMemo(
|
||
() => appState.pulses.find((pulse) => pulse.id === selectedPulseId) ?? null,
|
||
[appState.pulses, selectedPulseId],
|
||
)
|
||
|
||
const featurePulseMeta = useMemo(() => {
|
||
const meta = new Map<string, { count: number; latest: PulseEvent | null }>()
|
||
|
||
for (const pulse of [...appState.pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp))) {
|
||
if (!pulse.feature_id) continue
|
||
|
||
const current = meta.get(pulse.feature_id)
|
||
if (current) {
|
||
current.count += 1
|
||
continue
|
||
}
|
||
|
||
meta.set(pulse.feature_id, { count: 1, latest: pulse })
|
||
}
|
||
|
||
return meta
|
||
}, [appState.pulses])
|
||
|
||
const selectedFeaturePulses = useMemo(() => {
|
||
if (!selectedFeature) return []
|
||
|
||
return [...appState.pulses]
|
||
.filter((pulse) => pulse.feature_id === selectedFeature.id)
|
||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||
.slice(0, 4)
|
||
}, [appState.pulses, selectedFeature])
|
||
|
||
const filteredPulses = useMemo(() => {
|
||
return [...appState.pulses]
|
||
.filter((pulse) => (pulseTypeFilter === 'all' ? true : pulse.pulse_type === pulseTypeFilter))
|
||
.filter((pulse) => (pulseFeatureFilter === 'all' ? true : pulse.feature_id === pulseFeatureFilter))
|
||
.filter((pulse) => (pulseSourceFilter === 'all' ? true : pulse.source === pulseSourceFilter || pulse.agent_id === pulseSourceFilter))
|
||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||
}, [appState.pulses, pulseFeatureFilter, pulseSourceFilter, pulseTypeFilter])
|
||
|
||
const uniqueSources = useMemo(
|
||
() => Array.from(new Set(appState.pulses.flatMap((pulse) => [pulse.source, pulse.agent_id]).filter(Boolean))).sort(),
|
||
[appState.pulses],
|
||
)
|
||
|
||
const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState])
|
||
const sessionPrompt = useMemo(
|
||
() => createAgentSessionPrompt(appState, { featureId: promptFeatureId || undefined, target: promptTarget }),
|
||
[appState, promptFeatureId, promptTarget],
|
||
)
|
||
|
||
const updateProject = (field: keyof AppState['project'], value: string) => {
|
||
setAppState((current) => ({
|
||
...current,
|
||
project: {
|
||
...current.project,
|
||
[field]: value,
|
||
updated_at: nowIso(),
|
||
},
|
||
}))
|
||
}
|
||
|
||
const beginFeatureEdit = (feature: Feature) => {
|
||
setSelectedFeatureId(feature.id)
|
||
setFeatureDraft({
|
||
title: feature.title,
|
||
description: feature.description,
|
||
column: feature.column,
|
||
priority: feature.priority,
|
||
status: feature.status,
|
||
acceptanceCriteria: arrayToLines(feature.acceptance_criteria),
|
||
scopeNotes: feature.scope_notes,
|
||
})
|
||
}
|
||
|
||
const resetFeatureDraft = () => {
|
||
setSelectedFeatureId(null)
|
||
setFeatureDraft(initialFeatureDraft)
|
||
}
|
||
|
||
const saveFeature = () => {
|
||
if (!featureDraft.title.trim()) {
|
||
setStatusMessage('Feature title is required. Tiny cockpit, tiny guardrails.')
|
||
return
|
||
}
|
||
|
||
const timestamp = nowIso()
|
||
const acceptanceCriteria = linesToArray(featureDraft.acceptanceCriteria)
|
||
|
||
if (selectedFeatureId) {
|
||
setAppState((current) => ({
|
||
...current,
|
||
features: current.features.map((feature) =>
|
||
feature.id === selectedFeatureId
|
||
? {
|
||
...feature,
|
||
title: featureDraft.title.trim(),
|
||
description: featureDraft.description.trim(),
|
||
column: featureDraft.column,
|
||
priority: featureDraft.priority,
|
||
status: featureDraft.status,
|
||
acceptance_criteria: acceptanceCriteria,
|
||
scope_notes: featureDraft.scopeNotes.trim(),
|
||
updated_at: timestamp,
|
||
}
|
||
: feature,
|
||
),
|
||
}))
|
||
setStatusMessage('Feature updated.')
|
||
} else {
|
||
const title = featureDraft.title.trim()
|
||
const id = `feature_${slugify(title)}_${Date.now().toString(36)}`
|
||
setAppState((current) => ({
|
||
...current,
|
||
features: [
|
||
{
|
||
id,
|
||
title,
|
||
description: featureDraft.description.trim(),
|
||
column: featureDraft.column,
|
||
priority: featureDraft.priority,
|
||
status: featureDraft.status,
|
||
acceptance_criteria: acceptanceCriteria,
|
||
scope_notes: featureDraft.scopeNotes.trim(),
|
||
created_at: timestamp,
|
||
updated_at: timestamp,
|
||
},
|
||
...current.features,
|
||
],
|
||
}))
|
||
setStatusMessage(`Feature “${title}” added.`)
|
||
}
|
||
|
||
resetFeatureDraft()
|
||
}
|
||
|
||
const deleteFeature = (featureId: string) => {
|
||
setAppState((current) => ({
|
||
...current,
|
||
features: current.features.filter((feature) => feature.id !== featureId),
|
||
}))
|
||
if (selectedFeatureId === featureId) resetFeatureDraft()
|
||
setStatusMessage('Feature removed. Related pulses stay intact with a graceful missing-feature label.')
|
||
}
|
||
|
||
const quickMoveFeature = (featureId: string, column: FeatureColumn) => {
|
||
setAppState((current) => ({
|
||
...current,
|
||
features: current.features.map((feature) =>
|
||
feature.id === featureId ? { ...feature, column, updated_at: nowIso() } : feature,
|
||
),
|
||
}))
|
||
}
|
||
|
||
const refreshFromBackend = async () => {
|
||
setSyncAction('refreshing')
|
||
setSyncStatus('syncing')
|
||
|
||
try {
|
||
const [health, remoteState] = await Promise.all([fetchBackendHealth(), fetchRemoteState()])
|
||
|
||
if (!health.ok) throw new Error('Backend health check failed.')
|
||
|
||
const normalizedRemoteState = normalizeAppState(remoteState)
|
||
if (normalizedRemoteState) {
|
||
setAppState(normalizedRemoteState)
|
||
saveAppState(normalizedRemoteState)
|
||
setBackendMode('appwrite')
|
||
setSyncStatus('synced')
|
||
setLastSyncedAt(nowIso())
|
||
setStatusMessage('Reloaded BuildPulse state from Appwrite.')
|
||
} else {
|
||
setBackendMode('appwrite')
|
||
setSyncStatus('synced')
|
||
setStatusMessage('Backend reachable, but there is no valid remote state to reload yet.')
|
||
}
|
||
} catch {
|
||
setBackendMode('local-cache')
|
||
setSyncStatus('degraded')
|
||
setStatusMessage('Refresh failed. Staying on the local cache until Appwrite behaves again.')
|
||
} finally {
|
||
setSyncAction('idle')
|
||
}
|
||
}
|
||
|
||
const forceSyncNow = async () => {
|
||
setSyncAction('pushing')
|
||
setSyncStatus('syncing')
|
||
|
||
try {
|
||
await pushRemoteState(appState)
|
||
setBackendMode('appwrite')
|
||
setSyncStatus('synced')
|
||
setLastSyncedAt(nowIso())
|
||
setStatusMessage('Forced a clean sync to Appwrite.')
|
||
} catch {
|
||
setBackendMode('local-cache')
|
||
setSyncStatus('degraded')
|
||
setStatusMessage('Forced sync failed. Local cache still has the wheel.')
|
||
} finally {
|
||
setSyncAction('idle')
|
||
}
|
||
}
|
||
|
||
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')
|
||
setActiveTab('export')
|
||
|
||
const feature = appState.features.find((entry) => entry.id === featureId)
|
||
setStatusMessage(feature ? `Prepared AI handoff for “${feature.title}”.` : 'Prepared AI handoff prompt.')
|
||
}
|
||
|
||
const openFeaturePulse = (featureId: string) => {
|
||
const feature = appState.features.find((entry) => entry.id === featureId)
|
||
setSelectedPulseId(null)
|
||
setPulseDraft((current) => ({
|
||
...initialPulseDraft,
|
||
source: current.source,
|
||
agentId: current.agentId,
|
||
featureId,
|
||
pulseType: 'ACTION',
|
||
}))
|
||
setActiveTab('pulse-log')
|
||
setStatusMessage(feature ? `Pulse composer aimed at “${feature.title}”.` : 'Pulse composer ready.')
|
||
}
|
||
|
||
const beginParkingEdit = (item: ParkingLotItem) => {
|
||
setSelectedParkingId(item.id)
|
||
setParkingDraft({
|
||
title: item.title,
|
||
description: item.description,
|
||
reasonParked: item.reason_parked,
|
||
futurePlacement: item.possible_future_placement,
|
||
riskLevel: item.risk_level,
|
||
})
|
||
}
|
||
|
||
const resetParkingDraft = () => {
|
||
setSelectedParkingId(null)
|
||
setParkingDraft(initialParkingDraft)
|
||
}
|
||
|
||
const saveParkingItem = () => {
|
||
if (!parkingDraft.title.trim()) {
|
||
setStatusMessage('Parking Lot items need a title.')
|
||
return
|
||
}
|
||
|
||
const timestamp = nowIso()
|
||
if (selectedParkingId) {
|
||
setAppState((current) => ({
|
||
...current,
|
||
parking_lot: current.parking_lot.map((item) =>
|
||
item.id === selectedParkingId
|
||
? {
|
||
...item,
|
||
title: parkingDraft.title.trim(),
|
||
description: parkingDraft.description.trim(),
|
||
reason_parked: parkingDraft.reasonParked.trim(),
|
||
possible_future_placement: parkingDraft.futurePlacement.trim(),
|
||
risk_level: parkingDraft.riskLevel,
|
||
updated_at: timestamp,
|
||
}
|
||
: item,
|
||
),
|
||
}))
|
||
setStatusMessage('Parking Lot item updated.')
|
||
} else {
|
||
const title = parkingDraft.title.trim()
|
||
setAppState((current) => ({
|
||
...current,
|
||
parking_lot: [
|
||
{
|
||
id: `parked_${slugify(title)}_${Date.now().toString(36)}`,
|
||
title,
|
||
description: parkingDraft.description.trim(),
|
||
reason_parked: parkingDraft.reasonParked.trim(),
|
||
possible_future_placement: parkingDraft.futurePlacement.trim(),
|
||
risk_level: parkingDraft.riskLevel,
|
||
created_at: timestamp,
|
||
updated_at: timestamp,
|
||
},
|
||
...current.parking_lot,
|
||
],
|
||
}))
|
||
setStatusMessage(`Parked “${title}” safely.`)
|
||
}
|
||
|
||
resetParkingDraft()
|
||
}
|
||
|
||
const deleteParkingItem = (itemId: string) => {
|
||
setAppState((current) => ({
|
||
...current,
|
||
parking_lot: current.parking_lot.filter((item) => item.id !== itemId),
|
||
}))
|
||
if (selectedParkingId === itemId) resetParkingDraft()
|
||
setStatusMessage('Parking Lot item removed.')
|
||
}
|
||
|
||
const convertParkingItemToFeature = (item: ParkingLotItem) => {
|
||
const timestamp = nowIso()
|
||
setAppState((current) => ({
|
||
...current,
|
||
features: [
|
||
{
|
||
id: `feature_${slugify(item.title)}_${Date.now().toString(36)}`,
|
||
title: item.title,
|
||
description: item.description,
|
||
column: 'next',
|
||
priority: 'could',
|
||
status: 'idea',
|
||
acceptance_criteria: [],
|
||
scope_notes: `Converted from Parking Lot. Original reason parked: ${item.reason_parked}`,
|
||
created_at: timestamp,
|
||
updated_at: timestamp,
|
||
},
|
||
...current.features,
|
||
],
|
||
parking_lot: current.parking_lot.filter((entry) => entry.id !== item.id),
|
||
pulses: [
|
||
{
|
||
id: `pulse_${Date.now().toString(36)}`,
|
||
timestamp,
|
||
project_id: current.project.id,
|
||
source: 'manual',
|
||
agent_id: current.settings.default_agent_id,
|
||
pulse_type: 'PARKED_IDEA',
|
||
message: `Converted parked idea “${item.title}” into a Next feature.`,
|
||
confidence_score: 0.8,
|
||
evidence_refs: ['Converted from Parking Lot'],
|
||
},
|
||
...current.pulses,
|
||
],
|
||
}))
|
||
setStatusMessage(`Converted “${item.title}” into a feature.`)
|
||
if (selectedParkingId === item.id) resetParkingDraft()
|
||
}
|
||
|
||
const beginPulseEdit = (pulse: PulseEvent) => {
|
||
setSelectedPulseId(pulse.id)
|
||
setPulseDraft({
|
||
featureId: pulse.feature_id ?? '',
|
||
source: pulse.source,
|
||
agentId: pulse.agent_id,
|
||
pulseType: pulse.pulse_type,
|
||
message: pulse.message,
|
||
confidence: String(pulse.confidence_score),
|
||
evidenceRefs: arrayToLines(pulse.evidence_refs),
|
||
traceId: pulse.trace_id ?? '',
|
||
})
|
||
}
|
||
|
||
const resetPulseDraft = () => {
|
||
setSelectedPulseId(null)
|
||
setPulseDraft(initialPulseDraft)
|
||
}
|
||
|
||
const savePulse = () => {
|
||
if (!pulseDraft.message.trim()) {
|
||
setStatusMessage('Pulse message is required.')
|
||
return
|
||
}
|
||
|
||
const timestamp = nowIso()
|
||
const pulse: PulseEvent = {
|
||
id: selectedPulseId ?? `pulse_${Date.now().toString(36)}`,
|
||
timestamp: selectedPulse?.timestamp ?? timestamp,
|
||
project_id: appState.project.id,
|
||
feature_id: pulseDraft.featureId || undefined,
|
||
source: pulseDraft.source.trim() || 'manual',
|
||
agent_id: pulseDraft.agentId.trim() || appState.settings.default_agent_id,
|
||
pulse_type: pulseDraft.pulseType,
|
||
message: pulseDraft.message.trim(),
|
||
confidence_score: Number(pulseDraft.confidence) || 0,
|
||
evidence_refs: linesToArray(pulseDraft.evidenceRefs),
|
||
trace_id: pulseDraft.traceId.trim() || undefined,
|
||
}
|
||
|
||
if (selectedPulseId) {
|
||
setAppState((current) => ({
|
||
...current,
|
||
pulses: current.pulses.map((entry) => (entry.id === selectedPulseId ? pulse : entry)),
|
||
}))
|
||
setStatusMessage('Pulse updated.')
|
||
} else {
|
||
setAppState((current) => ({
|
||
...current,
|
||
pulses: [pulse, ...current.pulses],
|
||
}))
|
||
setStatusMessage('Pulse added.')
|
||
}
|
||
|
||
resetPulseDraft()
|
||
}
|
||
|
||
const deletePulse = (pulseId: string) => {
|
||
setAppState((current) => ({
|
||
...current,
|
||
pulses: current.pulses.filter((pulse) => pulse.id !== pulseId),
|
||
}))
|
||
if (selectedPulseId === pulseId) resetPulseDraft()
|
||
setStatusMessage('Pulse deleted.')
|
||
}
|
||
|
||
const copyMarkdown = async (filename: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(markdownPackage[filename as keyof typeof markdownPackage])
|
||
setStatusMessage(`${filename} copied to clipboard.`)
|
||
} catch {
|
||
setStatusMessage('Clipboard copy failed. Browser said no.')
|
||
}
|
||
}
|
||
|
||
const copySessionPrompt = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(sessionPrompt)
|
||
setStatusMessage('AI session prompt copied to clipboard.')
|
||
} catch {
|
||
setStatusMessage('Clipboard copy failed. Browser said no.')
|
||
}
|
||
}
|
||
|
||
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0]
|
||
if (!file) return
|
||
|
||
try {
|
||
const text = await file.text()
|
||
const parsed = JSON.parse(text)
|
||
const normalized = normalizeAppState(parsed)
|
||
if (!normalized) {
|
||
setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.')
|
||
return
|
||
}
|
||
setAppState(replaceAppState(normalized))
|
||
setStatusMessage('Import complete. State replaced cleanly.')
|
||
} catch {
|
||
setStatusMessage('Import failed: invalid JSON.')
|
||
} finally {
|
||
event.target.value = ''
|
||
}
|
||
}
|
||
|
||
const currentFeatureCount = groupedFeatures.now.length
|
||
const recentPulsePreview = [...appState.pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, 3)
|
||
const completedFeatureCount = groupedFeatures.done.length
|
||
const statusCards = [
|
||
{
|
||
title: 'Project Cockpit',
|
||
status: 'live',
|
||
description: 'Single-project mission, goal, notes, and focus statistics stay visible before the board tries to swallow the room.',
|
||
signal: appState.project.current_goal ? 'Goal set' : 'Needs current goal',
|
||
metric: appState.project.name,
|
||
action: 'Edit summary',
|
||
tab: 'status' as TabKey,
|
||
operatorNote: 'Use this when the project starts drifting and the cockpit needs a clean north star again.',
|
||
evidence: ['Project summary fields are editable inline.', 'Hero stats reflect live feature, parking, and pulse counts.', 'Current goal is always visible in the page header.'],
|
||
next: 'Add an inline “goal changed” pulse when the current goal is edited.',
|
||
},
|
||
{
|
||
title: 'Feature Plan',
|
||
status: currentFeatureCount ? 'active' : 'ready',
|
||
description: 'Now / Next / Later / Done columns keep work small, shaped, and movable without becoming Jira in a fake moustache.',
|
||
signal: `${appState.features.length} total · ${currentFeatureCount} now · ${completedFeatureCount} done`,
|
||
metric: `${currentFeatureCount} now`,
|
||
action: 'Open board',
|
||
tab: 'feature-plan' as TabKey,
|
||
operatorNote: 'Use this to decide what is actively being built and what should stay out of the way.',
|
||
evidence: ['Four columns: Now, Next, Later, Done.', 'Cards show priority, status, acceptance criteria count, and linked pulse activity.', 'Selected features expose focus notes, criteria, recent pulses, handoff, and pulse actions.'],
|
||
next: 'Add a readiness checklist that highlights missing acceptance criteria before work starts.',
|
||
},
|
||
{
|
||
title: 'Parking Lot',
|
||
status: appState.parking_lot.length ? 'active' : 'ready',
|
||
description: 'Useful distractions get captured, risk-tagged, and converted into features only when they earn their keep.',
|
||
signal: `${appState.parking_lot.length} parked idea${appState.parking_lot.length === 1 ? '' : 's'}`,
|
||
metric: `${appState.parking_lot.length} parked`,
|
||
action: 'Review parked',
|
||
tab: 'parking-lot' as TabKey,
|
||
operatorNote: 'Use this when an idea is useful but too distracting to deserve active build attention yet.',
|
||
evidence: ['Parked ideas carry risk level, reason parked, and possible future placement.', 'A parked idea can be converted into a real feature.', 'Parking keeps future options visible without polluting Now.'],
|
||
next: 'Add a “promote candidate” signal for parked items that keep reappearing in pulses.',
|
||
},
|
||
{
|
||
title: 'Pulse Log',
|
||
status: appState.pulses.length ? 'active' : 'ready',
|
||
description: 'Intent, decisions, blockers, test results, and outcomes form a future-compatible trail for agents and humans.',
|
||
signal: recentPulsePreview[0] ? `Latest: ${recentPulsePreview[0].pulse_type}` : 'No pulses yet',
|
||
metric: `${appState.pulses.length} pulses`,
|
||
action: 'Open log',
|
||
tab: 'pulse-log' as TabKey,
|
||
operatorNote: 'Use this as the honest activity ledger: intent, action, decision, blocker, result.',
|
||
evidence: ['Pulses can link to features.', 'Filters support pulse type, feature, source, and agent.', 'Recent pulse previews surface movement on the Feature Plan.'],
|
||
next: 'Add one-click TEST_RESULT and DECISION templates from the functionality detail panel.',
|
||
},
|
||
{
|
||
title: 'AI Handoff + Export',
|
||
status: 'live',
|
||
description: 'Generate JSON, JSONL, Markdown packages, and focused session prompts so coding agents get context without soup.',
|
||
signal: `${Object.keys(markdownPackage).length} markdown files ready`,
|
||
metric: 'handoff ready',
|
||
action: 'Export context',
|
||
tab: 'export' as TabKey,
|
||
operatorNote: 'Use this when another agent or coding session needs clean context without archaeology.',
|
||
evidence: ['JSON export preserves full app state.', 'JSONL export carries pulse history.', 'Markdown package includes agent-facing project, feature, parking, pulse, and context files.'],
|
||
next: 'Add a “copy focused handoff” button directly on each capability detail.',
|
||
},
|
||
{
|
||
title: 'Appwrite Sync',
|
||
status: backendMode === 'appwrite' && syncStatus === 'synced' ? 'live' : syncStatus === 'degraded' ? 'degraded' : 'syncing',
|
||
description: 'Infrastructure sync persists the local cockpit state to the Appwrite runtime document without becoming the product center.',
|
||
signal: backendMode === 'appwrite' ? `Sync status: ${syncStatus}` : 'Local cache fallback active',
|
||
metric: backendMode === 'appwrite' ? 'Appwrite' : 'cache',
|
||
action: 'Refresh state',
|
||
tab: 'status' as TabKey,
|
||
operatorNote: 'Use this only for operator recovery when browser state and backend truth need to be reconciled deliberately.',
|
||
evidence: ['Public health endpoint reports backend=appwrite.', 'Refresh from backend pulls the Appwrite document into local state.', 'Force sync now pushes the current cockpit state back to Appwrite.'],
|
||
next: 'Expose last successful pull/push direction as sync provenance.',
|
||
},
|
||
]
|
||
const selectedStatusCard = statusCards.find((card) => card.title === selectedStatusCardTitle) ?? statusCards[0]
|
||
const backendLabel =
|
||
backendMode === 'appwrite' ? 'Appwrite backend · Unraid server' : backendMode === 'connecting' ? 'Connecting to Appwrite…' : 'Local cache fallback'
|
||
const syncLabel =
|
||
syncStatus === 'synced'
|
||
? `Synced${lastSyncedAt ? ` ${formatDateTime(lastSyncedAt)}` : ''}`
|
||
: syncStatus === 'pending'
|
||
? 'Changes queued'
|
||
: syncStatus === 'syncing'
|
||
? 'Syncing now…'
|
||
: syncStatus === 'degraded'
|
||
? 'Sync degraded · local cache active'
|
||
: 'Connecting…'
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<nav className="tab-bar" aria-label="BuildPulse v0.1 views" role="tablist">
|
||
{TABS.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tab.key === activeTab}
|
||
className={tab.key === activeTab ? 'tab active' : 'tab'}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<aside className="secondary-nav card" aria-label="Secondary tools">
|
||
<div>
|
||
<span className="eyebrow">Local-first cockpit</span>
|
||
<p>Appwrite sync is infrastructure. Planning still belongs in the four v0.1 tabs.</p>
|
||
</div>
|
||
<button type="button" className={activeTab === 'status' ? 'ghost small active-secondary' : 'ghost small'} onClick={() => setActiveTab('status')}>
|
||
System Status
|
||
</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">
|
||
<div>
|
||
<p className="eyebrow">BuildPulse v0.1</p>
|
||
<h1>{appState.project.name}</h1>
|
||
<p className="hero-copy">{appState.project.one_line_pitch}</p>
|
||
<p className="hero-goal">
|
||
<strong>Current goal:</strong> {appState.project.current_goal || 'Set a goal so the cockpit has a heading.'}
|
||
</p>
|
||
</div>
|
||
<div className="hero-stats">
|
||
<div>
|
||
<span>Now</span>
|
||
<strong>{currentFeatureCount}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Parked</span>
|
||
<strong>{appState.parking_lot.length}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Pulses</span>
|
||
<strong>{appState.pulses.length}</strong>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="project-card card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h2>Project Summary</h2>
|
||
<p>Keep the mission clear without turning this into enterprise theater.</p>
|
||
</div>
|
||
</div>
|
||
<div className="form-grid project-grid">
|
||
<label>
|
||
Project name
|
||
<input value={appState.project.name} onChange={(event) => updateProject('name', event.target.value)} />
|
||
</label>
|
||
<label>
|
||
One-line pitch
|
||
<input
|
||
value={appState.project.one_line_pitch}
|
||
onChange={(event) => updateProject('one_line_pitch', event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="full-span">
|
||
Description
|
||
<textarea
|
||
rows={3}
|
||
value={appState.project.description}
|
||
onChange={(event) => updateProject('description', event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="full-span">
|
||
Current goal
|
||
<input value={appState.project.current_goal} onChange={(event) => updateProject('current_goal', event.target.value)} />
|
||
</label>
|
||
<label className="full-span">
|
||
Notes
|
||
<textarea rows={3} value={appState.project.notes} onChange={(event) => updateProject('notes', event.target.value)} />
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="quick-actions card">
|
||
<button type="button" onClick={() => { setActiveTab('feature-plan'); resetFeatureDraft() }}>
|
||
Add Feature
|
||
</button>
|
||
<button type="button" onClick={() => { setActiveTab('parking-lot'); resetParkingDraft() }}>
|
||
Park Idea
|
||
</button>
|
||
<button type="button" onClick={() => { setActiveTab('pulse-log'); resetPulseDraft() }}>
|
||
Add Pulse
|
||
</button>
|
||
<button type="button" onClick={() => setActiveTab('export')}>
|
||
Export
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'status' && (
|
||
<section className="view-stack">
|
||
<div className="section-heading">
|
||
<div>
|
||
<h2>System Status</h2>
|
||
<p>Secondary infrastructure view: what exists, what is parked, and whether sync is healthy. The product center stays Feature Plan, Parking Lot, Pulse Log, and Export.</p>
|
||
</div>
|
||
<div className="functionality-summary">
|
||
<span className="pill status-healthy">NPM live</span>
|
||
<span className="pill status-healthy">Appwrite backed</span>
|
||
<span className="pill">Unraid runtime</span>
|
||
</div>
|
||
</div>
|
||
|
||
<section className="card functionality-hero">
|
||
<div>
|
||
<p className="eyebrow">Secondary status</p>
|
||
<h3>{appState.project.name} is a local-first v0.1 cockpit with Appwrite sync support.</h3>
|
||
<p>
|
||
The planning loop works from browser storage first, then syncs to Appwrite for the deployed Unraid runtime. If sync degrades, the cockpit should still stay usable locally.
|
||
</p>
|
||
</div>
|
||
<div className="functionality-scorecard">
|
||
<div>
|
||
<span>v0.1 screens</span>
|
||
<strong>{statusCards.filter((card) => card.status === 'live' || card.status === 'active').length}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Operator recovery</span>
|
||
<strong>{backendMode === 'appwrite' ? 'Ready' : 'Cache'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Context packages</span>
|
||
<strong>{Object.keys(markdownPackage).length}</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card infrastructure-panel">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<p className="eyebrow">Infrastructure / Dev</p>
|
||
<h3>Appwrite sync controls</h3>
|
||
<p>Available for recovery and verification, deliberately kept out of the primary product flow.</p>
|
||
</div>
|
||
</div>
|
||
<div className="status-strip-row">
|
||
<span className={`pill status-${backendMode === 'appwrite' ? 'healthy' : backendMode === 'connecting' ? 'connecting' : 'degraded'}`}>
|
||
{backendLabel}
|
||
</span>
|
||
<span className={`pill status-${syncStatus === 'synced' ? 'healthy' : syncStatus === 'pending' || syncStatus === 'syncing' || syncStatus === 'connecting' ? 'connecting' : 'degraded'}`}>
|
||
{syncLabel}
|
||
</span>
|
||
</div>
|
||
<div className="button-inline-row">
|
||
<button type="button" className="ghost small" disabled={syncAction !== 'idle'} onClick={() => void refreshFromBackend()}>
|
||
{syncAction === 'refreshing' ? 'Refreshing…' : 'Refresh from backend'}
|
||
</button>
|
||
<button type="button" className="ghost small" disabled={syncAction !== 'idle'} onClick={() => void forceSyncNow()}>
|
||
{syncAction === 'pushing' ? 'Syncing…' : 'Force sync now'}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="functionality-grid">
|
||
{statusCards.map((card) => (
|
||
<article key={card.title} className={`card functionality-card functionality-${card.status}`}>
|
||
<div className="item-card-header">
|
||
<div>
|
||
<p className="eyebrow">{card.status}</p>
|
||
<h3>{card.title}</h3>
|
||
</div>
|
||
<span className="pill">{card.metric}</span>
|
||
</div>
|
||
<p>{card.description}</p>
|
||
<div className="functionality-signal">
|
||
<span>{card.signal}</span>
|
||
<div className="button-inline-row">
|
||
<button type="button" className="ghost small" onClick={() => setSelectedStatusCardTitle(card.title)}>
|
||
Inspect
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost small"
|
||
onClick={() => {
|
||
if (card.title === 'Appwrite Sync') {
|
||
void refreshFromBackend()
|
||
} else {
|
||
setActiveTab(card.tab)
|
||
}
|
||
}}
|
||
>
|
||
{card.action}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
|
||
<section className="card functionality-detail">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<p className="eyebrow">Selected status card</p>
|
||
<h3>{selectedStatusCard.title}</h3>
|
||
<p>{selectedStatusCard.operatorNote}</p>
|
||
</div>
|
||
<span className={`pill functionality-${selectedStatusCard.status}`}>{selectedStatusCard.status}</span>
|
||
</div>
|
||
<div className="functionality-detail-grid">
|
||
<article>
|
||
<h4>Proof it exists</h4>
|
||
<ul>
|
||
{selectedStatusCard.evidence.map((item) => (
|
||
<li key={item}>{item}</li>
|
||
))}
|
||
</ul>
|
||
</article>
|
||
<article>
|
||
<h4>Current signal</h4>
|
||
<p>{selectedStatusCard.signal}</p>
|
||
<h4>Next useful improvement</h4>
|
||
<p>{selectedStatusCard.next}</p>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card functionality-roadmap">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>What this is deliberately not yet</h3>
|
||
<p>Guardrails matter. The small cockpit wins because it refuses to cosplay as a whole enterprise suite.</p>
|
||
</div>
|
||
</div>
|
||
<div className="roadmap-grid">
|
||
<div>
|
||
<strong>Not Jira</strong>
|
||
<p>No issue jungle, sprint ceremony, or fake certainty factory.</p>
|
||
</div>
|
||
<div>
|
||
<strong>Not an autonomous agent framework</strong>
|
||
<p>Agent ingestion can come later; manual pulse truth comes first.</p>
|
||
</div>
|
||
<div>
|
||
<strong>Not multi-project yet</strong>
|
||
<p>Single-project discipline keeps v0.1 sharp enough to dogfood.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'feature-plan' && (
|
||
<section className="view-stack">
|
||
<div className="section-heading">
|
||
<div>
|
||
<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 && (
|
||
<section className="card quick-actions">
|
||
<div>
|
||
<strong>{selectedFeature.title}</strong>
|
||
<p>Selected and ready. Shape it here, then kick a clean brief to your agent.</p>
|
||
</div>
|
||
<div className="button-inline-row">
|
||
<button type="button" className="ghost" onClick={() => openFeaturePulse(selectedFeature.id)}>
|
||
Log Feature Pulse
|
||
</button>
|
||
<button type="button" onClick={() => openFeatureHandoff(selectedFeature.id)}>
|
||
Prepare AI Handoff
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{selectedFeature && (
|
||
<section className="card focus-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<p className="eyebrow">Night Shift Focus</p>
|
||
<h3>{selectedFeature.title}</h3>
|
||
<p>The current build thread, stripped of excuses and fog.</p>
|
||
</div>
|
||
<div className="focus-badges">
|
||
<span className={`pill ${selectedFeature.priority}`}>{selectedFeature.priority}</span>
|
||
<span className="pill">{selectedFeature.status}</span>
|
||
<span className="pill">{columnLabels[selectedFeature.column]}</span>
|
||
</div>
|
||
</div>
|
||
<div className="focus-grid">
|
||
<article className="focus-panel">
|
||
<h4>Acceptance Criteria</h4>
|
||
{selectedFeature.acceptance_criteria.length ? (
|
||
<ul>
|
||
{selectedFeature.acceptance_criteria.map((criterion) => (
|
||
<li key={criterion}>{criterion}</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p>No criteria yet. A dangerous little vacuum.</p>
|
||
)}
|
||
</article>
|
||
<article className="focus-panel">
|
||
<h4>Scope Notes</h4>
|
||
<p>{selectedFeature.scope_notes || 'No scope notes yet. Add the edges before the feature sprawls.'}</p>
|
||
<h4>Recent Pulse</h4>
|
||
{selectedFeaturePulses.length ? (
|
||
<div className="list-stack compact-stack">
|
||
{selectedFeaturePulses.map((pulse) => (
|
||
<div key={pulse.id} className="mini-pulse">
|
||
<strong>{pulse.pulse_type}</strong>
|
||
<span>{formatDateTime(pulse.timestamp)}</span>
|
||
<p>{pulse.message}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p>No feature-linked pulses yet. Log intent before the night gets blurry.</p>
|
||
)}
|
||
</article>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<div className="board-grid">
|
||
{FEATURE_COLUMNS.map((column) => (
|
||
<article key={column} className="column card">
|
||
<div className="column-header">
|
||
<div>
|
||
<h3>{columnLabels[column]}</h3>
|
||
<p>{groupedFeatures[column].length} feature{groupedFeatures[column].length === 1 ? '' : 's'}</p>
|
||
</div>
|
||
</div>
|
||
<div className="column-body">
|
||
{groupedFeatures[column].length ? (
|
||
groupedFeatures[column].map((feature) => {
|
||
const pulseMeta = featurePulseMeta.get(feature.id)
|
||
|
||
return (
|
||
<button key={feature.id} type="button" className="item-card feature-card" onClick={() => beginFeatureEdit(feature)}>
|
||
<div className="item-card-header">
|
||
<strong>{feature.title}</strong>
|
||
<span className={`pill ${feature.priority}`}>{feature.priority}</span>
|
||
</div>
|
||
<p>{feature.description || 'No description yet.'}</p>
|
||
<div className="feature-signal-row">
|
||
<span>{feature.acceptance_criteria.length} criteria</span>
|
||
{pulseMeta ? (
|
||
<span className="feature-signal">
|
||
{pulseMeta.count} pulse{pulseMeta.count === 1 ? '' : 's'} · last {pulseMeta.latest?.pulse_type ?? 'event'}
|
||
</span>
|
||
) : (
|
||
<span className="feature-signal quiet">No linked pulses yet</span>
|
||
)}
|
||
</div>
|
||
<div className="meta-row">
|
||
<span className="pill">{feature.status}</span>
|
||
<label>
|
||
<span className="sr-only">Move feature</span>
|
||
<select
|
||
value={feature.column}
|
||
onChange={(event) => {
|
||
event.stopPropagation()
|
||
quickMoveFeature(feature.id, event.target.value as FeatureColumn)
|
||
}}
|
||
>
|
||
{FEATURE_COLUMNS.map((entry) => (
|
||
<option key={entry} value={entry}>
|
||
{columnLabels[entry]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</button>
|
||
)
|
||
})
|
||
) : (
|
||
<div className="empty-state">No features here yet.</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
|
||
<div className="editor-grid">
|
||
<section className="card editor-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>{selectedFeature ? 'Edit Feature' : 'Add Feature'}</h3>
|
||
<p>Keep it small, clear, and testable.</p>
|
||
</div>
|
||
</div>
|
||
<div className="form-grid">
|
||
<label>
|
||
Title
|
||
<input value={featureDraft.title} onChange={(event) => setFeatureDraft((current) => ({ ...current, title: event.target.value }))} />
|
||
</label>
|
||
<label>
|
||
Column
|
||
<select value={featureDraft.column} onChange={(event) => setFeatureDraft((current) => ({ ...current, column: event.target.value as FeatureColumn }))}>
|
||
{FEATURE_COLUMNS.map((column) => (
|
||
<option key={column} value={column}>
|
||
{columnLabels[column]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Priority
|
||
<select value={featureDraft.priority} onChange={(event) => setFeatureDraft((current) => ({ ...current, priority: event.target.value as (typeof FEATURE_PRIORITIES)[number] }))}>
|
||
{FEATURE_PRIORITIES.map((priority) => (
|
||
<option key={priority} value={priority}>
|
||
{priority}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Status
|
||
<select value={featureDraft.status} onChange={(event) => setFeatureDraft((current) => ({ ...current, status: event.target.value as (typeof FEATURE_STATUSES)[number] }))}>
|
||
{FEATURE_STATUSES.map((status) => (
|
||
<option key={status} value={status}>
|
||
{status}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="full-span">
|
||
Description
|
||
<textarea rows={3} value={featureDraft.description} onChange={(event) => setFeatureDraft((current) => ({ ...current, description: event.target.value }))} />
|
||
</label>
|
||
<label className="full-span">
|
||
Acceptance criteria (one per line)
|
||
<textarea rows={5} value={featureDraft.acceptanceCriteria} onChange={(event) => setFeatureDraft((current) => ({ ...current, acceptanceCriteria: event.target.value }))} />
|
||
</label>
|
||
<label className="full-span">
|
||
Scope notes
|
||
<textarea rows={3} value={featureDraft.scopeNotes} onChange={(event) => setFeatureDraft((current) => ({ ...current, scopeNotes: event.target.value }))} />
|
||
</label>
|
||
</div>
|
||
<div className="button-row">
|
||
<button type="button" onClick={saveFeature}>{selectedFeature ? 'Save Changes' : 'Add Feature'}</button>
|
||
<button type="button" className="ghost" onClick={resetFeatureDraft}>Clear</button>
|
||
{selectedFeature && (
|
||
<>
|
||
<button type="button" className="ghost" onClick={() => openFeaturePulse(selectedFeature.id)}>
|
||
Log Feature Pulse
|
||
</button>
|
||
<button type="button" className="ghost" onClick={() => openFeatureHandoff(selectedFeature.id)}>
|
||
Prepare AI Handoff
|
||
</button>
|
||
<button type="button" className="danger" onClick={() => deleteFeature(selectedFeature.id)}>
|
||
Delete Feature
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card preview-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>Recent Pulse</h3>
|
||
<p>Just enough movement to stay grounded.</p>
|
||
</div>
|
||
</div>
|
||
{recentPulsePreview.length ? (
|
||
<div className="list-stack">
|
||
{recentPulsePreview.map((pulse) => (
|
||
<div key={pulse.id} className="mini-pulse">
|
||
<strong>{pulse.pulse_type}</strong>
|
||
<span>{formatDateTime(pulse.timestamp)}</span>
|
||
<p>{pulse.message}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">No pulses yet. Add an INTENT or DECISION to start the log.</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'parking-lot' && (
|
||
<section className="view-stack">
|
||
<div className="section-heading">
|
||
<div>
|
||
<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">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>{selectedParkingItem ? 'Edit Parked Idea' : 'Park a New Idea'}</h3>
|
||
<p>Capture it cleanly, then get back to the real work.</p>
|
||
</div>
|
||
</div>
|
||
<div className="form-grid">
|
||
<label>
|
||
Title
|
||
<input value={parkingDraft.title} onChange={(event) => setParkingDraft((current) => ({ ...current, title: event.target.value }))} />
|
||
</label>
|
||
<label>
|
||
Risk level
|
||
<select value={parkingDraft.riskLevel} onChange={(event) => setParkingDraft((current) => ({ ...current, riskLevel: event.target.value as RiskLevel }))}>
|
||
{RISK_LEVELS.map((level) => (
|
||
<option key={level} value={level}>
|
||
{level}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="full-span">
|
||
Description
|
||
<textarea rows={3} value={parkingDraft.description} onChange={(event) => setParkingDraft((current) => ({ ...current, description: event.target.value }))} />
|
||
</label>
|
||
<label className="full-span">
|
||
Reason parked
|
||
<textarea rows={3} value={parkingDraft.reasonParked} onChange={(event) => setParkingDraft((current) => ({ ...current, reasonParked: event.target.value }))} />
|
||
</label>
|
||
<label className="full-span">
|
||
Possible future placement
|
||
<input value={parkingDraft.futurePlacement} onChange={(event) => setParkingDraft((current) => ({ ...current, futurePlacement: event.target.value }))} />
|
||
</label>
|
||
</div>
|
||
<div className="button-row">
|
||
<button type="button" onClick={saveParkingItem}>{selectedParkingItem ? 'Save Changes' : 'Park Idea'}</button>
|
||
<button type="button" className="ghost" onClick={resetParkingDraft}>Clear</button>
|
||
{selectedParkingItem && (
|
||
<>
|
||
<button type="button" className="ghost" onClick={() => convertParkingItemToFeature(selectedParkingItem)}>
|
||
Convert to Feature
|
||
</button>
|
||
<button type="button" className="danger" onClick={() => deleteParkingItem(selectedParkingItem.id)}>
|
||
Delete
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card list-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>Parked Ideas</h3>
|
||
<p>Visible enough to trust, quiet enough not to derail you.</p>
|
||
</div>
|
||
</div>
|
||
<div className="list-stack">
|
||
{appState.parking_lot.length ? (
|
||
appState.parking_lot.map((item) => (
|
||
<button key={item.id} type="button" className="item-card parking-card" onClick={() => beginParkingEdit(item)}>
|
||
<div className="item-card-header">
|
||
<strong>{item.title}</strong>
|
||
<span className={`pill risk-${item.risk_level}`}>{item.risk_level}</span>
|
||
</div>
|
||
<p>{item.description || 'No description yet.'}</p>
|
||
<small>{item.reason_parked || 'No reason parked yet.'}</small>
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="empty-state">No parked ideas yet. That probably means you haven’t had a normal distracted brain day yet.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'pulse-log' && (
|
||
<section className="view-stack">
|
||
<div className="section-heading">
|
||
<div>
|
||
<h2>Pulse Log</h2>
|
||
<p>Manual now, future-compatible later. Keep the history honest.</p>
|
||
</div>
|
||
</div>
|
||
<div className="editor-grid">
|
||
<section className="card editor-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>{selectedPulse ? 'Edit Pulse' : 'Add Pulse'}</h3>
|
||
<p>Log what happened, not a fantasy backlog status.</p>
|
||
</div>
|
||
</div>
|
||
<div className="form-grid">
|
||
<label>
|
||
Pulse type
|
||
<select value={pulseDraft.pulseType} onChange={(event) => setPulseDraft((current) => ({ ...current, pulseType: event.target.value as (typeof PULSE_TYPES)[number] }))}>
|
||
{PULSE_TYPES.map((pulseType) => (
|
||
<option key={pulseType} value={pulseType}>
|
||
{pulseType}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Feature link
|
||
<select value={pulseDraft.featureId} onChange={(event) => setPulseDraft((current) => ({ ...current, featureId: event.target.value }))}>
|
||
<option value="">No linked feature</option>
|
||
{appState.features.map((feature) => (
|
||
<option key={feature.id} value={feature.id}>
|
||
{feature.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Source
|
||
<input value={pulseDraft.source} onChange={(event) => setPulseDraft((current) => ({ ...current, source: event.target.value }))} />
|
||
</label>
|
||
<label>
|
||
Agent ID
|
||
<input value={pulseDraft.agentId} onChange={(event) => setPulseDraft((current) => ({ ...current, agentId: event.target.value }))} />
|
||
</label>
|
||
<label>
|
||
Confidence
|
||
<input value={pulseDraft.confidence} onChange={(event) => setPulseDraft((current) => ({ ...current, confidence: event.target.value }))} />
|
||
</label>
|
||
<label>
|
||
Trace ID
|
||
<input value={pulseDraft.traceId} onChange={(event) => setPulseDraft((current) => ({ ...current, traceId: event.target.value }))} />
|
||
</label>
|
||
<label className="full-span">
|
||
Message
|
||
<textarea rows={4} value={pulseDraft.message} onChange={(event) => setPulseDraft((current) => ({ ...current, message: event.target.value }))} />
|
||
</label>
|
||
<label className="full-span">
|
||
Evidence refs (one per line)
|
||
<textarea rows={4} value={pulseDraft.evidenceRefs} onChange={(event) => setPulseDraft((current) => ({ ...current, evidenceRefs: event.target.value }))} />
|
||
</label>
|
||
</div>
|
||
<div className="button-row">
|
||
<button type="button" onClick={savePulse}>{selectedPulse ? 'Save Changes' : 'Add Pulse'}</button>
|
||
<button type="button" className="ghost" onClick={resetPulseDraft}>Clear</button>
|
||
{selectedPulse && (
|
||
<button type="button" className="danger" onClick={() => deletePulse(selectedPulse.id)}>
|
||
Delete Pulse
|
||
</button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card list-card">
|
||
<div className="section-heading compact filters-heading">
|
||
<div>
|
||
<h3>Pulse Timeline</h3>
|
||
<p>Newest first, with just enough filtering to stay useful.</p>
|
||
</div>
|
||
<div className="filter-row">
|
||
<select value={pulseTypeFilter} onChange={(event) => setPulseTypeFilter(event.target.value)}>
|
||
<option value="all">All types</option>
|
||
{PULSE_TYPES.map((pulseType) => (
|
||
<option key={pulseType} value={pulseType}>
|
||
{pulseType}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select value={pulseFeatureFilter} onChange={(event) => setPulseFeatureFilter(event.target.value)}>
|
||
<option value="all">All features</option>
|
||
{appState.features.map((feature) => (
|
||
<option key={feature.id} value={feature.id}>
|
||
{feature.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select value={pulseSourceFilter} onChange={(event) => setPulseSourceFilter(event.target.value)}>
|
||
<option value="all">All sources</option>
|
||
{uniqueSources.map((source) => (
|
||
<option key={source} value={source}>
|
||
{source}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="list-stack">
|
||
{filteredPulses.length ? (
|
||
filteredPulses.map((pulse) => {
|
||
const linkedFeature = appState.features.find((feature) => feature.id === pulse.feature_id)
|
||
return (
|
||
<button key={pulse.id} type="button" className="item-card pulse-card" onClick={() => beginPulseEdit(pulse)}>
|
||
<div className="item-card-header">
|
||
<strong>{pulse.pulse_type}</strong>
|
||
<span>{formatDateTime(pulse.timestamp)}</span>
|
||
</div>
|
||
<p>{pulse.message}</p>
|
||
<small>
|
||
{linkedFeature?.title ?? (pulse.feature_id ? `${pulse.feature_id} (missing feature)` : 'No linked feature')} · {pulse.source} / {pulse.agent_id}
|
||
</small>
|
||
</button>
|
||
)
|
||
})
|
||
) : (
|
||
<div className="empty-state">No pulses match the current filters.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'export' && (
|
||
<section className="view-stack">
|
||
<div className="section-heading">
|
||
<div>
|
||
<h2>Export</h2>
|
||
<p>Ship clean context packages, not rambling AI bait.</p>
|
||
</div>
|
||
</div>
|
||
<div className="editor-grid">
|
||
<section className="card editor-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>Data Exports</h3>
|
||
<p>Backups and handoffs first. Fancy multi-file packaging can wait.</p>
|
||
</div>
|
||
</div>
|
||
<div className="button-stack">
|
||
<button type="button" onClick={() => downloadText('buildpulse-export.json', createJsonExport(appState), 'application/json;charset=utf-8')}>
|
||
Download Full JSON
|
||
</button>
|
||
<button type="button" onClick={() => downloadText('pulses.jsonl', createPulseJsonl(appState), 'application/x-ndjson;charset=utf-8')}>
|
||
Download Pulse JSONL
|
||
</button>
|
||
<label className="import-label">
|
||
Import JSON
|
||
<input type="file" accept="application/json" onChange={handleImport} />
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="list-stack">
|
||
<section className="card list-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>AI Session Prompt</h3>
|
||
<p>Pick a focus feature and hand a sharp brief to your coding agent instead of pasting the whole kitchen sink.</p>
|
||
</div>
|
||
</div>
|
||
<div className="filter-row">
|
||
<label>
|
||
Target
|
||
<select value={promptTarget} onChange={(event) => setPromptTarget(event.target.value as (typeof PROMPT_TARGETS)[number])}>
|
||
{PROMPT_TARGETS.map((target) => (
|
||
<option key={target} value={target}>
|
||
{target}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Focus Feature
|
||
<select value={promptFeatureId} onChange={(event) => setPromptFeatureId(event.target.value)}>
|
||
<option value="">Auto-pick first Now feature</option>
|
||
{appState.features.map((feature) => (
|
||
<option key={feature.id} value={feature.id}>
|
||
{feature.title} · {columnLabels[feature.column]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="button-inline-row">
|
||
<button type="button" onClick={copySessionPrompt}>
|
||
Copy Prompt
|
||
</button>
|
||
<button type="button" className="ghost" onClick={() => downloadText('AI_SESSION_PROMPT.md', sessionPrompt)}>
|
||
Download Prompt
|
||
</button>
|
||
</div>
|
||
<div className="markdown-card">
|
||
<div className="item-card-header">
|
||
<strong>AI_SESSION_PROMPT.md</strong>
|
||
</div>
|
||
<pre>{sessionPrompt}</pre>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card list-card">
|
||
<div className="section-heading compact">
|
||
<div>
|
||
<h3>Markdown Package</h3>
|
||
<p>`CLAUDE_CONTEXT.md` is the decision-boundary file. Keep it sharp.</p>
|
||
</div>
|
||
</div>
|
||
<div className="list-stack markdown-list">
|
||
{Object.entries(markdownPackage).map(([filename, content]) => (
|
||
<div key={filename} className="markdown-card">
|
||
<div className="item-card-header">
|
||
<strong>{filename}</strong>
|
||
<div className="button-inline-row">
|
||
<button type="button" className="ghost small" onClick={() => copyMarkdown(filename)}>
|
||
Copy
|
||
</button>
|
||
<button type="button" className="ghost small" onClick={() => downloadText(filename, content)}>
|
||
Download
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<pre>{content}</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<footer className="status-bar">
|
||
<span>{statusMessage}</span>
|
||
<span>{backendLabel} · {syncLabel} · schema {appState.schema_version}</span>
|
||
</footer>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default App
|