Compare commits
9 Commits
0962548217
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 24fde566cd | |||
| 6da54d7115 | |||
| f09da97163 | |||
| 223d9325a1 | |||
| 4cfed90f37 | |||
| f09f132220 | |||
| 579cffd874 | |||
| 1654173540 | |||
| f6e0142226 |
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "buildpulse",
|
"name": "buildpulse",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.1",
|
"version": "0.4.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"api": "node --env-file=../.env server/index.mjs",
|
"api": "node --env-file=../.env server/index.mjs",
|
||||||
|
|||||||
+358
-133
@@ -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
|
||||||
@@ -148,6 +148,7 @@ function App() {
|
|||||||
const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false)
|
const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false)
|
||||||
const [showManualParkingEditor, setShowManualParkingEditor] = useState(false)
|
const [showManualParkingEditor, setShowManualParkingEditor] = useState(false)
|
||||||
const [showManualPulseEditor, setShowManualPulseEditor] = useState(false)
|
const [showManualPulseEditor, setShowManualPulseEditor] = useState(false)
|
||||||
|
const [todayCommandDrawerOpen, setTodayCommandDrawerOpen] = useState(false)
|
||||||
const hasHydratedRemote = useRef(false)
|
const hasHydratedRemote = useRef(false)
|
||||||
const initialLocalStateRef = useRef(appState)
|
const initialLocalStateRef = useRef(appState)
|
||||||
const triageStatusRef = useRef<HTMLDivElement | null>(null)
|
const triageStatusRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -316,6 +317,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) => ({
|
||||||
@@ -338,6 +358,12 @@ function App() {
|
|||||||
[appState.pulses],
|
[appState.pulses],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pulseDraftFeature = useMemo(
|
||||||
|
() => appState.features.find((feature) => feature.id === pulseDraft.featureId) ?? null,
|
||||||
|
[appState.features, pulseDraft.featureId],
|
||||||
|
)
|
||||||
|
const pulseDraftCanUpdateProgress = !selectedPulseId && Boolean(pulseDraftFeature) && (pulseDraft.pulseType === 'RESULT' || pulseDraft.pulseType === 'TEST_RESULT')
|
||||||
|
|
||||||
const triagePlacement = triageEditable.placement || triageRecommendation?.suggested_placement || 'parking_lot'
|
const triagePlacement = triageEditable.placement || triageRecommendation?.suggested_placement || 'parking_lot'
|
||||||
const triageIsFeaturePlacement = triagePlacement === 'now' || triagePlacement === 'next' || triagePlacement === 'later'
|
const triageIsFeaturePlacement = triagePlacement === 'now' || triagePlacement === 'next' || triagePlacement === 'later'
|
||||||
const triageNeedsClarification = triagePlacement === 'needs_clarification'
|
const triageNeedsClarification = triagePlacement === 'needs_clarification'
|
||||||
@@ -364,23 +390,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 +1075,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({
|
||||||
@@ -1162,44 +1229,78 @@ function App() {
|
|||||||
setPulseDraft(initialPulseDraft)
|
setPulseDraft(initialPulseDraft)
|
||||||
}
|
}
|
||||||
|
|
||||||
const savePulse = () => {
|
const buildPulseFromDraft = () => {
|
||||||
if (!pulseDraft.message.trim()) {
|
if (!pulseDraft.message.trim()) {
|
||||||
setStatusMessage('Pulse message is required.')
|
setStatusMessage('Pulse message is required.')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = nowIso()
|
const timestamp = nowIso()
|
||||||
const pulse: PulseEvent = {
|
return {
|
||||||
id: selectedPulseId ?? `pulse_${Date.now().toString(36)}`,
|
pulse: {
|
||||||
timestamp: selectedPulse?.timestamp ?? timestamp,
|
id: selectedPulseId ?? `pulse_${Date.now().toString(36)}`,
|
||||||
project_id: appState.project.id,
|
timestamp: selectedPulse?.timestamp ?? timestamp,
|
||||||
feature_id: pulseDraft.featureId || undefined,
|
project_id: appState.project.id,
|
||||||
source: pulseDraft.source.trim() || 'manual',
|
feature_id: pulseDraft.featureId || undefined,
|
||||||
agent_id: pulseDraft.agentId.trim() || appState.settings.default_agent_id,
|
source: pulseDraft.source.trim() || 'manual',
|
||||||
pulse_type: pulseDraft.pulseType,
|
agent_id: pulseDraft.agentId.trim() || appState.settings.default_agent_id,
|
||||||
message: pulseDraft.message.trim(),
|
pulse_type: pulseDraft.pulseType,
|
||||||
confidence_score: Number(pulseDraft.confidence) || 0,
|
message: pulseDraft.message.trim(),
|
||||||
evidence_refs: linesToArray(pulseDraft.evidenceRefs),
|
confidence_score: Number(pulseDraft.confidence) || 0,
|
||||||
trace_id: pulseDraft.traceId.trim() || undefined,
|
evidence_refs: linesToArray(pulseDraft.evidenceRefs),
|
||||||
}
|
trace_id: pulseDraft.traceId.trim() || undefined,
|
||||||
|
} satisfies PulseEvent,
|
||||||
if (selectedPulseId) {
|
timestamp,
|
||||||
setAppState((current) => ({
|
|
||||||
...current,
|
|
||||||
pulses: current.pulses.map((entry) => (entry.id === selectedPulseId ? pulse : entry)),
|
|
||||||
}))
|
|
||||||
setStatusMessage('Pulse updated.')
|
|
||||||
} else {
|
|
||||||
setAppState((current) => ({
|
|
||||||
...current,
|
|
||||||
pulses: [pulse, ...current.pulses],
|
|
||||||
}))
|
|
||||||
setStatusMessage('Pulse added.')
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistPulse = (
|
||||||
|
pulse: PulseEvent,
|
||||||
|
successMessage: string,
|
||||||
|
featureProgress?: { featureId: string; status: (typeof FEATURE_STATUSES)[number]; column: FeatureColumn; timestamp: string },
|
||||||
|
) => {
|
||||||
|
setAppState((current) => ({
|
||||||
|
...current,
|
||||||
|
features: featureProgress
|
||||||
|
? current.features.map((feature) =>
|
||||||
|
feature.id === featureProgress.featureId
|
||||||
|
? {
|
||||||
|
...feature,
|
||||||
|
status: featureProgress.status,
|
||||||
|
column: featureProgress.column,
|
||||||
|
updated_at: featureProgress.timestamp,
|
||||||
|
}
|
||||||
|
: feature,
|
||||||
|
)
|
||||||
|
: current.features,
|
||||||
|
pulses: selectedPulseId ? current.pulses.map((entry) => (entry.id === selectedPulseId ? pulse : entry)) : [pulse, ...current.pulses],
|
||||||
|
}))
|
||||||
|
setStatusMessage(successMessage)
|
||||||
resetPulseDraft()
|
resetPulseDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savePulse = () => {
|
||||||
|
const draftPulse = buildPulseFromDraft()
|
||||||
|
if (!draftPulse) return
|
||||||
|
persistPulse(draftPulse.pulse, selectedPulseId ? 'Pulse updated.' : 'Pulse added.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePulseWithFeatureProgress = (status: (typeof FEATURE_STATUSES)[number], column: FeatureColumn) => {
|
||||||
|
const draftPulse = buildPulseFromDraft()
|
||||||
|
if (!draftPulse) return
|
||||||
|
if (!draftPulse.pulse.feature_id) {
|
||||||
|
setStatusMessage('Link a feature before using the build-loop progress buttons.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = appState.features.find((entry) => entry.id === draftPulse.pulse.feature_id)
|
||||||
|
persistPulse(
|
||||||
|
draftPulse.pulse,
|
||||||
|
feature ? `Pulse added and “${feature.title}” moved to ${status}.` : `Pulse added and feature moved to ${status}.`,
|
||||||
|
{ featureId: draftPulse.pulse.feature_id, status, column, timestamp: draftPulse.timestamp },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const deletePulse = (pulseId: string) => {
|
const deletePulse = (pulseId: string) => {
|
||||||
setAppState((current) => ({
|
setAppState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1251,7 +1352,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logHandoffIntent = (featureId: string, target: (typeof PROMPT_TARGETS)[number]) => {
|
const startFeatureSession = (featureId: string, target: (typeof PROMPT_TARGETS)[number]) => {
|
||||||
const feature = appState.features.find((entry) => entry.id === featureId)
|
const feature = appState.features.find((entry) => entry.id === featureId)
|
||||||
if (!feature) return null
|
if (!feature) return null
|
||||||
|
|
||||||
@@ -1263,22 +1364,30 @@ function App() {
|
|||||||
feature_id: feature.id,
|
feature_id: feature.id,
|
||||||
source: 'buildpulse',
|
source: 'buildpulse',
|
||||||
agent_id: target,
|
agent_id: target,
|
||||||
pulse_type: 'INTENT',
|
pulse_type: 'SESSION_START',
|
||||||
message: `Generated ${target} handoff for feature “${feature.title}”.`,
|
message: `Started ${target} build session for feature “${feature.title}”.`,
|
||||||
confidence_score: 0.9,
|
confidence_score: 0.9,
|
||||||
evidence_refs: [
|
evidence_refs: [
|
||||||
'Feature-detail handoff action',
|
'Today build-loop handoff action',
|
||||||
feature.release_id ? `Release: ${appState.releases.find((release) => release.id === feature.release_id)?.name || feature.release_id}` : 'No linked release',
|
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',
|
feature.phase_id ? `Phase: ${appState.phases.find((phase) => phase.id === feature.phase_id)?.title || feature.phase_id}` : 'No linked phase',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextFeature = {
|
||||||
|
...feature,
|
||||||
|
column: feature.column === 'done' ? feature.column : ('now' as FeatureColumn),
|
||||||
|
status: feature.status === 'done' ? feature.status : ('building' as (typeof FEATURE_STATUSES)[number]),
|
||||||
|
updated_at: timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
setAppState((current) => ({
|
setAppState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
|
features: current.features.map((entry) => (entry.id === feature.id ? nextFeature : entry)),
|
||||||
pulses: [pulse, ...current.pulses],
|
pulses: [pulse, ...current.pulses],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return feature
|
return nextFeature
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyFocusedHandoff = async (featureId?: string, target = promptTarget, shouldLogIntent = false, promptOverride?: string) => {
|
const copyFocusedHandoff = async (featureId?: string, target = promptTarget, shouldLogIntent = false, promptOverride?: string) => {
|
||||||
@@ -1294,13 +1403,13 @@ function App() {
|
|||||||
|
|
||||||
let feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null
|
let feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null
|
||||||
if (resolvedFeatureId && shouldLogIntent) {
|
if (resolvedFeatureId && shouldLogIntent) {
|
||||||
feature = logHandoffIntent(resolvedFeatureId, target) ?? feature
|
feature = startFeatureSession(resolvedFeatureId, target) ?? feature
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
feature
|
feature
|
||||||
? `${target} handoff for “${feature.title}” copied${shouldLogIntent ? ' and INTENT logged' : ''}.`
|
? `${target} handoff for “${feature.title}” copied${shouldLogIntent ? ', session started, and status moved to building' : ''}.`
|
||||||
: `${target} handoff copied${shouldLogIntent ? ' and INTENT logged' : ''}.`,
|
: `${target} handoff copied${shouldLogIntent ? ' and session started' : ''}.`,
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1471,8 +1580,8 @@ function App() {
|
|||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="mobile-shell-header card">
|
<header className="mobile-shell-header card">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">BuildPulse v0.4.1</p>
|
<p className="eyebrow">BuildPulse v0.4.8</p>
|
||||||
<h1>{appState.project.name}</h1>
|
<h1>BuildPulse</h1>
|
||||||
<p className="hero-goal compact-goal">
|
<p className="hero-goal compact-goal">
|
||||||
<strong>Current goal:</strong> {appState.project.current_goal || 'Classify new ideas before they become work.'}
|
<strong>Current goal:</strong> {appState.project.current_goal || 'Classify new ideas before they become work.'}
|
||||||
</p>
|
</p>
|
||||||
@@ -1480,8 +1589,8 @@ function App() {
|
|||||||
<div className="top-status-row">
|
<div className="top-status-row">
|
||||||
<span className={`status-dot ${backendMode === 'appwrite' ? 'healthy' : backendMode === 'connecting' ? 'connecting' : 'degraded'}`} aria-hidden="true" />
|
<span className={`status-dot ${backendMode === 'appwrite' ? 'healthy' : backendMode === 'connecting' ? 'connecting' : 'degraded'}`} aria-hidden="true" />
|
||||||
<span>{backendMode === 'appwrite' ? 'Live' : backendMode === 'connecting' ? 'Syncing' : 'Local'}</span>
|
<span>{backendMode === 'appwrite' ? 'Live' : backendMode === 'connecting' ? 'Syncing' : 'Local'}</span>
|
||||||
<button type="button" className="ghost small" onClick={() => setActiveTab('status')}>
|
<button type="button" className="ghost small" aria-label="Open system status" onClick={() => setActiveTab('status')}>
|
||||||
System Status
|
Status
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1765,11 +1874,11 @@ 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>
|
||||||
<p className="eyebrow">v0.4.1 Handoff preview</p>
|
<p className="eyebrow">v0.4.4 Build handoff</p>
|
||||||
<h3>{handoffPreviewFeature.title}</h3>
|
<h3>{handoffPreviewFeature.title}</h3>
|
||||||
<p>Choose the target, tweak the brief if needed, then copy it cleanly.</p>
|
<p>Choose the target, tweak the brief if needed, then copy it cleanly.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1806,7 +1915,7 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="tap-hint">Preview mode is the safer path when you want to edit wording or create a clean INTENT pulse.</p>
|
<p className="tap-hint">Preview mode is the safer path when you want to edit wording, copy the brief, and mark the feature as actively building.</p>
|
||||||
<p className="tap-hint">Switching targets resets the draft to that target’s preset.</p>
|
<p className="tap-hint">Switching targets resets the draft to that target’s preset.</p>
|
||||||
<div className="button-inline-row">
|
<div className="button-inline-row">
|
||||||
<button type="button" className="ghost small" onClick={resetHandoffPreview}>Reset to preset</button>
|
<button type="button" className="ghost small" onClick={resetHandoffPreview}>Reset to preset</button>
|
||||||
@@ -1843,7 +1952,7 @@ function App() {
|
|||||||
if (ok) setHandoffPreviewOpen(false)
|
if (ok) setHandoffPreviewOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy + INTENT Pulse
|
Copy + Start Session
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="ghost" onClick={() => setHandoffPreviewOpen(false)}>
|
<button type="button" className="ghost" onClick={() => setHandoffPreviewOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -1858,7 +1967,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 +1985,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 +2031,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)}>
|
||||||
@@ -2178,8 +2294,19 @@ function App() {
|
|||||||
<textarea rows={4} value={pulseDraft.evidenceRefs} onChange={(event) => setPulseDraft((current) => ({ ...current, evidenceRefs: event.target.value }))} />
|
<textarea rows={4} value={pulseDraft.evidenceRefs} onChange={(event) => setPulseDraft((current) => ({ ...current, evidenceRefs: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{pulseDraftCanUpdateProgress && (
|
||||||
|
<div className="result-progress-strip" aria-label="Build-loop progress shortcuts">
|
||||||
|
<span>Finish the loop for {pulseDraftFeature?.title}</span>
|
||||||
|
<button type="button" className="ghost small" onClick={() => savePulseWithFeatureProgress('testing', 'now')}>
|
||||||
|
Save + Move to Testing
|
||||||
|
</button>
|
||||||
|
<button type="button" className="primary small" onClick={() => savePulseWithFeatureProgress('done', 'done')}>
|
||||||
|
Save + Mark Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="button-row sheet-actions">
|
<div className="button-row sheet-actions">
|
||||||
<button type="button" onClick={savePulse}>{selectedPulse ? 'Save Changes' : 'Add Pulse'}</button>
|
<button type="button" onClick={savePulse}>{selectedPulse ? 'Save Changes' : 'Add Pulse Only'}</button>
|
||||||
<button type="button" className="ghost" onClick={resetPulseDraft}>Clear</button>
|
<button type="button" className="ghost" onClick={resetPulseDraft}>Clear</button>
|
||||||
{selectedPulse && (
|
{selectedPulse && (
|
||||||
<button type="button" className="danger" onClick={() => deletePulse(selectedPulse.id)}>
|
<button type="button" className="danger" onClick={() => deletePulse(selectedPulse.id)}>
|
||||||
@@ -2355,65 +2482,171 @@ 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>Capture → Brief → Build</h2>
|
||||||
<p>{appState.project.current_goal || 'Classify new ideas before they become work.'}</p>
|
<p>One decision at a time. Capture the idea, pick the next move, hand it to an agent.</p>
|
||||||
</div>
|
|
||||||
<div className="button-inline-row">
|
|
||||||
<button type="button" className="primary-triage-button" onClick={() => openTriage()}>
|
|
||||||
Triage Idea
|
|
||||||
</button>
|
|
||||||
<button type="button" className="ghost" onClick={() => setShowManualFeatureEditor((current) => !current)}>
|
|
||||||
{showManualFeatureEditor || selectedFeature ? 'Hide Add Feature' : 'Add Feature'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flow-breadcrumbs" aria-label="BuildPulse flow">
|
<div className="today-command-bar" aria-label="Today primary workflow">
|
||||||
<span>Raw idea</span>
|
<button type="button" className="primary-triage-button today-primary-command" onClick={() => openTriage()}>
|
||||||
<span>AI recommendation</span>
|
Capture new idea
|
||||||
<span>User decision</span>
|
</button>
|
||||||
<span>DECISION pulse</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost today-drawer-toggle"
|
||||||
|
aria-expanded={todayCommandDrawerOpen}
|
||||||
|
aria-controls="today-command-drawer"
|
||||||
|
onClick={() => setTodayCommandDrawerOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
{todayCommandDrawerOpen ? 'Hide' : 'More'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cockpit-counts plan-counts">
|
{todayCommandDrawerOpen && (
|
||||||
<span>Now <strong>{groupedFeatures.now.length}</strong></span>
|
<div id="today-command-drawer" className="today-command-drawer" aria-label="Secondary Today commands">
|
||||||
<span>Next <strong>{groupedFeatures.next.length}</strong></span>
|
<div>
|
||||||
<span>Later <strong>{groupedFeatures.later.length}</strong></span>
|
<p className="eyebrow">Build</p>
|
||||||
<span>Parking Lot <strong>{appState.parking_lot.length}</strong></span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
className="drawer-command primary"
|
||||||
<details className="card inline-details compact-project-details">
|
disabled={!focusFeature}
|
||||||
<summary>Project details</summary>
|
onClick={() => {
|
||||||
<div className="form-grid project-grid compact-project-form">
|
if (!focusFeature) return
|
||||||
<label>
|
setTodayCommandDrawerOpen(false)
|
||||||
Project name
|
openHandoffPreview(focusFeature.id, 'OpenClaw')
|
||||||
<input value={appState.project.name} onChange={(event) => updateProject('name', event.target.value)} />
|
}}
|
||||||
</label>
|
>
|
||||||
<label>
|
Start AI session
|
||||||
One-line pitch
|
</button>
|
||||||
<input value={appState.project.one_line_pitch} onChange={(event) => updateProject('one_line_pitch', event.target.value)} />
|
<button
|
||||||
</label>
|
type="button"
|
||||||
<label className="full-span">
|
className="drawer-command"
|
||||||
Description
|
disabled={!focusFeature}
|
||||||
<textarea rows={3} value={appState.project.description} onChange={(event) => updateProject('description', event.target.value)} />
|
onClick={() => {
|
||||||
</label>
|
if (!focusFeature) return
|
||||||
<label className="full-span">
|
setTodayCommandDrawerOpen(false)
|
||||||
Current goal
|
openFeatureResultCapture(focusFeature.id)
|
||||||
<input value={appState.project.current_goal} onChange={(event) => updateProject('current_goal', event.target.value)} />
|
}}
|
||||||
</label>
|
>
|
||||||
<label className="full-span">
|
Record result
|
||||||
Notes
|
</button>
|
||||||
<textarea rows={3} value={appState.project.notes} onChange={(event) => updateProject('notes', event.target.value)} />
|
<button
|
||||||
</label>
|
type="button"
|
||||||
|
className="drawer-command"
|
||||||
|
disabled={!focusFeature}
|
||||||
|
onClick={() => {
|
||||||
|
if (!focusFeature) return
|
||||||
|
setTodayCommandDrawerOpen(false)
|
||||||
|
beginFeatureEdit(focusFeature)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Focus details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Sort</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-command"
|
||||||
|
onClick={() => {
|
||||||
|
setTodayCommandDrawerOpen(false)
|
||||||
|
setShowManualFeatureEditor((current) => !current)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manual feature
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-command"
|
||||||
|
onClick={() => {
|
||||||
|
setTodayCommandDrawerOpen(false)
|
||||||
|
setActiveTab('parking-lot')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Parked ideas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-command"
|
||||||
|
onClick={() => {
|
||||||
|
setTodayCommandDrawerOpen(false)
|
||||||
|
setActiveTab('pulse-log')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pulse history
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Ship</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-command"
|
||||||
|
onClick={() => {
|
||||||
|
setTodayCommandDrawerOpen(false)
|
||||||
|
setActiveTab('roadmap')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Release view
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-command"
|
||||||
|
onClick={() => {
|
||||||
|
setTodayCommandDrawerOpen(false)
|
||||||
|
setActiveTab('export')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Session handoff
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
)}
|
||||||
|
|
||||||
|
<div className="today-grid">
|
||||||
|
<article className="focus-panel today-focus-card">
|
||||||
|
<p className="eyebrow">Focus</p>
|
||||||
|
{focusFeature ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<p className="today-card-note">Use Commands for build, result, and detail actions.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>No focus feature yet. Add an idea and let triage decide if it deserves to become work.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="focus-panel today-release-card">
|
||||||
|
<p className="eyebrow">Current release</p>
|
||||||
|
<h3>{selectedRelease?.name || 'No release selected'}</h3>
|
||||||
|
<p>{selectedRelease?.goal || 'Pick the release that matters now.'}</p>
|
||||||
|
<div className="compact-stack">
|
||||||
|
<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>
|
||||||
|
<p className="today-card-note">Release controls live in Commands.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="focus-panel today-decision-card">
|
||||||
|
<p className="eyebrow">Needs decision</p>
|
||||||
|
<h3>{duplicateParkingGroups.length ? `${duplicateParkingGroups.length} duplicate idea group${duplicateParkingGroups.length === 1 ? '' : 's'}` : 'No obvious duplicate pile-ups'}</h3>
|
||||||
|
<p>{appState.parking_lot.length} parked · {appState.pulses.length} pulses</p>
|
||||||
|
<p className="today-card-note">Decision controls live in Commands.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</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 +2660,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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+1337
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user