feat: add editable handoff preview flow

This commit is contained in:
OpenClaw Bot
2026-05-11 22:49:52 +02:00
parent 34413ffafa
commit ec85b8e4d7
6 changed files with 211 additions and 53 deletions
+1 -1
View File
@@ -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.0one-tap feature handoffs with target-specific briefs and optional INTENT logging
- v0.4.1preview/edit-before-copy handoffs with cleaner INTENT controls and tighter mobile UX
Personal runtime target:
- `build.friborg.uk`
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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",
+159 -26
View File
@@ -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<string | null>(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,22 +1212,21 @@ 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, {
return {
resolvedFeatureId,
prompt: createAgentSessionPrompt(appState, {
featureId: resolvedFeatureId || undefined,
target,
})
try {
await navigator.clipboard.writeText(prompt)
if (resolvedFeatureId) {
setPromptFeatureId(resolvedFeatureId)
}),
}
}
setPromptTarget(target)
const feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null
if (feature && shouldLogIntent) {
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)}`,
@@ -1242,6 +1249,24 @@ function App() {
...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(finalPrompt)
if (resolvedFeatureId) {
setPromptFeatureId(resolvedFeatureId)
}
setPromptTarget(target)
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<HTMLInputElement>) => {
@@ -1385,7 +1435,7 @@ function App() {
<div className="app-shell">
<header className="mobile-shell-header card">
<div>
<p className="eyebrow">BuildPulse v0.4.0</p>
<p className="eyebrow">BuildPulse v0.4.1</p>
<h1>{appState.project.name}</h1>
<p className="hero-goal compact-goal">
<strong>Current goal:</strong> {appState.project.current_goal || 'Classify new ideas before they become work.'}
@@ -1649,6 +1699,93 @@ function App() {
</div>
)}
{handoffPreviewOpen && handoffPreviewFeature && (
<div className="editor-sheet-backdrop" role="presentation">
<section className="card editor-sheet handoff-sheet" role="dialog" aria-modal="true" aria-label="Handoff preview">
<div className="section-heading compact">
<div>
<p className="eyebrow">v0.4.1 Handoff preview</p>
<h3>{handoffPreviewFeature.title}</h3>
<p>Choose the target, tweak the brief if needed, then copy it cleanly.</p>
</div>
<button type="button" className="ghost small" onClick={() => setHandoffPreviewOpen(false)}>
Close
</button>
</div>
<div className="sheet-summary-grid handoff-preview-grid">
<div className="focus-panel">
<h4>Preview context</h4>
<div className="focus-badges">
<span className="pill">{handoffPreviewTarget}</span>
<span className="pill">{handoffPreviewFeature.status}</span>
<span className={`pill ${handoffPreviewFeature.priority}`}>{handoffPreviewFeature.priority}</span>
{handoffPreviewFeature.release_role && <span className="pill">{handoffPreviewFeature.release_role}</span>}
</div>
<p><strong>Phase:</strong> {appState.phases.find((phase) => phase.id === handoffPreviewFeature.phase_id)?.title || 'No phase linked'}</p>
<p><strong>Release:</strong> {appState.releases.find((release) => release.id === handoffPreviewFeature.release_id)?.name || 'No release linked'}</p>
<p><strong>Acceptance criteria:</strong> {handoffPreviewFeature.acceptance_criteria.length ? handoffPreviewFeature.acceptance_criteria.join('; ') : 'None yet'}</p>
<p><strong>Scope guard:</strong> {handoffPreviewFeature.scope_notes || 'No scope notes yet.'}</p>
</div>
<div className="focus-panel">
<h4>Handoff controls</h4>
<div className="button-inline-row">
{PROMPT_TARGETS.map((target) => (
<button
key={target}
type="button"
className={handoffPreviewTarget === target ? 'small' : 'ghost small'}
onClick={() => syncHandoffPreviewTarget(target)}
>
{target}
</button>
))}
</div>
<p className="tap-hint">Preview mode is the safer path when you want to edit wording or create a clean INTENT pulse.</p>
<div className="button-inline-row">
<button type="button" className="ghost small" onClick={resetHandoffPreview}>Reset to preset</button>
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(handoffPreviewFeature.id)}>Open in Export</button>
</div>
</div>
</div>
<label className="full-span handoff-preview-editor">
<span>Editable handoff brief</span>
<textarea
rows={18}
value={handoffPreviewDraft}
onChange={(event) => setHandoffPreviewDraft(event.target.value)}
/>
</label>
<div className="button-row sheet-actions">
<button
type="button"
onClick={() => {
void copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, false, handoffPreviewDraft)
setHandoffPreviewOpen(false)
}}
>
Copy Handoff
</button>
<button
type="button"
className="ghost"
onClick={() => {
void copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, true, handoffPreviewDraft)
setHandoffPreviewOpen(false)
}}
>
Copy + INTENT Pulse
</button>
<button type="button" className="ghost" onClick={() => setHandoffPreviewOpen(false)}>
Cancel
</button>
</div>
</section>
</div>
)}
{featureSheetOpen && (
<div className="editor-sheet-backdrop" role="presentation">
<section className="card editor-sheet" role="dialog" aria-modal="true" aria-label={selectedFeature ? 'Feature details' : 'Manual feature'}>
@@ -1713,26 +1850,22 @@ function App() {
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Prepare AI Handoff
</button>
<button type="button" className="ghost small" onClick={() => openHandoffPreview(selectedFeature.id, 'OpenClaw')}>
Preview / Edit Handoff
</button>
</div>
<label className="tap-chip">
<input
type="checkbox"
checked={logIntentOnHandoff}
onChange={(event) => setLogIntentOnHandoff(event.target.checked)}
/>
<span>Log INTENT pulse when copying handoff</span>
</label>
<p className="tap-hint">Quick copy if you already know the target. Use preview when you want to tweak the brief or log an INTENT cleanly.</p>
<div className="button-inline-row">
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw')}>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw', false)}>
Copy for OpenClaw
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Claude Code')}>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Claude Code', false)}>
Copy for Claude Code
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Codex')}>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Codex', false)}>
Copy for Codex
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Generic Agent')}>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Generic Agent', false)}>
Copy Generic Brief
</button>
</div>
+5 -5
View File
@@ -131,13 +131,13 @@ export const createSeedState = (): AppState => ({
definition_of_done: [
'Each feature detail exposes one-tap copy actions for OpenClaw, Claude Code, Codex, and a generic brief.',
'Prompt generation includes phase, release, readiness, blockers, parking-lot, and forbidden-work guardrails.',
'Copy actions can optionally log an INTENT pulse without triggering any live execution.',
'Preview/edit before copy is available with explicit INTENT pulse controls and mobile-safe actions.',
],
required_feature_ids: ['export_screen', 'feature_focused_handoff_shortcuts'],
optional_feature_ids: ['pulse_log_screen'],
forbidden_feature_titles: ['Live OpenClaw/Hermes Agent Status', 'WebSocket agent telemetry', 'GitHub / Gitea sync'],
status: 'in_progress',
notes: 'v0.4.0 focuses on one-tap feature handoffs before preview/edit and result-capture slices.',
notes: 'v0.4.1 adds preview/edit before copy and cleaner INTENT controls; result capture comes next.',
created_at: seedDate,
updated_at: seedDate,
},
@@ -152,10 +152,10 @@ export const createSeedState = (): AppState => ({
status: 'building',
acceptance_criteria: [
'Feature detail exposes separate copy actions for OpenClaw, Claude Code, Codex, and Generic.',
'Copied prompt includes release context, blockers, and forbidden-work warnings.',
'Copy can optionally log an INTENT pulse for the chosen target.',
'Preview/edit is available before copy, with target switching and reset-to-preset behavior.',
'INTENT pulse logging is explicit from the preview flow instead of hidden behind a toggle.',
],
scope_notes: 'This is the v0.4.0 loop: pick a feature, copy the right brief in one tap, then let the agent work.',
scope_notes: 'This is the v0.4 loop: quick-copy when you know the target, preview/edit when you want a safer brief, then let the agent work.',
phase_id: 'phase_session_handoff',
release_id: 'release_v040_focused_handoffs',
release_role: 'required',
+25
View File
@@ -263,6 +263,31 @@ textarea {
padding: 1rem;
}
.handoff-sheet {
max-width: 1080px;
}
.handoff-preview-grid {
align-items: start;
}
.handoff-preview-editor {
display: grid;
gap: 0.55rem;
}
.handoff-preview-editor span {
font-size: 0.85rem;
color: #9fb9d6;
}
.handoff-preview-editor textarea {
min-height: 22rem;
font-family: 'IBM Plex Mono', 'SFMono-Regular', ui-monospace, monospace;
font-size: 0.85rem;
line-height: 1.45;
}
.focus-panel h4 {
margin: 0 0 0.65rem;
}