feat: ship one-tap feature handoffs
This commit is contained in:
+54
-5
@@ -118,6 +118,7 @@ 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 [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
|
||||
const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting')
|
||||
@@ -1203,7 +1204,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const copyFocusedHandoff = async (featureId?: string, target = promptTarget) => {
|
||||
const copyFocusedHandoff = async (featureId?: string, target = promptTarget, shouldLogIntent = logIntentOnHandoff) => {
|
||||
const resolvedFeatureId = featureId ?? selectedFeature?.id ?? (promptFeatureId || groupedFeatures.now[0]?.id)
|
||||
const prompt = createAgentSessionPrompt(appState, {
|
||||
featureId: resolvedFeatureId || undefined,
|
||||
@@ -1218,7 +1219,36 @@ function App() {
|
||||
setPromptTarget(target)
|
||||
|
||||
const feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null
|
||||
setStatusMessage(feature ? `Focused AI handoff for “${feature.title}” copied.` : 'Focused AI handoff copied.')
|
||||
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],
|
||||
}))
|
||||
}
|
||||
|
||||
setStatusMessage(
|
||||
feature
|
||||
? `${target} handoff for “${feature.title}” copied${shouldLogIntent ? ' and INTENT logged' : ''}.`
|
||||
: `${target} handoff copied${shouldLogIntent ? ' and INTENT logged' : ''}.`,
|
||||
)
|
||||
} catch {
|
||||
setStatusMessage('Clipboard copy failed. Browser said no.')
|
||||
}
|
||||
@@ -1355,7 +1385,7 @@ function App() {
|
||||
<div className="app-shell">
|
||||
<header className="mobile-shell-header card">
|
||||
<div>
|
||||
<p className="eyebrow">BuildPulse v0.3.1</p>
|
||||
<p className="eyebrow">BuildPulse v0.4.0</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.'}
|
||||
@@ -1683,8 +1713,27 @@ function App() {
|
||||
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}>
|
||||
Prepare AI 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>
|
||||
<div className="button-inline-row">
|
||||
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw')}>
|
||||
Copy AI Handoff
|
||||
Copy for OpenClaw
|
||||
</button>
|
||||
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Claude Code')}>
|
||||
Copy for Claude Code
|
||||
</button>
|
||||
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Codex')}>
|
||||
Copy for Codex
|
||||
</button>
|
||||
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Generic Agent')}>
|
||||
Copy Generic Brief
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1958,7 +2007,7 @@ function App() {
|
||||
<section className="card functionality-hero">
|
||||
<div>
|
||||
<p className="eyebrow">Secondary status</p>
|
||||
<h3>{appState.project.name} is now a local-first v0.3 cockpit with Appwrite sync, release-planning structure, and faster handoff prep.</h3>
|
||||
<h3>{appState.project.name} is now a local-first v0.4 cockpit with release structure, AI triage, and one-tap handoff prep.</h3>
|
||||
<p>
|
||||
The planning loop works from browser storage first, then syncs to Appwrite for the deployed Unraid runtime. If sync degrades, the cockpit should still stay usable locally.
|
||||
</p>
|
||||
|
||||
@@ -39,6 +39,8 @@ const renderFeature = (feature: Feature) => {
|
||||
const sortPulsesNewestFirst = (pulses: PulseEvent[]) =>
|
||||
[...pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
|
||||
const isFeatureDone = (feature: Feature) => feature.status === 'done' || feature.column === 'done'
|
||||
|
||||
const targetPresets: Record<string, { intro: string; workingStyle: string[]; deliverBack: string[] }> = {
|
||||
OpenClaw: {
|
||||
intro: 'Work like a proactive operator: act, verify, and keep the slice small enough to ship cleanly.',
|
||||
@@ -94,6 +96,29 @@ export const createAgentSessionPrompt = (
|
||||
const relatedPulses = sortPulsesNewestFirst(state.pulses)
|
||||
.filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true))
|
||||
.slice(0, 6)
|
||||
const releaseFeatures = focusRelease
|
||||
? state.features.filter((feature) => feature.release_id === focusRelease.id)
|
||||
: []
|
||||
const requiredReleaseFeatures = focusRelease
|
||||
? releaseFeatures.filter((feature) => focusRelease.required_feature_ids.includes(feature.id))
|
||||
: []
|
||||
const completedRequiredFeatures = requiredReleaseFeatures.filter(isFeatureDone)
|
||||
const remainingRequiredFeatures = requiredReleaseFeatures.filter((feature) => !isFeatureDone(feature))
|
||||
const releaseBlockers = sortPulsesNewestFirst(state.pulses)
|
||||
.filter((pulse) => {
|
||||
if (pulse.pulse_type !== 'BLOCKER') return false
|
||||
if (focusFeature && pulse.feature_id === focusFeature.id) return true
|
||||
if (!focusRelease) return false
|
||||
return releaseFeatures.some((feature) => feature.id === pulse.feature_id)
|
||||
})
|
||||
.slice(0, 4)
|
||||
const forbiddenWarnings = [
|
||||
...(focusRelease?.forbidden_feature_titles || []).map((title) => `${title} — forbidden in this release.`),
|
||||
...state.parking_lot.map((item) => `${item.title} — ${item.reason_parked || item.description || 'Parked for later.'}`),
|
||||
]
|
||||
const readinessSummary = focusRelease
|
||||
? `Required done: ${completedRequiredFeatures.length}/${requiredReleaseFeatures.length || focusRelease.required_feature_ids.length || 0}`
|
||||
: 'No release linked to this feature yet.'
|
||||
|
||||
const renderFeatureBlock = (feature: Feature | null) => {
|
||||
if (!feature) return '- No specific feature selected yet. Choose the smallest useful next move.'
|
||||
@@ -138,15 +163,28 @@ export const createAgentSessionPrompt = (
|
||||
'## Next Up',
|
||||
grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.',
|
||||
'',
|
||||
'## Current Release Context',
|
||||
'## Current Phase + Release Context',
|
||||
focusRelease
|
||||
? [`- Release: ${focusRelease.name}`, `- Goal: ${focusRelease.goal}`, `- Status: ${focusRelease.status}`, `- Definition of done: ${focusRelease.definition_of_done.length ? focusRelease.definition_of_done.join('; ') : '—'}`].join('\n')
|
||||
: '- No release linked to this feature yet.',
|
||||
? [
|
||||
`- Phase: ${focusPhase?.title || '—'} (${focusPhase?.status || '—'})`,
|
||||
`- Release: ${focusRelease.name}`,
|
||||
`- Goal: ${focusRelease.goal}`,
|
||||
`- Status: ${focusRelease.status}`,
|
||||
`- Release readiness: ${readinessSummary}`,
|
||||
`- Remaining required features: ${remainingRequiredFeatures.length ? remainingRequiredFeatures.map((feature) => feature.title).join('; ') : 'None'}`,
|
||||
`- Definition of done: ${focusRelease.definition_of_done.length ? focusRelease.definition_of_done.join('; ') : '—'}`,
|
||||
].join('\n')
|
||||
: `- Phase: ${focusPhase?.title || '—'} (${focusPhase?.status || '—'})\n- No release linked to this feature yet.`,
|
||||
'',
|
||||
'## Parking Lot / Do Not Implement Yet',
|
||||
state.parking_lot.length
|
||||
? state.parking_lot.map((item) => `- ${item.title} — ${item.reason_parked || item.description || 'Parked for later.'}`).join('\n')
|
||||
: '- Nothing parked yet.',
|
||||
'## Relevant Blockers',
|
||||
releaseBlockers.length
|
||||
? releaseBlockers.map((pulse) => `- ${formatDateTime(pulse.timestamp)} · ${getFeatureLabel(state.features, pulse.feature_id)} · ${pulse.message}`).join('\n')
|
||||
: '- No current blocker pulses for this feature or release.',
|
||||
'',
|
||||
'## Parking Lot / Forbidden Work',
|
||||
forbiddenWarnings.length
|
||||
? forbiddenWarnings.map((item) => `- ${item}`).join('\n')
|
||||
: '- Nothing parked or forbidden right now.',
|
||||
'',
|
||||
'## Recent Relevant Pulses',
|
||||
relatedPulses.length
|
||||
|
||||
@@ -129,15 +129,15 @@ export const createSeedState = (): AppState => ({
|
||||
name: 'v0.4 — Focused Session Handoffs',
|
||||
goal: 'Cut the friction between choosing a feature and giving a coding agent a sharp, minimal brief.',
|
||||
definition_of_done: [
|
||||
'Feature detail includes a one-click copy handoff action.',
|
||||
'Status detail can copy a focused handoff without detouring through export archaeology.',
|
||||
'Prompt generation still respects release, phase, parking-lot, and recent-pulse guardrails.',
|
||||
'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.',
|
||||
],
|
||||
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: 'First concrete v0.4 slice before deeper prompt presets or session templates.',
|
||||
notes: 'v0.4.0 focuses on one-tap feature handoffs before preview/edit and result-capture slices.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
@@ -145,17 +145,17 @@ export const createSeedState = (): AppState => ({
|
||||
features: [
|
||||
{
|
||||
id: 'feature_focused_handoff_shortcuts',
|
||||
title: 'Focused handoff shortcuts',
|
||||
description: 'Let operators copy a sharp AI handoff directly from feature and status detail surfaces.',
|
||||
title: 'One-tap target-specific handoffs',
|
||||
description: 'Let operators copy a sharp AI handoff for OpenClaw, Claude Code, Codex, or a generic agent directly from feature detail.',
|
||||
column: 'now',
|
||||
priority: 'must',
|
||||
status: 'building',
|
||||
acceptance_criteria: [
|
||||
'Feature detail includes a one-click copy handoff action.',
|
||||
'Status detail can copy a focused handoff without navigating the whole Export screen.',
|
||||
'Copied prompt still respects the selected feature or first active Now item.',
|
||||
'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.',
|
||||
],
|
||||
scope_notes: 'This is the first concrete v0.4 step: less archaeology before a coding session starts.',
|
||||
scope_notes: 'This is the v0.4.0 loop: pick a feature, copy the right brief in one tap, then let the agent work.',
|
||||
phase_id: 'phase_session_handoff',
|
||||
release_id: 'release_v040_focused_handoffs',
|
||||
release_role: 'required',
|
||||
|
||||
@@ -426,6 +426,9 @@ pre {
|
||||
|
||||
.tap-chip {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.15rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.65rem;
|
||||
@@ -435,6 +438,10 @@ pre {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tap-chip input {
|
||||
accent-color: #818cf8;
|
||||
}
|
||||
|
||||
.feature-signal-row {
|
||||
font-size: 0.82rem;
|
||||
color: #9fb4d9;
|
||||
|
||||
Reference in New Issue
Block a user