diff --git a/frontend/src/components/pilot/ProposalBanner.tsx b/frontend/src/components/pilot/ProposalBanner.tsx index 491ae6f8..1bd91475 100644 --- a/frontend/src/components/pilot/ProposalBanner.tsx +++ b/frontend/src/components/pilot/ProposalBanner.tsx @@ -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) { diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 0ae41ec4..27b3be39 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -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 && ( <> +