fix: unblock clarification retries and refresh handoff UX

This commit is contained in:
OpenClaw Bot
2026-05-11 23:07:56 +02:00
parent ec85b8e4d7
commit 0962548217
2 changed files with 111 additions and 26 deletions
+88 -20
View File
@@ -143,6 +143,7 @@ function App() {
const [triageError, setTriageError] = useState('') const [triageError, setTriageError] = useState('')
const [triageBatchStatus, setTriageBatchStatus] = useState<'idle' | 'running'>('idle') const [triageBatchStatus, setTriageBatchStatus] = useState<'idle' | 'running'>('idle')
const [triageEditMode, setTriageEditMode] = useState(false) const [triageEditMode, setTriageEditMode] = useState(false)
const [triageClarificationAnswer, setTriageClarificationAnswer] = useState('')
const [triageSavedMessage, setTriageSavedMessage] = useState('') const [triageSavedMessage, setTriageSavedMessage] = useState('')
const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false) const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false)
const [showManualParkingEditor, setShowManualParkingEditor] = useState(false) const [showManualParkingEditor, setShowManualParkingEditor] = useState(false)
@@ -656,6 +657,7 @@ function App() {
setTriageOpen(true) setTriageOpen(true)
setTriageError('') setTriageError('')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
setTriageSavedMessage('') setTriageSavedMessage('')
setTriageStatus(triageRecommendation && !seed ? 'ready' : 'idle') setTriageStatus(triageRecommendation && !seed ? 'ready' : 'idle')
if (seed) { if (seed) {
@@ -671,6 +673,7 @@ function App() {
setTriageStatus('idle') setTriageStatus('idle')
setTriageError('') setTriageError('')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
} }
const openDecisionPulse = (pulseId?: string | null) => { const openDecisionPulse = (pulseId?: string | null) => {
@@ -741,7 +744,7 @@ function App() {
.map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })), .map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })),
}) })
const runAiTriage = async () => { const runAiTriage = async (optionalContextOverride?: string) => {
const rawIdea = triageDraft.rawIdea.trim() const rawIdea = triageDraft.rawIdea.trim()
if (!rawIdea) { if (!rawIdea) {
setTriageError('Write the idea first. The scope goblin needs bait.') setTriageError('Write the idea first. The scope goblin needs bait.')
@@ -749,6 +752,8 @@ function App() {
return return
} }
const optionalContext = optionalContextOverride ?? triageDraft.optionalContext.trim()
setTriageStatus('loading') setTriageStatus('loading')
setTriageError('') setTriageError('')
setTriageSavedMessage('') setTriageSavedMessage('')
@@ -757,7 +762,7 @@ function App() {
try { try {
const recommendation = await triageIdeaWithAi({ const recommendation = await triageIdeaWithAi({
raw_idea: rawIdea, raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(), optional_context: optionalContext,
app_context: buildAiTriageContext(), app_context: buildAiTriageContext(),
}) })
const timestamp = nowIso() const timestamp = nowIso()
@@ -765,7 +770,7 @@ function App() {
id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`, id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`,
created_at: timestamp, created_at: timestamp,
raw_idea: rawIdea, raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(), optional_context: optionalContext,
context_summary: `${appState.project.name}: ${appState.project.current_goal}`, context_summary: `${appState.project.name}: ${appState.project.current_goal}`,
...recommendation, ...recommendation,
user_decision: 'pending', user_decision: 'pending',
@@ -786,6 +791,7 @@ function App() {
}) })
setTriageStatus('ready') setTriageStatus('ready')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`) setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`)
} catch (error) { } catch (error) {
setTriageStatus('error') setTriageStatus('error')
@@ -1203,6 +1209,28 @@ function App() {
setStatusMessage('Pulse deleted.') setStatusMessage('Pulse deleted.')
} }
const answerClarifyingQuestion = async () => {
const answer = triageClarificationAnswer.trim()
if (!answer) {
setTriageError('Give the AI a real answer first.')
setTriageStatus('error')
return
}
const mergedContext = [triageDraft.optionalContext.trim(), `Clarification answer: ${answer}`]
.filter(Boolean)
.join('\n\n')
setTriageDraft((current) => ({
...current,
optionalContext: mergedContext,
}))
setTriageRecommendation(null)
setTriageEditMode(false)
setStatusMessage('Re-running triage with your clarification…')
await runAiTriage(mergedContext)
}
const copyMarkdown = async (filename: string) => { const copyMarkdown = async (filename: string) => {
try { try {
await navigator.clipboard.writeText(markdownPackage[filename as keyof typeof markdownPackage]) await navigator.clipboard.writeText(markdownPackage[filename as keyof typeof markdownPackage])
@@ -1274,8 +1302,10 @@ function App() {
? `${target} handoff for “${feature.title}” copied${shouldLogIntent ? ' and INTENT logged' : ''}.` ? `${target} handoff for “${feature.title}” copied${shouldLogIntent ? ' and INTENT logged' : ''}.`
: `${target} handoff copied${shouldLogIntent ? ' and INTENT logged' : ''}.`, : `${target} handoff copied${shouldLogIntent ? ' and INTENT logged' : ''}.`,
) )
return true
} catch { } catch {
setStatusMessage('Clipboard copy failed. Browser said no.') setStatusMessage('Clipboard copy failed. Browser said no.')
return false
} }
} }
@@ -1292,9 +1322,15 @@ function App() {
} }
const syncHandoffPreviewTarget = (target: (typeof PROMPT_TARGETS)[number]) => { const syncHandoffPreviewTarget = (target: (typeof PROMPT_TARGETS)[number]) => {
const { prompt } = buildHandoffPrompt(handoffPreviewFeatureId || undefined, target) const { prompt } = buildHandoffPrompt(handoffPreviewFeatureId || undefined, handoffPreviewTarget)
if (handoffPreviewDraft.trim() !== prompt.trim()) {
const shouldReplace = window.confirm('Switching target will replace your current edits with the new preset. Continue?')
if (!shouldReplace) return
}
const { prompt: nextPrompt } = buildHandoffPrompt(handoffPreviewFeatureId || undefined, target)
setHandoffPreviewTarget(target) setHandoffPreviewTarget(target)
setHandoffPreviewDraft(prompt) setHandoffPreviewDraft(nextPrompt)
} }
const resetHandoffPreview = () => { const resetHandoffPreview = () => {
@@ -1621,16 +1657,20 @@ function App() {
<div className="triage-step-card triage-decision-box"> <div className="triage-step-card triage-decision-box">
<p className="eyebrow">Step 3 of 3</p> <p className="eyebrow">Step 3 of 3</p>
<h3>Choose action</h3> <h3>{triageNeedsClarification ? 'Clarify before deciding' : 'Choose action'}</h3>
<p>AI advises. You decide.</p> <p>{triageNeedsClarification ? 'Answer the question, then re-run triage with the missing detail.' : 'AI advises. You decide.'}</p>
<div className="button-row triage-action-stack"> <div className="button-row triage-action-stack">
{triageNeedsClarification ? ( {triageNeedsClarification ? (
<> <>
<button type="button" onClick={() => setTriageEditMode(true)}>Answer Question</button> <button type="button" className="primary" onClick={() => {
setTriageEditMode(true)
setStatusMessage('Answer the clarification question below, then re-run triage.')
}}>Answer + Retry</button>
<button type="button" className="ghost" onClick={() => { <button type="button" className="ghost" onClick={() => {
setTriageRecommendation(null) setTriageRecommendation(null)
setTriageStatus('idle') setTriageStatus('idle')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
}}>Edit Idea</button> }}>Edit Idea</button>
<button type="button" className="danger small" onClick={rejectTriageRecommendation}>Reject</button> <button type="button" className="danger small" onClick={rejectTriageRecommendation}>Reject</button>
</> </>
@@ -1656,7 +1696,32 @@ function App() {
{triageEditMode && ( {triageEditMode && (
<div className="triage-step-card triage-edit-fields form-grid"> <div className="triage-step-card triage-edit-fields form-grid">
<p className="eyebrow">Edit before saving</p> <p className="eyebrow">{triageNeedsClarification ? 'Answer and refine' : 'Edit before saving'}</p>
{triageNeedsClarification && (
<>
<div className="triage-callout warning full-span">
<strong>Answer the question</strong>
<p>{triageRecommendation?.clarifying_question || 'Clarify the idea, then re-run triage.'}</p>
</div>
<label className="full-span">
Your answer
<textarea
rows={4}
value={triageClarificationAnswer}
onChange={(event) => setTriageClarificationAnswer(event.target.value)}
placeholder="Add the missing detail so the AI can classify the idea properly."
/>
</label>
<div className="button-inline-row full-span">
<button type="button" onClick={() => void answerClarifyingQuestion()} disabled={triageStatus === 'loading'}>
{triageStatus === 'loading' ? 'Re-running…' : 'Re-run with Answer'}
</button>
<button type="button" className="ghost small" onClick={() => setTriageEditMode(false)}>
Hide Clarification
</button>
</div>
</>
)}
<label> <label>
Placement Placement
<select value={triageEditable.placement} onChange={(event) => setTriageEditable((current) => ({ ...current, placement: event.target.value as AiPlacement }))}> <select value={triageEditable.placement} onChange={(event) => setTriageEditable((current) => ({ ...current, placement: event.target.value as AiPlacement }))}>
@@ -1734,7 +1799,7 @@ function App() {
<button <button
key={target} key={target}
type="button" type="button"
className={handoffPreviewTarget === target ? 'small' : 'ghost small'} className={handoffPreviewTarget === target ? 'primary small' : 'ghost small'}
onClick={() => syncHandoffPreviewTarget(target)} onClick={() => syncHandoffPreviewTarget(target)}
> >
{target} {target}
@@ -1742,6 +1807,7 @@ function App() {
))} ))}
</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 or create a clean INTENT pulse.</p>
<p className="tap-hint">Switching targets resets the draft to that targets 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>
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(handoffPreviewFeature.id)}>Open in Export</button> <button type="button" className="ghost small" onClick={() => openFeatureHandoff(handoffPreviewFeature.id)}>Open in Export</button>
@@ -1761,19 +1827,20 @@ function App() {
<div className="button-row sheet-actions"> <div className="button-row sheet-actions">
<button <button
type="button" type="button"
onClick={() => { className="ghost"
void copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, false, handoffPreviewDraft) onClick={async () => {
setHandoffPreviewOpen(false) const ok = await copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, false, handoffPreviewDraft)
if (ok) setHandoffPreviewOpen(false)
}} }}
> >
Copy Handoff Copy Handoff
</button> </button>
<button <button
type="button" type="button"
className="ghost" className="primary"
onClick={() => { onClick={async () => {
void copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, true, handoffPreviewDraft) const ok = await copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, true, handoffPreviewDraft)
setHandoffPreviewOpen(false) if (ok) setHandoffPreviewOpen(false)
}} }}
> >
Copy + INTENT Pulse Copy + INTENT Pulse
@@ -1848,13 +1915,14 @@ function App() {
Log Feature Pulse Log Feature Pulse
</button> </button>
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}> <button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Prepare AI Handoff Open in Export
</button> </button>
<button type="button" className="ghost small" onClick={() => openHandoffPreview(selectedFeature.id, 'OpenClaw')}> <button type="button" className="primary small" onClick={() => openHandoffPreview(selectedFeature.id, 'OpenClaw')}>
Preview / Edit Handoff Preview / Edit Handoff
</button> </button>
</div> </div>
<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> <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 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)}>
Copy for OpenClaw Copy for OpenClaw
+23 -6
View File
@@ -325,26 +325,43 @@ textarea {
button, button,
.import-label { .import-label {
border: 0; border: 0;
border-radius: 14px; border-radius: 10px;
background: linear-gradient(135deg, #60a5fa, #818cf8); background: linear-gradient(135deg, #60a5fa, #818cf8);
color: white; color: white;
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
transition: transform 120ms ease, opacity 120ms ease; font-weight: 600;
letter-spacing: 0.01em;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.2);
transition: transform 120ms ease, opacity 120ms ease, box-shadow 120ms ease, background 120ms ease;
} }
button:hover, button:hover,
.import-label:hover { .import-label:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
}
button.primary {
background: linear-gradient(135deg, #67e8f9, #818cf8);
color: #06111c;
} }
button.ghost, button.ghost,
.import-label { .import-label {
background: rgba(30, 41, 59, 0.84); background: rgba(18, 25, 40, 0.88);
border: 1px solid rgba(148, 163, 184, 0.16); border: 1px solid rgba(148, 163, 184, 0.18);
color: #dbe7ff;
box-shadow: none;
}
button.ghost:hover,
.import-label:hover {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
} }
button.danger { button.danger {
background: linear-gradient(135deg, #f97316, #ef4444); background: linear-gradient(135deg, #f97316, #ef4444);
color: white;
} }
button.small { button.small {
@@ -946,7 +963,7 @@ body::before {
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
scrollbar-width: none; scrollbar-width: none;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 999px; border-radius: 18px;
background: rgba(3, 7, 18, 0.78); background: rgba(3, 7, 18, 0.78);
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28); box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@@ -958,7 +975,7 @@ body::before {
.tab { .tab {
min-width: 8.5rem; min-width: 8.5rem;
border-radius: 999px; border-radius: 14px;
border-color: transparent; border-color: transparent;
background: transparent; background: transparent;
color: #9fb0c9; color: #9fb0c9;