fix(pilot): persist Apply — stamp applied_at on click
Issue #2 from phase-8-review-issues.md. Apply was client-side-only via a bannerApplied flag. Refresh / chat reselect / multi-tab would drop Verifying state back to Proposed. - New POST /ai-sessions/{sid}/suggested-fixes/{fid}/apply stamps applied_at without changing status (still 'proposed'). Idempotent if already stamped; 409 if fix is past proposed (a terminal outcome was already recorded). - Bumps state_version so resolve/escalate preview bundles reflect that the fix has entered verifying. - Frontend handleApplyFix calls the endpoint and uses the returned applied_at directly. bannerApplied client flag is removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,19 @@ export const sessionSuggestedFixesApi = {
|
||||
return r.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
|
||||
* Does NOT change status (fix remains 'proposed'). Status flips only on
|
||||
* a subsequent PATCH /outcome. Idempotent if applied_at is already set.
|
||||
* Returns 409 if the fix is no longer in 'proposed' status.
|
||||
*/
|
||||
async applyFix(sessionId: string, fixId: string): Promise<SessionSuggestedFix> {
|
||||
const r = await apiClient.post<SessionSuggestedFix>(
|
||||
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/apply`,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Record the outcome of applying a suggested fix. Transition rules:
|
||||
* - from `proposed` or `applied_partial`: any outcome is valid (partial is
|
||||
|
||||
@@ -135,20 +135,19 @@ export default function AssistantChatPage() {
|
||||
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.
|
||||
// Phase 8: compute the current banner mode from activeFix.
|
||||
// applied_at is now persisted on the server (stamped by POST /apply),
|
||||
// so bannerMode is derived entirely from server state — no client-side flag.
|
||||
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 (activeFix.applied_at) {
|
||||
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
|
||||
return 'verifying'
|
||||
}
|
||||
@@ -341,7 +340,6 @@ export default function AssistantChatPage() {
|
||||
setScriptPanelOpen(false)
|
||||
// Phase 8: banner state reset
|
||||
setBannerCollapsed(false)
|
||||
setBannerApplied(false)
|
||||
setPostApplyMsgCount(0)
|
||||
setNudgeSilenced(false)
|
||||
setEscalateIntercept(null)
|
||||
@@ -513,16 +511,23 @@ export default function AssistantChatPage() {
|
||||
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)
|
||||
// Phase 8: handleApplyFix — stamps applied_at on the server so Verifying state
|
||||
// survives refresh/reselect/multi-tab, then opens the script panel.
|
||||
const handleApplyFix = useCallback(async () => {
|
||||
if (!activeFix || !activeChatId) return
|
||||
setPostApplyMsgCount(0)
|
||||
setNudgeSilenced(false)
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.applyFix(activeChatId, activeFix.id)
|
||||
setActiveFix(updated)
|
||||
} catch (err: unknown) {
|
||||
// Non-fatal: the script panel still opens. The banner will stay in
|
||||
// 'proposed' mode until the next refreshActiveFix succeeds, which is
|
||||
// a cosmetic gap only — no data loss.
|
||||
console.error('[AssistantChat] applyFix failed:', err)
|
||||
}
|
||||
setScriptPanelOpen(true)
|
||||
}, [activeFix])
|
||||
}, [activeFix, activeChatId])
|
||||
|
||||
// Phase 8: record a terminal outcome for the active fix. Updates local state
|
||||
// on success. For applied_success also opens the Resolve preview.
|
||||
@@ -532,7 +537,6 @@ export default function AssistantChatPage() {
|
||||
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') {
|
||||
@@ -586,7 +590,7 @@ export default function AssistantChatPage() {
|
||||
const handleEscalateClick = useCallback(() => {
|
||||
const inVerifyState =
|
||||
activeFix && (
|
||||
((bannerApplied || !!activeFix.applied_at) && activeFix.status === 'proposed') ||
|
||||
(!!activeFix.applied_at && activeFix.status === 'proposed') ||
|
||||
activeFix.status === 'applied_partial'
|
||||
)
|
||||
if (inVerifyState && activeFix) {
|
||||
@@ -594,7 +598,7 @@ export default function AssistantChatPage() {
|
||||
return
|
||||
}
|
||||
setShowConclude(true)
|
||||
}, [activeFix, bannerApplied])
|
||||
}, [activeFix])
|
||||
|
||||
const handleInterceptChoice = useCallback(async (choice: InterceptChoice) => {
|
||||
const stored = escalateIntercept
|
||||
@@ -614,7 +618,7 @@ export default function AssistantChatPage() {
|
||||
// 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) {
|
||||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed' && activeChatId) {
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success')
|
||||
setActiveFix(updated)
|
||||
@@ -623,7 +627,7 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}
|
||||
setShowConclude(true)
|
||||
}, [activeChatId, activeFix, bannerApplied])
|
||||
}, [activeChatId, activeFix])
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setPreviewKind(null)
|
||||
@@ -841,8 +845,7 @@ export default function AssistantChatPage() {
|
||||
// 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') {
|
||||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
|
||||
setPostApplyMsgCount(c => c + 1)
|
||||
}
|
||||
// Refetch facts + active fix; preview refreshes if open.
|
||||
@@ -917,8 +920,7 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
// 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') {
|
||||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
|
||||
setPostApplyMsgCount(c => c + 1)
|
||||
}
|
||||
// Refetch facts + active fix; answering tasks is the primary trigger.
|
||||
|
||||
Reference in New Issue
Block a user