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 [triageBatchStatus, setTriageBatchStatus] = useState<'idle' | 'running'>('idle')
const [triageEditMode, setTriageEditMode] = useState(false)
const [triageClarificationAnswer, setTriageClarificationAnswer] = useState('')
const [triageSavedMessage, setTriageSavedMessage] = useState('')
const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false)
const [showManualParkingEditor, setShowManualParkingEditor] = useState(false)
@@ -656,6 +657,7 @@ function App() {
setTriageOpen(true)
setTriageError('')
setTriageEditMode(false)
setTriageClarificationAnswer('')
setTriageSavedMessage('')
setTriageStatus(triageRecommendation && !seed ? 'ready' : 'idle')
if (seed) {
@@ -671,6 +673,7 @@ function App() {
setTriageStatus('idle')
setTriageError('')
setTriageEditMode(false)
setTriageClarificationAnswer('')
}
const openDecisionPulse = (pulseId?: string | null) => {
@@ -741,7 +744,7 @@ function App() {
.map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })),
})
const runAiTriage = async () => {
const runAiTriage = async (optionalContextOverride?: string) => {
const rawIdea = triageDraft.rawIdea.trim()
if (!rawIdea) {
setTriageError('Write the idea first. The scope goblin needs bait.')
@@ -749,6 +752,8 @@ function App() {
return
}
const optionalContext = optionalContextOverride ?? triageDraft.optionalContext.trim()
setTriageStatus('loading')
setTriageError('')
setTriageSavedMessage('')
@@ -757,7 +762,7 @@ function App() {
try {
const recommendation = await triageIdeaWithAi({
raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(),
optional_context: optionalContext,
app_context: buildAiTriageContext(),
})
const timestamp = nowIso()
@@ -765,7 +770,7 @@ function App() {
id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`,
created_at: timestamp,
raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(),
optional_context: optionalContext,
context_summary: `${appState.project.name}: ${appState.project.current_goal}`,
...recommendation,
user_decision: 'pending',
@@ -786,6 +791,7 @@ function App() {
})
setTriageStatus('ready')
setTriageEditMode(false)
setTriageClarificationAnswer('')
setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`)
} catch (error) {
setTriageStatus('error')
@@ -1203,6 +1209,28 @@ function App() {
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) => {
try {
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 copied${shouldLogIntent ? ' and INTENT logged' : ''}.`,
)
return true
} catch {
setStatusMessage('Clipboard copy failed. Browser said no.')
return false
}
}
@@ -1292,9 +1322,15 @@ function App() {
}
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)
setHandoffPreviewDraft(prompt)
setHandoffPreviewDraft(nextPrompt)
}
const resetHandoffPreview = () => {
@@ -1621,16 +1657,20 @@ function App() {
<div className="triage-step-card triage-decision-box">
<p className="eyebrow">Step 3 of 3</p>
<h3>Choose action</h3>
<p>AI advises. You decide.</p>
<h3>{triageNeedsClarification ? 'Clarify before deciding' : 'Choose action'}</h3>
<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">
{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={() => {
setTriageRecommendation(null)
setTriageStatus('idle')
setTriageEditMode(false)
setTriageClarificationAnswer('')
}}>Edit Idea</button>
<button type="button" className="danger small" onClick={rejectTriageRecommendation}>Reject</button>
</>
@@ -1656,7 +1696,32 @@ function App() {
{triageEditMode && (
<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>
Placement
<select value={triageEditable.placement} onChange={(event) => setTriageEditable((current) => ({ ...current, placement: event.target.value as AiPlacement }))}>
@@ -1734,7 +1799,7 @@ function App() {
<button
key={target}
type="button"
className={handoffPreviewTarget === target ? 'small' : 'ghost small'}
className={handoffPreviewTarget === target ? 'primary small' : 'ghost small'}
onClick={() => syncHandoffPreviewTarget(target)}
>
{target}
@@ -1742,6 +1807,7 @@ function App() {
))}
</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">Switching targets resets the draft to that targets preset.</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>
@@ -1761,19 +1827,20 @@ function App() {
<div className="button-row sheet-actions">
<button
type="button"
onClick={() => {
void copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, false, handoffPreviewDraft)
setHandoffPreviewOpen(false)
className="ghost"
onClick={async () => {
const ok = await copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, false, handoffPreviewDraft)
if (ok) setHandoffPreviewOpen(false)
}}
>
Copy Handoff
</button>
<button
type="button"
className="ghost"
onClick={() => {
void copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, true, handoffPreviewDraft)
setHandoffPreviewOpen(false)
className="primary"
onClick={async () => {
const ok = await copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, true, handoffPreviewDraft)
if (ok) setHandoffPreviewOpen(false)
}}
>
Copy + INTENT Pulse
@@ -1848,13 +1915,14 @@ function App() {
Log Feature Pulse
</button>
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Prepare AI Handoff
Open in Export
</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
</button>
</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">
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw', false)}>
Copy for OpenClaw
+23 -6
View File
@@ -325,26 +325,43 @@ textarea {
button,
.import-label {
border: 0;
border-radius: 14px;
border-radius: 10px;
background: linear-gradient(135deg, #60a5fa, #818cf8);
color: white;
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,
.import-label:hover {
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,
.import-label {
background: rgba(30, 41, 59, 0.84);
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(18, 25, 40, 0.88);
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 {
background: linear-gradient(135deg, #f97316, #ef4444);
color: white;
}
button.small {
@@ -946,7 +963,7 @@ body::before {
overscroll-behavior-x: contain;
scrollbar-width: none;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 999px;
border-radius: 18px;
background: rgba(3, 7, 18, 0.78);
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
@@ -958,7 +975,7 @@ body::before {
.tab {
min-width: 8.5rem;
border-radius: 999px;
border-radius: 14px;
border-color: transparent;
background: transparent;
color: #9fb0c9;