fix(pilot): Phase 9 review — partial-outcome notes + per-fix script-builder remount
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Addresses docs/FlowAssist_Migration/Issues/phase-9-review-issues.md. Issue #1 (High): "Applied partially" from the escalation intercept silently dropped because the backend requires notes on applied_partial and the dialog sent none. The catch was silent and the UI advanced into the conclude flow as if the outcome were recorded. - EscalateInterceptDialog now has a two-step flow: clicking the partial choice reveals a notes textarea (autofocused, required non-empty) plus Back / "Record partial & escalate" buttons. - onChoose signature extended to (choice, notes?). - handleInterceptChoice passes notes to patchOutcome; on failure it surfaces a toast and does NOT advance to the conclude modal, so the intercept stays open for retry. Issue #2 (Medium/High): ScriptBuilderTab kept local state across active-fix changes within the same pilot session, so a stale draft could PATCH against a newer fix.id. Added key={activeFix.id} on the mount — forces a clean remount per fix; backend get-or-create (keyed on user+ai_session_id) still returns the same session row, which is the intended resume-on-refresh semantic; but messages/editorBuffer/latestScript local state resets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,14 +8,16 @@
|
|||||||
* Visual reference: docs/FlowAssist_Migration/mockups/07-verify-states.html
|
* Visual reference: docs/FlowAssist_Migration/mockups/07-verify-states.html
|
||||||
* (panel C).
|
* (panel C).
|
||||||
*/
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
import { X, AlertCircle, Check, Info } from 'lucide-react'
|
import { X, AlertCircle, Check, Info } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { FixOutcome } from '@/api/sessionSuggestedFixes'
|
import type { FixOutcome } from '@/api/sessionSuggestedFixes'
|
||||||
|
|
||||||
export type InterceptChoice = FixOutcome | 'never_applied'
|
export type InterceptChoice = FixOutcome | 'never_applied'
|
||||||
|
|
||||||
export interface EscalateInterceptDialogProps {
|
export interface EscalateInterceptDialogProps {
|
||||||
fixTitle: string
|
fixTitle: string
|
||||||
onChoose: (choice: InterceptChoice) => void
|
onChoose: (choice: InterceptChoice, notes?: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +26,11 @@ export function EscalateInterceptDialog({
|
|||||||
onChoose,
|
onChoose,
|
||||||
onClose,
|
onClose,
|
||||||
}: EscalateInterceptDialogProps) {
|
}: EscalateInterceptDialogProps) {
|
||||||
|
const [partialStep, setPartialStep] = useState(false)
|
||||||
|
const [partialNotes, setPartialNotes] = useState('')
|
||||||
|
|
||||||
|
const notesValid = partialNotes.trim().length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -36,6 +43,8 @@ export function EscalateInterceptDialog({
|
|||||||
aria-label="Capture fix outcome before escalating"
|
aria-label="Capture fix outcome before escalating"
|
||||||
className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]"
|
className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]"
|
||||||
>
|
>
|
||||||
|
{!partialStep ? (
|
||||||
|
<>
|
||||||
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
|
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
|
||||||
Before escalating — what happened with the fix?
|
Before escalating — what happened with the fix?
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +63,7 @@ export function EscalateInterceptDialog({
|
|||||||
<span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]">↵</span>
|
<span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]">↵</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onChoose('applied_partial')}
|
onClick={() => setPartialStep(true)}
|
||||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
|
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
|
||||||
>
|
>
|
||||||
<Info size={13} strokeWidth={2} />
|
<Info size={13} strokeWidth={2} />
|
||||||
@@ -75,6 +84,45 @@ export function EscalateInterceptDialog({
|
|||||||
<span className="flex-1">Never actually applied it</span>
|
<span className="flex-1">Never actually applied it</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
|
||||||
|
What partially worked?
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
|
||||||
|
A short note for whoever picks this up — what you tried, what worked, what's still broken.
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={partialNotes}
|
||||||
|
onChange={(e) => setPartialNotes(e.target.value)}
|
||||||
|
placeholder="e.g. Cleared cached creds; rebuild profile still hung on sync."
|
||||||
|
className="w-full h-24 resize-none rounded-md border border-white/10 bg-elevated px-2.5 py-2 text-[12.5px] text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPartialStep(false)
|
||||||
|
setPartialNotes('')
|
||||||
|
}}
|
||||||
|
className="text-[12px] text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChoose('applied_partial', partialNotes.trim())}
|
||||||
|
disabled={!notesValid}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded text-[12.5px] font-semibold transition-colors',
|
||||||
|
'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Record partial & escalate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -656,19 +656,33 @@ export default function AssistantChatPage() {
|
|||||||
setShowConclude(true)
|
setShowConclude(true)
|
||||||
}, [activeFix])
|
}, [activeFix])
|
||||||
|
|
||||||
const handleInterceptChoice = useCallback(async (choice: InterceptChoice) => {
|
const handleInterceptChoice = useCallback(async (choice: InterceptChoice, notes?: string) => {
|
||||||
const stored = escalateIntercept
|
const stored = escalateIntercept
|
||||||
|
if (!stored || !activeChatId) {
|
||||||
setEscalateIntercept(null)
|
setEscalateIntercept(null)
|
||||||
if (!stored || !activeChatId) return
|
return
|
||||||
|
}
|
||||||
const outcomeToSend: FixOutcome =
|
const outcomeToSend: FixOutcome =
|
||||||
choice === 'never_applied' ? 'dismissed' : choice
|
choice === 'never_applied' ? 'dismissed' : choice
|
||||||
try {
|
try {
|
||||||
const updated = await sessionSuggestedFixesApi.patchOutcome(
|
const updated = await sessionSuggestedFixesApi.patchOutcome(
|
||||||
activeChatId, stored.fixId, outcomeToSend,
|
activeChatId, stored.fixId, outcomeToSend, notes,
|
||||||
)
|
)
|
||||||
setActiveFix(updated)
|
setActiveFix(updated)
|
||||||
} catch { /* non-fatal — engineer can still escalate */ }
|
setEscalateIntercept(null)
|
||||||
setShowConclude(true)
|
setShowConclude(true)
|
||||||
|
} catch (err) {
|
||||||
|
// applied_partial without notes (or any other 4xx) must surface — the
|
||||||
|
// previous silent catch let engineers believe the partial outcome was
|
||||||
|
// recorded while it was rejected server-side.
|
||||||
|
const message = choice === 'applied_partial'
|
||||||
|
? 'Couldn’t record the partial outcome. Add a short note and try again.'
|
||||||
|
: 'Couldn’t record the outcome before escalating. Try again.'
|
||||||
|
toast.error(message)
|
||||||
|
// Keep the intercept open so the engineer can retry (partial path can
|
||||||
|
// re-enter the notes step from the dialog).
|
||||||
|
if (import.meta.env.DEV) console.warn('[AssistantChat] intercept outcome failed:', err)
|
||||||
|
}
|
||||||
}, [activeChatId, escalateIntercept])
|
}, [activeChatId, escalateIntercept])
|
||||||
|
|
||||||
// Phase 8: Resolve click — auto-mark applied_success if in verifying state
|
// Phase 8: Resolve click — auto-mark applied_success if in verifying state
|
||||||
@@ -1629,7 +1643,12 @@ export default function AssistantChatPage() {
|
|||||||
so both scroll positions and state are preserved across tab switches. */}
|
so both scroll positions and state are preserved across tab switches. */}
|
||||||
{showTabStrip && activeFix && activeChatId && (
|
{showTabStrip && activeFix && activeChatId && (
|
||||||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}>
|
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}>
|
||||||
|
{/* key={activeFix.id} forces a fresh mount when the active
|
||||||
|
fix changes within the same pilot session — otherwise
|
||||||
|
stale messages / editorBuffer / latestScript from the
|
||||||
|
prior fix could submit against the new fix.id. */}
|
||||||
<ScriptBuilderTab
|
<ScriptBuilderTab
|
||||||
|
key={activeFix.id}
|
||||||
fix={activeFix}
|
fix={activeFix}
|
||||||
pilotSessionId={activeChatId}
|
pilotSessionId={activeChatId}
|
||||||
onProgressChange={setScriptBuilderHasProgress}
|
onProgressChange={setScriptBuilderHasProgress}
|
||||||
|
|||||||
Reference in New Issue
Block a user