Files
buildpulse/src/App.tsx
T
2026-05-09 20:53:15 +02:00

1946 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 havent 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