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:
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user