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
+178 -45
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,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<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;
}