feat(pilot): mount ProposalBanner + wire implicit signals

Replaces the task-lane SuggestedFix card with the ProposalBanner docked
above the chat composer. Wires:
- Resolve-while-verifying auto-marks applied_success (one-click resolve).
- Escalate-while-verifying opens EscalateInterceptDialog to capture the
  real outcome (default: didn't work) before handoff.
- 3+ post-apply engineer messages trigger the passive Nudge banner.
- AI [FIX_OUTCOME] proposals surface in the AIConfirming state; one-click
  confirm applies the outcome.

Banner state resets on session switch via resetSessionDerivedState.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 15:58:06 -04:00
parent 075b0fc1d8
commit bdb238a274
2 changed files with 214 additions and 49 deletions

View File

@@ -36,7 +36,7 @@ export interface ProposalBannerProps {
collapsed?: boolean
onToggleCollapsed?: () => void
/** Silence the nudge without collapsing it (Task 11 wires this). */
onSilenceNudge?: () => void
onSilenceNudge: () => void
}
export function ProposalBanner(props: ProposalBannerProps) {

View File

@@ -14,8 +14,11 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha
import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
import { ProposalBanner } from '@/components/pilot/ProposalBanner'
import type { BannerMode } from '@/components/pilot/ProposalBanner'
import { EscalateInterceptDialog } from '@/components/pilot/EscalateInterceptDialog'
import type { InterceptChoice } from '@/components/pilot/EscalateInterceptDialog'
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
@@ -34,6 +37,7 @@ import {
type SessionSuggestedFix,
type ResolutionNotePreview as ResolutionNotePreviewData,
type UserDecision,
type FixOutcome,
} from '@/api/sessionSuggestedFixes'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
@@ -129,6 +133,28 @@ export default function AssistantChatPage() {
// Phase 7: below 1200px the task lane collapses to a bottom drawer per the
// migration spec. Above, it's the standard right-side panel.
const isNarrow = useMediaQuery('(max-width: 1199px)')
// Phase 8: ProposalBanner + EscalateInterceptDialog state.
const [bannerCollapsed, setBannerCollapsed] = useState(false)
const [bannerApplied, setBannerApplied] = useState(false)
const [postApplyMsgCount, setPostApplyMsgCount] = useState(0)
const [nudgeSilenced, setNudgeSilenced] = useState(false)
const [escalateIntercept, setEscalateIntercept] = useState<{ fixId: string; fixTitle: string } | null>(null)
// Phase 8: compute the current banner mode from activeFix + client-side flags.
// bannerApplied is a client-side-only flag that flips on Apply click so we
// don't need a server round-trip just to stamp applied_at for mode computation.
const bannerMode: BannerMode | null = (() => {
if (!activeFix) return null
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_success' || activeFix.status === 'applied_failed') return null
if (bannerApplied || activeFix.applied_at) {
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
return 'verifying'
}
return 'proposed'
})()
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
setSidebarCollapsed(next)
@@ -313,6 +339,12 @@ export default function AssistantChatPage() {
setPreviewError(null)
setPreviewPosting(false)
setScriptPanelOpen(false)
// Phase 8: banner state reset
setBannerCollapsed(false)
setBannerApplied(false)
setPostApplyMsgCount(0)
setNudgeSilenced(false)
setEscalateIntercept(null)
}, [])
// Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat
@@ -434,19 +466,6 @@ export default function AssistantChatPage() {
}
}
const handleDismissFix = async () => {
if (!activeChatId || !activeFix) return
try {
await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed')
setActiveFix(null)
setScriptPanelOpen(false)
// Dismissal bumps state_version on the server; reflect in preview.
schedulePreviewRefresh(activeChatId)
} catch {
toast.error('Failed to dismiss suggestion')
}
}
// Phase 5: handle a path choice from NoTemplateDialog. one_off and
// draft_template just record the decision (returning the rendered script
// for display); build_template returns a redirect_path to the Script
@@ -482,7 +501,9 @@ export default function AssistantChatPage() {
}
}
const handleOpenPreview = (kind: 'resolve' | 'escalate') => {
// handleOpenPreview is declared before handleSetOutcome so it can be listed
// as a useCallback dep without a temporal dead zone.
const handleOpenPreview = useCallback((kind: 'resolve' | 'escalate') => {
if (!activeChatId) return
// Opening a different kind clobbers the cached markdown so the popover
// doesn't flash stale content while the new kind fetches.
@@ -490,7 +511,119 @@ export default function AssistantChatPage() {
setPreviewKind(kind)
setPreviewError(null)
refreshPreview(activeChatId, kind)
}
}, [activeChatId, previewKind, refreshPreview])
// Phase 8: handleApplyFix — opens the existing script panel (same as clicking
// the SuggestedFix card) and stamps the client-side bannerApplied flag so
// bannerMode transitions to 'verifying' without a server round-trip.
const handleApplyFix = useCallback(() => {
if (!activeFix) return
setBannerApplied(true)
setPostApplyMsgCount(0)
setNudgeSilenced(false)
setScriptPanelOpen(true)
}, [activeFix])
// Phase 8: record a terminal outcome for the active fix. Updates local state
// on success. For applied_success also opens the Resolve preview.
const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => {
if (!activeChatId || !activeFix) return
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
setActiveFix(updated)
// Reset apply tracking state since we now have a terminal outcome.
setBannerApplied(false)
setPostApplyMsgCount(0)
setNudgeSilenced(false)
if (outcome === 'applied_success') {
// Open the Resolve note preview so the engineer can post to PSA.
handleOpenPreview('resolve')
}
} catch (err: unknown) {
const status = (err as { response?: { status?: number; data?: { detail?: string } } })?.response?.status
if (status === 409) {
toast.warning('Outcome already recorded — session may already be in a terminal state.')
} else {
toast.error('Failed to record outcome')
}
}
}, [activeChatId, activeFix, handleOpenPreview])
// Phase 8: accept the AI-proposed outcome. Translates AI proposal outcome
// names to FixOutcome values, then delegates to handleSetOutcome.
// For partial, a non-empty notes string is required by the backend (400 on
// empty). Fall back to a generic note if the AI's reason is blank.
const handleAcceptAIProposal = useCallback(async () => {
if (!activeFix?.ai_outcome_proposal) return
const { outcome, reason } = activeFix.ai_outcome_proposal
const fixOutcome: FixOutcome =
outcome === 'success' ? 'applied_success'
: outcome === 'failure' ? 'applied_failed'
: 'applied_partial'
const notes = fixOutcome === 'applied_partial'
? (reason?.trim() || 'Partially applied per AI detection')
: fixOutcome === 'applied_failed'
? reason?.trim() || undefined
: undefined
await handleSetOutcome(fixOutcome, notes)
}, [activeFix, handleSetOutcome])
// Phase 8: reject the AI proposal — clear it locally (client-side only for v1;
// the proposal will re-surface on next server fetch but that's acceptable).
const handleRejectAIProposal = useCallback(() => {
if (!activeFix) return
setActiveFix({ ...activeFix, ai_outcome_proposal: null })
}, [activeFix])
// Phase 8: silence the nudge banner without recording an outcome.
const handleSilenceNudge = useCallback(() => {
setNudgeSilenced(true)
setPostApplyMsgCount(0)
}, [])
// Phase 8: Escalate intercept — capture fix outcome before proceeding.
// Wraps the existing Escalate click (which opens ConcludeSessionModal).
const handleEscalateClick = useCallback(() => {
const inVerifyState =
activeFix && (
((bannerApplied || !!activeFix.applied_at) && activeFix.status === 'proposed') ||
activeFix.status === 'applied_partial'
)
if (inVerifyState && activeFix) {
setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title })
return
}
setShowConclude(true)
}, [activeFix, bannerApplied])
const handleInterceptChoice = useCallback(async (choice: InterceptChoice) => {
const stored = escalateIntercept
setEscalateIntercept(null)
if (!stored || !activeChatId) return
const outcomeToSend: FixOutcome =
choice === 'never_applied' ? 'dismissed' : choice
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(
activeChatId, stored.fixId, outcomeToSend,
)
setActiveFix(updated)
} catch { /* non-fatal — engineer can still escalate */ }
setShowConclude(true)
}, [activeChatId, escalateIntercept])
// Phase 8: Resolve click — auto-mark applied_success if in verifying state
// before opening the resolution note preview.
const handleResolveClick = useCallback(async () => {
if (activeFix && (bannerApplied || activeFix.applied_at) && activeFix.status === 'proposed' && activeChatId) {
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success')
setActiveFix(updated)
} catch {
// Non-fatal; user can still resolve.
}
}
setShowConclude(true)
}, [activeChatId, activeFix, bannerApplied])
const handleClosePreview = () => {
setPreviewKind(null)
@@ -644,8 +777,8 @@ export default function AssistantChatPage() {
await aiSessionsApi.deleteSession(chatId)
setChats(prev => prev.filter(c => c.id !== chatId))
if (activeChatId === chatId) {
resetSessionDerivedState()
setActiveChatId(null)
setMessages([])
}
} catch {
toast.error('Failed to delete chat')
@@ -704,6 +837,14 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Phase 8: increment post-apply message counter for nudge logic.
// Only increments when fix is still in 'proposed' (verifying) state —
// partial/dismissed/terminal states don't render the nudge, and a
// partial→verifying transition could inherit an already-saturated counter.
if (activeFix && (bannerApplied || activeFix.applied_at) &&
activeFix.status === 'proposed') {
setPostApplyMsgCount(c => c + 1)
}
// Refetch facts + active fix; preview refreshes if open.
refreshSessionDerived(sentForChatId)
} catch (err: unknown) {
@@ -774,6 +915,12 @@ export default function AssistantChatPage() {
setActiveQuestions([])
setActiveActions([])
}
// Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend).
// Only increments in 'proposed' (verifying) state — same rationale as handleSend.
if (activeFix && (bannerApplied || activeFix.applied_at) &&
activeFix.status === 'proposed') {
setPostApplyMsgCount(c => c + 1)
}
// Refetch facts + active fix; answering tasks is the primary trigger.
refreshSessionDerived(sentForChatId)
} catch (err: unknown) {
@@ -1080,7 +1227,7 @@ export default function AssistantChatPage() {
{isActive && (
<>
<button
onClick={() => setShowConclude(true)}
onClick={handleResolveClick}
disabled={!canAct}
data-conclude-outcome="resolved"
className="flex items-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-3 py-1.5 text-xs font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
@@ -1088,8 +1235,9 @@ export default function AssistantChatPage() {
<CheckCircle2 size={13} />
Resolve
</button>
<div className="relative">
<button
onClick={() => setShowConclude(true)}
onClick={handleEscalateClick}
disabled={!canAct}
data-conclude-outcome="escalated"
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
@@ -1097,6 +1245,14 @@ export default function AssistantChatPage() {
<ArrowUpRight size={13} />
Escalate
</button>
{escalateIntercept && (
<EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)}
/>
)}
</div>
</>
)}
{messages.length >= 2 && (
@@ -1152,21 +1308,32 @@ export default function AssistantChatPage() {
{isActive && (
<>
<button
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
onClick={() => { setShowOverflow(false); handleResolveClick() }}
disabled={!canAct}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-success hover:bg-success-dim transition-colors disabled:opacity-40"
>
<CheckCircle2 size={14} />
Resolve
</button>
<button
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
disabled={!canAct}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
>
<ArrowUpRight size={14} />
Escalate
</button>
{/* Mobile Escalate: wrapped in relative so EscalateInterceptDialog anchors here */}
<div className="relative">
<button
onClick={() => { setShowOverflow(false); handleEscalateClick() }}
disabled={!canAct}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
>
<ArrowUpRight size={14} />
Escalate
</button>
{/* Mobile intercept dialog — mirrors desktop; only one is visible at a time */}
{escalateIntercept && (
<EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)}
/>
)}
</div>
</>
)}
<button
@@ -1233,6 +1400,22 @@ export default function AssistantChatPage() {
<div ref={messagesEndRef} />
</div>
{/* Phase 8: ProposalBanner — mounted above the composer */}
{activeFix && bannerMode && (
<ProposalBanner
fix={activeFix}
mode={bannerMode}
collapsed={bannerCollapsed && bannerMode !== 'nudge' && bannerMode !== 'ai_confirming'}
onToggleCollapsed={() => setBannerCollapsed(v => !v)}
onApply={handleApplyFix}
onDismiss={() => handleSetOutcome('dismissed')}
onOutcome={handleSetOutcome}
onAcceptAIProposal={handleAcceptAIProposal}
onRejectAIProposal={handleRejectAIProposal}
onSilenceNudge={handleSilenceNudge}
/>
)}
{/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0">
<div
@@ -1431,16 +1614,7 @@ export default function AssistantChatPage() {
loading={loading}
/>
}
suggestedFixSlot={
activeFix && (
<SuggestedFix
fix={activeFix}
onDismiss={handleDismissFix}
onActivate={() => setScriptPanelOpen((prev) => !prev)}
panelOpen={scriptPanelOpen}
/>
)
}
suggestedFixSlot={null}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && (
@@ -1520,16 +1694,7 @@ export default function AssistantChatPage() {
loading={loading}
/>
}
suggestedFixSlot={
activeFix && (
<SuggestedFix
fix={activeFix}
onDismiss={handleDismissFix}
onActivate={() => setScriptPanelOpen((prev) => !prev)}
panelOpen={scriptPanelOpen}
/>
)
}
suggestedFixSlot={null}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && (