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 = { 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 = { 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(() => loadAppState()) const [activeTab, setActiveTab] = useState('feature-plan') const [statusMessage, setStatusMessage] = useState('Seeded with BuildPulse so you can dogfood it immediately.') const [selectedFeatureId, setSelectedFeatureId] = useState(null) const [selectedParkingId, setSelectedParkingId] = useState(null) const [selectedPulseId, setSelectedPulseId] = useState(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(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(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>((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() 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>>((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) => { 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 (
{triageOpen && (

BuildPulse v0.2

AI Idea Placement

AI advises where a raw idea belongs. You decide what gets saved.