feat: tighten feature pulse flow and backend resilience
This commit is contained in:
+80
-28
@@ -157,6 +157,24 @@ function App() {
|
||||
[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 []
|
||||
|
||||
@@ -297,6 +315,20 @@ function App() {
|
||||
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({
|
||||
@@ -626,6 +658,9 @@ function App() {
|
||||
<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>
|
||||
@@ -693,34 +728,48 @@ function App() {
|
||||
</div>
|
||||
<div className="column-body">
|
||||
{groupedFeatures[column].length ? (
|
||||
groupedFeatures[column].map((feature) => (
|
||||
<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="meta-row">
|
||||
<span>{feature.acceptance_criteria.length} criteria</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>
|
||||
))
|
||||
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>
|
||||
)}
|
||||
@@ -790,6 +839,9 @@ function App() {
|
||||
<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>
|
||||
|
||||
+23
-1
@@ -354,6 +354,7 @@ button.small {
|
||||
.column-header,
|
||||
.item-card-header,
|
||||
.meta-row,
|
||||
.feature-signal-row,
|
||||
.status-bar,
|
||||
.filters-heading {
|
||||
display: flex;
|
||||
@@ -405,6 +406,26 @@ pre {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.feature-signal-row {
|
||||
font-size: 0.82rem;
|
||||
color: #9fb4d9;
|
||||
}
|
||||
|
||||
.feature-signal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.feature-signal.quiet {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.item-card select {
|
||||
min-width: 120px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
@@ -516,7 +537,8 @@ pre {
|
||||
.tab-bar,
|
||||
.button-row,
|
||||
.filter-row,
|
||||
.status-bar {
|
||||
.status-bar,
|
||||
.feature-signal-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
+22
-7
@@ -1,15 +1,30 @@
|
||||
import type { AppState } from './types'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || ''
|
||||
const REQUEST_TIMEOUT_MS = Number(import.meta.env.VITE_BUILDPULSE_API_TIMEOUT_MS || 5000)
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
...init,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
...init,
|
||||
signal: init?.signal || controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error(`BuildPulse API request timed out after ${REQUEST_TIMEOUT_MS}ms`, { cause: error })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
|
||||
Reference in New Issue
Block a user