feat: add editable handoff preview flow
This commit is contained in:
+178
-45
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user