feat(suggested-fix): add applied_pending status for deferred verification
Engineer applies a fix but can't verify yet (waiting on client power-cycle,
AD replication, async sync). Today the verifying banner forces a synchronous
verdict (worked / didn't / partial) — anything else means leaving the banner
stale or guessing wrong. This adds a fourth outcome that parks the fix in a
non-terminal "Awaiting verification" state with a reason ("waiting on what?")
and exposes it on the chat-anchored banner so the engineer doesn't lose track.
Backend
- New non-terminal status `applied_pending` parallel to `applied_partial`.
- New `pending_reason` column (nullable Text) — the "what are you waiting on?"
prose, mirrors `partial_notes`. Required when outcome=applied_pending.
- Outcome endpoint allows pending in/out transitions; pending stamps
applied_at but NOT verified_at (it's parked, not verified).
- Resolution-note + escalation-package prompts handle the new status:
resolution note frames the fix as provisional; escalation package surfaces
pending verification as the leading hypothesis with reference to what's
being waited on.
- Migration: add column + extend status CHECK constraint.
Frontend
- New `BannerMode = 'pending'` + `PendingBanner` component (info-tone,
parallel to PartialBanner) with worked / didn't / update-reason actions.
- VerifyingBanner overflow menu adds "Waiting to verify…".
- Nudge banner's "Still checking" button now actually records pending with
a reason, instead of just silencing for the session.
- AssistantChatPage banner-mode derivation maps applied_pending → 'pending'.
Tests: 4 new integration tests covering pending notes requirement, reason
storage + applied_at/verified_at semantics, pending→success transition,
and pending_reason update on re-PATCH.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,14 @@ export type FixStatus =
|
||||
| 'applied_success'
|
||||
| 'applied_failed'
|
||||
| 'applied_partial'
|
||||
| 'applied_pending'
|
||||
| 'dismissed'
|
||||
|
||||
export type FixOutcome =
|
||||
| 'applied_success'
|
||||
| 'applied_failed'
|
||||
| 'applied_partial'
|
||||
| 'applied_pending'
|
||||
| 'dismissed'
|
||||
|
||||
export interface AIOutcomeProposal {
|
||||
@@ -41,6 +43,7 @@ export interface SessionSuggestedFix {
|
||||
applied_at: string | null
|
||||
verified_at: string | null
|
||||
partial_notes: string | null
|
||||
pending_reason: string | null
|
||||
failure_reason: string | null
|
||||
ai_outcome_proposal: AIOutcomeProposal | null
|
||||
superseded_at: string | null
|
||||
@@ -126,11 +129,12 @@ export const sessionSuggestedFixesApi = {
|
||||
|
||||
/**
|
||||
* Record the outcome of applying a suggested fix. Transition rules:
|
||||
* - from `proposed` or `applied_partial`: any outcome is valid (partial is
|
||||
* parked, not terminal — engineer may update notes, abandon via dismiss,
|
||||
* or advance to success/failed).
|
||||
* - from `proposed`, `applied_partial`, or `applied_pending`: any outcome
|
||||
* is valid. Partial = "did some of it"; pending = "did all of it but
|
||||
* verification is deferred". Both are parked, not terminal.
|
||||
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
|
||||
* server returns 409.
|
||||
* - `applied_pending` requires `notes` (the "what are you waiting on?" reason).
|
||||
*/
|
||||
async patchOutcome(
|
||||
sessionId: string,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* + 07-verify-states.html.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info } from 'lucide-react'
|
||||
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info, Clock3 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
SessionSuggestedFix,
|
||||
@@ -21,6 +21,7 @@ export type BannerMode =
|
||||
| 'proposed' // AI just proposed; engineer hasn't applied yet
|
||||
| 'verifying' // Engineer clicked Apply; awaiting outcome
|
||||
| 'partial' // Applied partially; awaiting finish or terminal outcome
|
||||
| 'pending' // Applied fully; verification deferred (waiting on client, etc)
|
||||
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
|
||||
| 'nudge' // Compact nudge shown after N post-apply messages
|
||||
|
||||
@@ -45,6 +46,7 @@ export function ProposalBanner(props: ProposalBannerProps) {
|
||||
case 'proposed': return <ProposedBanner {...props} />
|
||||
case 'verifying': return <VerifyingBanner {...props} />
|
||||
case 'partial': return <PartialBanner {...props} />
|
||||
case 'pending': return <PendingBanner {...props} />
|
||||
case 'ai_confirming': return <AIConfirmingBanner {...props} />
|
||||
case 'nudge': return <NudgeBanner {...props} />
|
||||
}
|
||||
@@ -148,7 +150,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<div className={cn(
|
||||
'absolute top-full right-0 mt-1 w-48 rounded-lg',
|
||||
'absolute top-full right-0 mt-1 w-56 rounded-lg',
|
||||
'border border-white/10 bg-card shadow-xl py-1 z-10',
|
||||
)}>
|
||||
<button
|
||||
@@ -161,6 +163,17 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
>
|
||||
Mark partial…
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowOverflow(false)
|
||||
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
|
||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Clock3 size={12} className="text-info" />
|
||||
Waiting to verify…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -247,6 +260,66 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
|
||||
<Clock3 size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<span>Awaiting verification</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
|
||||
Parked
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
{fix.pending_reason && (
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Waiting on</span>
|
||||
<span>{fix.pending_reason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = window.prompt(
|
||||
'Update what you\'re waiting on:',
|
||||
fix.pending_reason ?? '',
|
||||
)
|
||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Update reason
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
|
||||
>
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
It worked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) {
|
||||
const proposal = fix.ai_outcome_proposal
|
||||
if (!proposal) return null
|
||||
@@ -318,9 +391,19 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||
Did <strong className="text-heading">"{fix.title}"</strong> work?
|
||||
</span>
|
||||
<button
|
||||
onClick={onSilenceNudge}
|
||||
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
onClick={() => {
|
||||
const reason = window.prompt(
|
||||
'What are you waiting on? (e.g. "client power-cycling router")',
|
||||
)
|
||||
if (reason && reason.trim()) {
|
||||
onOutcome('applied_pending', reason.trim())
|
||||
} else {
|
||||
onSilenceNudge()
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<Clock3 size={11} />
|
||||
Still checking
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -221,6 +221,7 @@ export default function AssistantChatPage() {
|
||||
if (activeFix.status === 'dismissed') return null
|
||||
if (activeFix.ai_outcome_proposal) return 'ai_confirming'
|
||||
if (activeFix.status === 'applied_partial') return 'partial'
|
||||
if (activeFix.status === 'applied_pending') return 'pending'
|
||||
if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null
|
||||
if (activeFix.applied_at) {
|
||||
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
|
||||
|
||||
Reference in New Issue
Block a user