diff --git a/server/appwriteBackend.mjs b/server/appwriteBackend.mjs index 39042cc..70c684a 100644 --- a/server/appwriteBackend.mjs +++ b/server/appwriteBackend.mjs @@ -11,17 +11,34 @@ const baseHeaders = { '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 collectionUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents` async function appwriteFetch(url, init = {}) { - const response = await fetch(url, { - ...init, - headers: { - ...baseHeaders, - ...(init.headers || {}), - }, - }) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + let response + 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 diff --git a/src/App.tsx b/src/App.tsx index aeeef1f..a830fc5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -157,6 +157,24 @@ function App() { [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 [] @@ -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() {

Selected and ready. Shape it here, then kick a clean brief to your agent.

+ @@ -693,34 +728,48 @@ function App() {
{groupedFeatures[column].length ? ( - groupedFeatures[column].map((feature) => ( - - )) + groupedFeatures[column].map((feature) => { + const pulseMeta = featurePulseMeta.get(feature.id) + + return ( + + ) + }) ) : (
No features here yet.
)} @@ -790,6 +839,9 @@ function App() { {selectedFeature && ( <> + diff --git a/src/index.css b/src/index.css index 25e1354..d45d821 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/store/remote.ts b/src/store/remote.ts index 33f1c58..6ecaf00 100644 --- a/src/store/remote.ts +++ b/src/store/remote.ts @@ -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 (path: string, init?: RequestInit): Promise => { - 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()