feat: start buildpulse ux simplification pass

This commit is contained in:
OpenClaw Bot
2026-05-12 19:53:54 +02:00
parent 0962548217
commit f6e0142226
3 changed files with 1396 additions and 84 deletions
File diff suppressed because it is too large Load Diff
+155 -84
View File
@@ -9,11 +9,11 @@ import type { AiPlacement, AiRecommendation, AppState, Feature, FeatureColumn, P
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format' import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
const TABS: Array<{ key: TabKey; label: string }> = [ const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'feature-plan', label: 'Plan' }, { key: 'feature-plan', label: 'Today' },
{ key: 'roadmap', label: 'Roadmap' }, { key: 'parking-lot', label: 'Ideas' },
{ key: 'parking-lot', label: 'Park' }, { key: 'roadmap', label: 'Release' },
{ key: 'pulse-log', label: 'Pulse' }, { key: 'pulse-log', label: 'History' },
{ key: 'export', label: 'Export' }, { key: 'export', label: 'Session' },
] ]
const PROMPT_TARGETS = ['OpenClaw', 'Claude Code', 'Codex', 'Generic Agent'] as const const PROMPT_TARGETS = ['OpenClaw', 'Claude Code', 'Codex', 'Generic Agent'] as const
@@ -316,6 +316,25 @@ function App() {
.slice(0, 6) .slice(0, 6)
}, [appState.pulses, selectedRelease]) }, [appState.pulses, selectedRelease])
const focusFeature = useMemo(
() => groupedFeatures.now.find((feature) => feature.status !== 'done') ?? groupedFeatures.now[0] ?? groupedFeatures.next[0] ?? appState.features[0] ?? null,
[appState.features, groupedFeatures.next, groupedFeatures.now],
)
const duplicateParkingGroups = useMemo(() => {
const groups = new Map<string, ParkingLotItem[]>()
appState.parking_lot.forEach((item) => {
const key = item.title.toLowerCase().replace(/[^a-z0-9æøå]+/gi, ' ').trim()
if (!key) return
groups.set(key, [...(groups.get(key) ?? []), item])
})
return Array.from(groups.values()).filter((items) => items.length > 1)
}, [appState.parking_lot])
const activeReleaseSummary = selectedRelease
? `${selectedRelease.name} · ${selectedReleaseRequiredDoneCount}/${selectedReleaseRequiredFeatures.length || 0} required done`
: 'No active release selected'
const parkingByRisk = useMemo( const parkingByRisk = useMemo(
() => () =>
PARKING_RISK_ORDER.map((risk) => ({ PARKING_RISK_ORDER.map((risk) => ({
@@ -364,23 +383,46 @@ function App() {
? 'status-connecting' ? 'status-connecting'
: '' : ''
useEffect(() => {
const closeTopSheet = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (handoffPreviewOpen) {
setHandoffPreviewOpen(false)
return
}
if (triageOpen) {
setTriageOpen(false)
return
}
if (pulseSheetOpen) {
setSelectedPulseId(null)
setPulseDraft(initialPulseDraft)
setShowManualPulseEditor(false)
return
}
if (parkingSheetOpen) {
setSelectedParkingId(null)
setParkingDraft(initialParkingDraft)
setShowManualParkingEditor(false)
return
}
if (featureSheetOpen) {
setSelectedFeatureId(null)
setFeatureDraft(initialFeatureDraft)
setShowManualFeatureEditor(false)
}
}
window.addEventListener('keydown', closeTopSheet)
return () => window.removeEventListener('keydown', closeTopSheet)
}, [featureSheetOpen, handoffPreviewOpen, parkingSheetOpen, pulseSheetOpen, triageOpen])
const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState]) const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState])
const sessionPrompt = useMemo( const sessionPrompt = useMemo(
() => createAgentSessionPrompt(appState, { featureId: promptFeatureId || undefined, target: promptTarget }), () => createAgentSessionPrompt(appState, { featureId: promptFeatureId || undefined, target: promptTarget }),
[appState, promptFeatureId, promptTarget], [appState, promptFeatureId, promptTarget],
) )
const updateProject = (field: keyof AppState['project'], value: string) => {
setAppState((current) => ({
...current,
project: {
...current.project,
[field]: value,
updated_at: nowIso(),
},
}))
}
const updatePhase = (phaseId: string, field: keyof ProjectPhase, value: string | number) => { const updatePhase = (phaseId: string, field: keyof ProjectPhase, value: string | number) => {
setAppState((current) => ({ setAppState((current) => ({
...current, ...current,
@@ -1026,10 +1068,28 @@ function App() {
featureId, featureId,
pulseType: 'ACTION', pulseType: 'ACTION',
})) }))
setShowManualPulseEditor(true)
setActiveTab('pulse-log') setActiveTab('pulse-log')
setStatusMessage(feature ? `Pulse composer aimed at “${feature.title}”.` : 'Pulse composer ready.') setStatusMessage(feature ? `Pulse composer aimed at “${feature.title}”.` : 'Pulse composer ready.')
} }
const openFeatureResultCapture = (featureId: string) => {
const feature = appState.features.find((entry) => entry.id === featureId)
setSelectedPulseId(null)
setPulseDraft((current) => ({
...initialPulseDraft,
source: 'agent-session',
agentId: current.agentId || 'OpenClaw',
featureId,
pulseType: 'RESULT',
message: feature ? `Agent result for “${feature.title}”: ` : 'Agent result: ',
evidenceRefs: 'Pasted agent/session result',
}))
setShowManualPulseEditor(true)
setActiveTab('pulse-log')
setStatusMessage(feature ? `Paste the agent result for “${feature.title}”.` : 'Paste the agent result and save it as history.')
}
const beginParkingEdit = (item: ParkingLotItem) => { const beginParkingEdit = (item: ParkingLotItem) => {
setSelectedParkingId(item.id) setSelectedParkingId(item.id)
setParkingDraft({ setParkingDraft({
@@ -1765,7 +1825,7 @@ function App() {
)} )}
{handoffPreviewOpen && handoffPreviewFeature && ( {handoffPreviewOpen && handoffPreviewFeature && (
<div className="editor-sheet-backdrop" role="presentation"> <div className="editor-sheet-backdrop handoff-backdrop" role="presentation">
<section className="card editor-sheet handoff-sheet" role="dialog" aria-modal="true" aria-label="Handoff preview"> <section className="card editor-sheet handoff-sheet" role="dialog" aria-modal="true" aria-label="Handoff preview">
<div className="section-heading compact"> <div className="section-heading compact">
<div> <div>
@@ -1858,7 +1918,7 @@ function App() {
<section className="card editor-sheet" role="dialog" aria-modal="true" aria-label={selectedFeature ? 'Feature details' : 'Manual feature'}> <section className="card editor-sheet" role="dialog" aria-modal="true" aria-label={selectedFeature ? 'Feature details' : 'Manual feature'}>
<div className="section-heading compact"> <div className="section-heading compact">
<div> <div>
<p className="eyebrow">{selectedFeature ? 'Feature focus' : 'Manual feature'}</p> <p className="eyebrow">{selectedFeature ? 'Build session' : 'Manual feature'}</p>
<h3>{selectedFeature ? selectedFeature.title : 'Add Feature'}</h3> <h3>{selectedFeature ? selectedFeature.title : 'Add Feature'}</h3>
<p>{selectedFeature ? (selectedFeature.description || 'No description yet.') : 'Use this when you deliberately want to bypass AI triage.'}</p> <p>{selectedFeature ? (selectedFeature.description || 'No description yet.') : 'Use this when you deliberately want to bypass AI triage.'}</p>
</div> </div>
@@ -1876,13 +1936,23 @@ function App() {
{selectedFeature && ( {selectedFeature && (
<div className="sheet-summary-grid"> <div className="sheet-summary-grid">
<div className="focus-panel"> <div className="focus-panel session-command-panel">
<div className="focus-badges"> <div className="focus-badges">
<span className={`pill ${selectedFeature.priority}`}>{selectedFeature.priority}</span> <span className={`pill ${selectedFeature.priority}`}>{selectedFeature.priority}</span>
<span className="pill">{selectedFeature.status}</span> <span className="pill">{selectedFeature.status}</span>
<span className="pill">{columnLabels[selectedFeature.column]}</span> <span className="pill">{columnLabels[selectedFeature.column]}</span>
{selectedFeature.release_role && <span className="pill">{selectedFeature.release_role}</span>} {selectedFeature.release_role && <span className="pill">{selectedFeature.release_role}</span>}
</div> </div>
<h4>Next sane action</h4>
<p>Start a focused AI session, then come back and record the result. This is the v0.4 loop.</p>
<div className="button-inline-row sticky-action-row">
<button type="button" className="primary small" onClick={() => openHandoffPreview(selectedFeature.id, 'OpenClaw')}>
Start AI Session
</button>
<button type="button" className="ghost small" onClick={() => openFeatureResultCapture(selectedFeature.id)}>
Record Result
</button>
</div>
<h4>Acceptance Criteria</h4> <h4>Acceptance Criteria</h4>
{selectedFeature.acceptance_criteria.length ? ( {selectedFeature.acceptance_criteria.length ? (
<ul> <ul>
@@ -1912,16 +1982,13 @@ function App() {
)} )}
<div className="button-inline-row"> <div className="button-inline-row">
<button type="button" className="ghost small" onClick={() => openFeaturePulse(selectedFeature.id)}> <button type="button" className="ghost small" onClick={() => openFeaturePulse(selectedFeature.id)}>
Log Feature Pulse Add Activity Note
</button> </button>
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}> <button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Open in Export Open Session Export
</button>
<button type="button" className="primary small" onClick={() => openHandoffPreview(selectedFeature.id, 'OpenClaw')}>
Preview / Edit Handoff
</button> </button>
</div> </div>
<p className="tap-hint">Preview is the safer path. Quick-copy is for when you already know the target and do not need edits.</p> <p className="tap-hint">Quick-copy is here for speed. Use Start AI Session when the wording matters.</p>
<p className="tap-hint">Quick copy targets</p> <p className="tap-hint">Quick copy targets</p>
<div className="button-inline-row"> <div className="button-inline-row">
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw', false)}> <button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw', false)}>
@@ -2355,65 +2422,77 @@ function App() {
{activeTab === 'feature-plan' && ( {activeTab === 'feature-plan' && (
<section className="view-stack plan-view"> <section className="view-stack plan-view">
<section className="card plan-home-card"> <section className="card today-card">
<div className="section-heading compact"> <div className="section-heading compact">
<div> <div>
<p className="eyebrow">Home / Plan</p> <p className="eyebrow">Today</p>
<h2>Triage first. Create later. Log always.</h2> <h2>{appState.project.current_goal || 'Classify new ideas before they become work.'}</h2>
<p>{appState.project.current_goal || 'Classify new ideas before they become work.'}</p> <p>BuildPulse now starts with the next sane action not the whole database spilled onto the floor.</p>
</div> </div>
<div className="button-inline-row"> <div className="button-inline-row">
<button type="button" className="primary-triage-button" onClick={() => openTriage()}> <button type="button" className="primary-triage-button" onClick={() => openTriage()}>
Triage Idea + Add Idea
</button> </button>
<button type="button" className="ghost" onClick={() => setShowManualFeatureEditor((current) => !current)}> <button type="button" className="ghost" onClick={() => setShowManualFeatureEditor((current) => !current)}>
{showManualFeatureEditor || selectedFeature ? 'Hide Add Feature' : 'Add Feature'} Manual Feature
</button> </button>
</div> </div>
</div> </div>
<div className="flow-breadcrumbs" aria-label="BuildPulse flow"> <div className="today-grid">
<span>Raw idea</span> <article className="focus-panel today-focus-card">
<span>AI recommendation</span> <p className="eyebrow">Focus</p>
<span>User decision</span> {focusFeature ? (
<span>DECISION pulse</span> <>
</div> <h3>{focusFeature.title}</h3>
<p>{focusFeature.description || 'No description yet.'}</p>
<div className="focus-badges">
<span className={`pill ${focusFeature.priority}`}>{focusFeature.priority}</span>
<span className="pill">{focusFeature.status}</span>
<span className="pill">{columnLabels[focusFeature.column]}</span>
</div>
<div className="button-inline-row sticky-action-row">
<button type="button" className="primary small" onClick={() => openHandoffPreview(focusFeature.id, 'OpenClaw')}>
Start AI Session
</button>
<button type="button" className="ghost small" onClick={() => openFeatureResultCapture(focusFeature.id)}>
Record Result
</button>
<button type="button" className="ghost small" onClick={() => beginFeatureEdit(focusFeature)}>
Details
</button>
</div>
</>
) : (
<p>No focus feature yet. Add an idea and let triage decide if it deserves to become work.</p>
)}
</article>
<div className="cockpit-counts plan-counts"> <article className="focus-panel today-release-card">
<span>Now <strong>{groupedFeatures.now.length}</strong></span> <p className="eyebrow">Current release</p>
<span>Next <strong>{groupedFeatures.next.length}</strong></span> <h3>{selectedRelease?.name || 'No release selected'}</h3>
<span>Later <strong>{groupedFeatures.later.length}</strong></span> <p>{selectedRelease?.goal || 'Pick a release when this project needs structure.'}</p>
<span>Parking Lot <strong>{appState.parking_lot.length}</strong></span> <div className="compact-stack">
</div> <small>{activeReleaseSummary}</small>
<small>{selectedReleaseBlockers.length ? `${selectedReleaseBlockers.length} required item(s) still open` : 'No required blockers detected'}</small>
<small>{selectedRelease?.forbidden_feature_titles.length ? `Forbidden right now: ${selectedRelease.forbidden_feature_titles.slice(0, 3).join(', ')}` : 'No forbidden-work list yet'}</small>
</div>
<button type="button" className="ghost small" onClick={() => setActiveTab('roadmap')}>Review Release</button>
</article>
<details className="card inline-details compact-project-details"> <article className="focus-panel today-decision-card">
<summary>Project details</summary> <p className="eyebrow">Needs decision</p>
<div className="form-grid project-grid compact-project-form"> <h3>{duplicateParkingGroups.length ? `${duplicateParkingGroups.length} duplicate idea group${duplicateParkingGroups.length === 1 ? '' : 's'}` : 'No obvious duplicate pile-ups'}</h3>
<label> <p>{appState.parking_lot.length} parked idea{appState.parking_lot.length === 1 ? '' : 's'} · {appState.pulses.length} history event{appState.pulses.length === 1 ? '' : 's'}</p>
Project name <div className="button-inline-row">
<input value={appState.project.name} onChange={(event) => updateProject('name', event.target.value)} /> <button type="button" className="ghost small" onClick={() => setActiveTab('parking-lot')}>Review Ideas</button>
</label> <button type="button" className="ghost small" onClick={() => setActiveTab('pulse-log')}>Open History</button>
<label> </div>
One-line pitch </article>
<input value={appState.project.one_line_pitch} onChange={(event) => updateProject('one_line_pitch', event.target.value)} /> </div>
</label>
<label className="full-span">
Description
<textarea rows={3} value={appState.project.description} onChange={(event) => updateProject('description', event.target.value)} />
</label>
<label className="full-span">
Current goal
<input value={appState.project.current_goal} onChange={(event) => updateProject('current_goal', event.target.value)} />
</label>
<label className="full-span">
Notes
<textarea rows={3} value={appState.project.notes} onChange={(event) => updateProject('notes', event.target.value)} />
</label>
</div>
</details>
</section> </section>
<p className="tap-hint">Tap any feature card to open a dedicated sheet with the full details and actions.</p> <p className="tap-hint">Open details only when you need them. The happy path is: add idea start session record result.</p>
<div className="board-grid accordion-board"> <div className="board-grid accordion-board">
{FEATURE_COLUMNS.map((column) => ( {FEATURE_COLUMNS.map((column) => (
<details key={column} className="column card feature-column-details" open={column === 'now'}> <details key={column} className="column card feature-column-details" open={column === 'now'}>
@@ -2427,25 +2506,17 @@ function App() {
{groupedFeatures[column].length ? ( {groupedFeatures[column].length ? (
groupedFeatures[column].map((feature) => { groupedFeatures[column].map((feature) => {
return ( return (
<button key={feature.id} type="button" className="item-card feature-card compact-feature-card" onClick={() => beginFeatureEdit(feature)}> <button key={feature.id} type="button" className="item-card feature-card compact-feature-card simplified-feature-card" onClick={() => beginFeatureEdit(feature)}>
<div className="item-card-header"> <div className="item-card-header">
<strong>{feature.title}</strong> <strong>{feature.title}</strong>
<span className={`pill ${feature.priority}`}>{feature.priority}</span>
</div>
<div className="feature-signal-row">
<span>{columnLabels[feature.column]}</span>
<span className="pill">{feature.status}</span> <span className="pill">{feature.status}</span>
<span>{feature.acceptance_criteria.length} criteria</span>
</div> </div>
{(feature.phase_id || feature.release_id) && ( <p>{feature.description || feature.scope_notes || 'No outcome written yet.'}</p>
<div className="feature-signal-row roadmap-badges"> <div className="feature-signal-row">
{feature.phase_id && <span>{appState.phases.find((phase) => phase.id === feature.phase_id)?.title || 'Phase'}</span>} <span className={`pill ${feature.priority}`}>{feature.priority}</span>
{feature.release_id && <span>{appState.releases.find((release) => release.id === feature.release_id)?.name || 'Release'}</span>} <span>{feature.acceptance_criteria.length} criteria</span>
{feature.release_role && <span className="pill">{feature.release_role}</span>} <span>Open details</span>
</div> </div>
)}
{feature.triaged_at && <small>AI triaged {formatDateTime(feature.triaged_at)}</small>}
<span className="tap-chip">Tap to open</span>
</button> </button>
) )
}) })
+92
View File
@@ -1968,3 +1968,95 @@ select {
.roadmap-badges { .roadmap-badges {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* UX reset: Today-first, Oikos-inspired simplicity pass */
.handoff-backdrop {
z-index: 120;
}
.today-card {
border-color: rgba(125, 211, 252, 0.22);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.88), rgba(15, 23, 42, 0.68)),
radial-gradient(circle at top left, rgba(45, 212, 191, 0.1), transparent 34%);
}
.today-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) repeat(2, minmax(220px, 0.85fr));
gap: 1rem;
align-items: stretch;
}
.today-focus-card,
.today-release-card,
.today-decision-card,
.session-command-panel {
background: rgba(8, 13, 28, 0.66);
}
.today-focus-card h3,
.today-release-card h3,
.today-decision-card h3 {
margin: 0 0 0.45rem;
font-size: clamp(1.15rem, 2vw, 1.55rem);
}
.today-focus-card p,
.today-release-card p,
.today-decision-card p {
color: #c4d1e6;
}
.sticky-action-row {
margin-top: 0.85rem;
}
.simplified-feature-card p {
margin: 0.35rem 0 0.75rem;
color: #c4d1e6;
text-align: left;
}
.simplified-feature-card .feature-signal-row {
justify-content: flex-start;
color: #9fb0c9;
}
@media (max-width: 900px) {
.today-grid {
grid-template-columns: 1fr;
}
.today-card .section-heading {
gap: 1rem;
}
.tab-bar {
position: sticky;
top: 0;
z-index: 40;
padding: 0.45rem;
margin-inline: -0.25rem;
background: rgba(8, 13, 28, 0.86);
backdrop-filter: blur(14px);
border-radius: 20px;
}
.tab {
flex: 1 1 auto;
min-width: 5.2rem;
padding: 0.65rem 0.55rem;
font-size: 0.9rem;
}
.sticky-action-row {
position: sticky;
bottom: 0.75rem;
padding: 0.6rem;
border-radius: 18px;
background: rgba(8, 13, 28, 0.92);
border: 1px solid rgba(148, 163, 184, 0.14);
backdrop-filter: blur(14px);
}
}