feat: tighten feature pulse flow and backend resilience

This commit is contained in:
OpenClaw Bot
2026-05-09 07:25:26 +02:00
parent f3e6106f49
commit d0806dccb8
4 changed files with 149 additions and 43 deletions
+24 -7
View File
@@ -11,17 +11,34 @@ const baseHeaders = {
'X-Appwrite-Project': projectId, 'X-Appwrite-Project': projectId,
} }
const REQUEST_TIMEOUT_MS = Number(process.env.BUILDPULSE_APPWRITE_TIMEOUT_MS || 4500)
const documentUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents/${BUILDPULSE_DOCUMENT_ID}` const documentUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents/${BUILDPULSE_DOCUMENT_ID}`
const collectionUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents` const collectionUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents`
async function appwriteFetch(url, init = {}) { async function appwriteFetch(url, init = {}) {
const response = await fetch(url, { const controller = new AbortController()
...init, const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
headers: {
...baseHeaders, let response
...(init.headers || {}), try {
}, response = await fetch(url, {
}) ...init,
signal: init.signal || controller.signal,
headers: {
...baseHeaders,
...(init.headers || {}),
},
})
} catch (error) {
if (error?.name === 'AbortError') {
const timeoutError = new Error(`Appwrite request timed out after ${REQUEST_TIMEOUT_MS}ms`)
timeoutError.status = 504
throw timeoutError
}
throw error
} finally {
clearTimeout(timeout)
}
if (response.status === 204) return null if (response.status === 204) return null
+80 -28
View File
@@ -157,6 +157,24 @@ function App() {
[appState.pulses, selectedPulseId], [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(() => { const selectedFeaturePulses = useMemo(() => {
if (!selectedFeature) return [] if (!selectedFeature) return []
@@ -297,6 +315,20 @@ function App() {
setStatusMessage(feature ? `Prepared AI handoff for “${feature.title}”.` : 'Prepared AI handoff prompt.') 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) => { const beginParkingEdit = (item: ParkingLotItem) => {
setSelectedParkingId(item.id) setSelectedParkingId(item.id)
setParkingDraft({ setParkingDraft({
@@ -626,6 +658,9 @@ function App() {
<p>Selected and ready. Shape it here, then kick a clean brief to your agent.</p> <p>Selected and ready. Shape it here, then kick a clean brief to your agent.</p>
</div> </div>
<div className="button-inline-row"> <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)}> <button type="button" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Prepare AI Handoff Prepare AI Handoff
</button> </button>
@@ -693,34 +728,48 @@ function App() {
</div> </div>
<div className="column-body"> <div className="column-body">
{groupedFeatures[column].length ? ( {groupedFeatures[column].length ? (
groupedFeatures[column].map((feature) => ( groupedFeatures[column].map((feature) => {
<button key={feature.id} type="button" className="item-card feature-card" onClick={() => beginFeatureEdit(feature)}> const pulseMeta = featurePulseMeta.get(feature.id)
<div className="item-card-header">
<strong>{feature.title}</strong> return (
<span className={`pill ${feature.priority}`}>{feature.priority}</span> <button key={feature.id} type="button" className="item-card feature-card" onClick={() => beginFeatureEdit(feature)}>
</div> <div className="item-card-header">
<p>{feature.description || 'No description yet.'}</p> <strong>{feature.title}</strong>
<div className="meta-row"> <span className={`pill ${feature.priority}`}>{feature.priority}</span>
<span>{feature.acceptance_criteria.length} criteria</span> </div>
<label> <p>{feature.description || 'No description yet.'}</p>
<span className="sr-only">Move feature</span> <div className="feature-signal-row">
<select <span>{feature.acceptance_criteria.length} criteria</span>
value={feature.column} {pulseMeta ? (
onChange={(event) => { <span className="feature-signal">
event.stopPropagation() {pulseMeta.count} pulse{pulseMeta.count === 1 ? '' : 's'} · last {pulseMeta.latest?.pulse_type ?? 'event'}
quickMoveFeature(feature.id, event.target.value as FeatureColumn) </span>
}} ) : (
> <span className="feature-signal quiet">No linked pulses yet</span>
{FEATURE_COLUMNS.map((entry) => ( )}
<option key={entry} value={entry}> </div>
{columnLabels[entry]} <div className="meta-row">
</option> <span className="pill">{feature.status}</span>
))} <label>
</select> <span className="sr-only">Move feature</span>
</label> <select
</div> value={feature.column}
</button> 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 className="empty-state">No features here yet.</div>
)} )}
@@ -790,6 +839,9 @@ function App() {
<button type="button" className="ghost" onClick={resetFeatureDraft}>Clear</button> <button type="button" className="ghost" onClick={resetFeatureDraft}>Clear</button>
{selectedFeature && ( {selectedFeature && (
<> <>
<button type="button" className="ghost" onClick={() => openFeaturePulse(selectedFeature.id)}>
Log Feature Pulse
</button>
<button type="button" className="ghost" onClick={() => openFeatureHandoff(selectedFeature.id)}> <button type="button" className="ghost" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Prepare AI Handoff Prepare AI Handoff
</button> </button>
+23 -1
View File
@@ -354,6 +354,7 @@ button.small {
.column-header, .column-header,
.item-card-header, .item-card-header,
.meta-row, .meta-row,
.feature-signal-row,
.status-bar, .status-bar,
.filters-heading { .filters-heading {
display: flex; display: flex;
@@ -405,6 +406,26 @@ pre {
gap: 0.65rem; 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 { .item-card select {
min-width: 120px; min-width: 120px;
padding: 0.5rem 0.7rem; padding: 0.5rem 0.7rem;
@@ -516,7 +537,8 @@ pre {
.tab-bar, .tab-bar,
.button-row, .button-row,
.filter-row, .filter-row,
.status-bar { .status-bar,
.feature-signal-row {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
+22 -7
View File
@@ -1,15 +1,30 @@
import type { AppState } from './types' import type { AppState } from './types'
const API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || '' 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 request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(`${API_BASE}${path}`, { const controller = new AbortController()
headers: { const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
'Content-Type': 'application/json',
...(init?.headers || {}), let response: Response
}, try {
...init, 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) { if (!response.ok) {
const message = await response.text() const message = await response.text()