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:
2026-04-23 22:10:52 -04:00
parent 362c7b1d79
commit de2bef3175
4 changed files with 209 additions and 22 deletions

View File

@@ -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

View File

@@ -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.