From ec85b8e4d7793ef4ad26fd6f91810b4057359f80 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 11 May 2026 22:49:52 +0200 Subject: [PATCH] feat: add editable handoff preview flow --- README.md | 2 +- docs/ROADMAP.md | 2 +- package.json | 2 +- src/App.tsx | 223 +++++++++++++++++++----- src/features/project/projectDefaults.ts | 10 +- src/index.css | 25 +++ 6 files changed, 211 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e99966e..ff664eb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ BuildPulse is a calm planning cockpit for AI-assisted product building. It helps capture features, park distracting ideas, log progress as Pulse events, and export clean project context for AI coding agents such as Claude Code, Codex, OpenCode, OpenClaw, or future autonomous agents. Current release line: -- v0.4.0 — one-tap feature handoffs with target-specific briefs and optional INTENT logging +- v0.4.1 — preview/edit-before-copy handoffs with cleaner INTENT controls and tighter mobile UX Personal runtime target: - `build.friborg.uk` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e47f6f3..09eaa33 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -67,9 +67,9 @@ Current shipped slices: - v0.3.1 — focused handoff shortcuts - v0.3.2 — target-specific handoff presets - v0.4.0 — one-tap feature handoffs +- v0.4.1 — preview/edit before copy + explicit INTENT controls Planned slices: -- v0.4.1 — preview/edit before copy + optional INTENT pulse controls - v0.4.2 — paste agent result into RESULT/BLOCKER/TEST_RESULT pulses - v0.4.3 — session modes (30-minute, feature-based, bugfix, QA review) diff --git a/package.json b/package.json index 2dc5ab2..4d1fa40 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "buildpulse", "private": true, - "version": "0.4.0", + "version": "0.4.1", "type": "module", "scripts": { "api": "node --env-file=../.env server/index.mjs", diff --git a/src/App.tsx b/src/App.tsx index d6fb43a..e9bff77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -118,8 +118,11 @@ function App() { const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all') const [pulseSourceFilter, setPulseSourceFilter] = useState('all') const [promptTarget, setPromptTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw') - const [logIntentOnHandoff, setLogIntentOnHandoff] = useState(true) const [promptFeatureId, setPromptFeatureId] = useState('') + const [handoffPreviewOpen, setHandoffPreviewOpen] = useState(false) + const [handoffPreviewTarget, setHandoffPreviewTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw') + const [handoffPreviewFeatureId, setHandoffPreviewFeatureId] = useState('') + const [handoffPreviewDraft, setHandoffPreviewDraft] = 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) @@ -231,6 +234,11 @@ function App() { [appState.features, selectedFeatureId], ) + const handoffPreviewFeature = useMemo( + () => appState.features.find((feature) => feature.id === handoffPreviewFeatureId) ?? null, + [appState.features, handoffPreviewFeatureId], + ) + const selectedParkingItem = useMemo( () => appState.parking_lot.find((item) => item.id === selectedParkingId) ?? null, [appState.parking_lot, selectedParkingId], @@ -1204,44 +1212,61 @@ function App() { } } - const copyFocusedHandoff = async (featureId?: string, target = promptTarget, shouldLogIntent = logIntentOnHandoff) => { + const buildHandoffPrompt = (featureId?: string, target = promptTarget) => { const resolvedFeatureId = featureId ?? selectedFeature?.id ?? (promptFeatureId || groupedFeatures.now[0]?.id) - const prompt = createAgentSessionPrompt(appState, { - featureId: resolvedFeatureId || undefined, - target, - }) + return { + resolvedFeatureId, + prompt: createAgentSessionPrompt(appState, { + featureId: resolvedFeatureId || undefined, + target, + }), + } + } + + const logHandoffIntent = (featureId: string, target: (typeof PROMPT_TARGETS)[number]) => { + const feature = appState.features.find((entry) => entry.id === featureId) + if (!feature) return null + + const timestamp = nowIso() + const pulse: PulseEvent = { + id: `pulse_${Date.now().toString(36)}`, + timestamp, + project_id: appState.project.id, + feature_id: feature.id, + source: 'buildpulse', + agent_id: target, + pulse_type: 'INTENT', + message: `Generated ${target} handoff for feature “${feature.title}”.`, + confidence_score: 0.9, + evidence_refs: [ + 'Feature-detail handoff action', + feature.release_id ? `Release: ${appState.releases.find((release) => release.id === feature.release_id)?.name || feature.release_id}` : 'No linked release', + feature.phase_id ? `Phase: ${appState.phases.find((phase) => phase.id === feature.phase_id)?.title || feature.phase_id}` : 'No linked phase', + ], + } + + setAppState((current) => ({ + ...current, + pulses: [pulse, ...current.pulses], + })) + + return feature + } + + const copyFocusedHandoff = async (featureId?: string, target = promptTarget, shouldLogIntent = false, promptOverride?: string) => { + const { resolvedFeatureId, prompt } = buildHandoffPrompt(featureId, target) + const finalPrompt = promptOverride ?? prompt try { - await navigator.clipboard.writeText(prompt) + await navigator.clipboard.writeText(finalPrompt) if (resolvedFeatureId) { setPromptFeatureId(resolvedFeatureId) } setPromptTarget(target) - const feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null - if (feature && shouldLogIntent) { - const timestamp = nowIso() - const pulse: PulseEvent = { - id: `pulse_${Date.now().toString(36)}`, - timestamp, - project_id: appState.project.id, - feature_id: feature.id, - source: 'buildpulse', - agent_id: target, - pulse_type: 'INTENT', - message: `Generated ${target} handoff for feature “${feature.title}”.`, - confidence_score: 0.9, - evidence_refs: [ - 'Feature-detail handoff action', - feature.release_id ? `Release: ${appState.releases.find((release) => release.id === feature.release_id)?.name || feature.release_id}` : 'No linked release', - feature.phase_id ? `Phase: ${appState.phases.find((phase) => phase.id === feature.phase_id)?.title || feature.phase_id}` : 'No linked phase', - ], - } - - setAppState((current) => ({ - ...current, - pulses: [pulse, ...current.pulses], - })) + let feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null + if (resolvedFeatureId && shouldLogIntent) { + feature = logHandoffIntent(resolvedFeatureId, target) ?? feature } setStatusMessage( @@ -1254,8 +1279,33 @@ function App() { } } + const openHandoffPreview = (featureId?: string, target = promptTarget) => { + const { resolvedFeatureId, prompt } = buildHandoffPrompt(featureId, target) + if (resolvedFeatureId) { + setPromptFeatureId(resolvedFeatureId) + setHandoffPreviewFeatureId(resolvedFeatureId) + } + setHandoffPreviewTarget(target) + setHandoffPreviewDraft(prompt) + setHandoffPreviewOpen(true) + setStatusMessage('Preview the handoff, tweak it if needed, then copy when ready.') + } + + const syncHandoffPreviewTarget = (target: (typeof PROMPT_TARGETS)[number]) => { + const { prompt } = buildHandoffPrompt(handoffPreviewFeatureId || undefined, target) + setHandoffPreviewTarget(target) + setHandoffPreviewDraft(prompt) + } + + const resetHandoffPreview = () => { + if (!handoffPreviewFeatureId) return + const { prompt } = buildHandoffPrompt(handoffPreviewFeatureId, handoffPreviewTarget) + setHandoffPreviewDraft(prompt) + setStatusMessage('Handoff preview reset to the current preset.') + } + const copySessionPrompt = async () => { - await copyFocusedHandoff(promptFeatureId || undefined, promptTarget) + await copyFocusedHandoff(promptFeatureId || undefined, promptTarget, false) } const handleImport = async (event: ChangeEvent) => { @@ -1385,7 +1435,7 @@ function App() {
-

BuildPulse v0.4.0

+

BuildPulse v0.4.1

{appState.project.name}

Current goal: {appState.project.current_goal || 'Classify new ideas before they become work.'} @@ -1649,6 +1699,93 @@ function App() {

)} + {handoffPreviewOpen && handoffPreviewFeature && ( +
+
+
+
+

v0.4.1 Handoff preview

+

{handoffPreviewFeature.title}

+

Choose the target, tweak the brief if needed, then copy it cleanly.

+
+ +
+ +
+
+

Preview context

+
+ {handoffPreviewTarget} + {handoffPreviewFeature.status} + {handoffPreviewFeature.priority} + {handoffPreviewFeature.release_role && {handoffPreviewFeature.release_role}} +
+

Phase: {appState.phases.find((phase) => phase.id === handoffPreviewFeature.phase_id)?.title || 'No phase linked'}

+

Release: {appState.releases.find((release) => release.id === handoffPreviewFeature.release_id)?.name || 'No release linked'}

+

Acceptance criteria: {handoffPreviewFeature.acceptance_criteria.length ? handoffPreviewFeature.acceptance_criteria.join('; ') : 'None yet'}

+

Scope guard: {handoffPreviewFeature.scope_notes || 'No scope notes yet.'}

+
+
+

Handoff controls

+
+ {PROMPT_TARGETS.map((target) => ( + + ))} +
+

Preview mode is the safer path when you want to edit wording or create a clean INTENT pulse.

+
+ + +
+
+
+ +