Skip to content

Commit 60d9c8b

Browse files
gcmsgclaude
andcommitted
feat(console): add unsaved changes protection to AgentEditPage
Uses React Router useBlocker and beforeunload event to warn when navigating away with dirty form state. Shows modal dialog with discard/cancel options. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4078c31 commit 60d9c8b

File tree

1 file changed

+48
-3
lines changed

1 file changed

+48
-3
lines changed

web/app/src/pages/AgentEditPage.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useState } from "react"
2-
import { useParams, useNavigate } from "react-router-dom"
1+
import { useState, useEffect, useCallback, useRef } from "react"
2+
import { useParams, useNavigate, useBlocker } from "react-router-dom"
33
import { useTranslation } from "react-i18next"
44
import { useProviderAgent, useProviderMutations } from "@/hooks/use-provider"
55
import type { RegisterAgentData } from "@/hooks/use-provider"
@@ -27,6 +27,9 @@ export function AgentEditPage() {
2727
const [capInput, setCapInput] = useState("")
2828
const [tagInput, setTagInput] = useState("")
2929

30+
const [isDirty, setIsDirty] = useState(false)
31+
const submittingRef = useRef(false)
32+
3033
// Initialize form when agent data loads
3134
if (agent && !form) {
3235
setForm({
@@ -43,6 +46,25 @@ export function AgentEditPage() {
4346
})
4447
}
4548

49+
// Track dirty state
50+
const markDirty = useCallback(() => setIsDirty(true), [])
51+
52+
// Warn on browser close / tab close
53+
useEffect(() => {
54+
if (!isDirty) return
55+
const handler = (e: BeforeUnloadEvent) => {
56+
e.preventDefault()
57+
}
58+
window.addEventListener("beforeunload", handler)
59+
return () => window.removeEventListener("beforeunload", handler)
60+
}, [isDirty])
61+
62+
// Block in-app navigation
63+
const blocker = useBlocker(
64+
({ currentLocation, nextLocation }) =>
65+
isDirty && !submittingRef.current && currentLocation.pathname !== nextLocation.pathname
66+
)
67+
4668
if (loading) {
4769
return (
4870
<div className="flex h-64 items-center justify-center">
@@ -61,8 +83,10 @@ export function AgentEditPage() {
6183

6284
if (!agent || !form) return null
6385

64-
const update = (key: keyof RegisterAgentData, value: unknown) =>
86+
const update = (key: keyof RegisterAgentData, value: unknown) => {
6587
setForm((prev) => prev ? { ...prev, [key]: value } : prev)
88+
markDirty()
89+
}
6690

6791
const toggleProtocol = (proto: string) => {
6892
const current = form.protocols ?? []
@@ -92,11 +116,14 @@ export function AgentEditPage() {
92116
if (!id || !form) return
93117
setSubmitting(true)
94118
setSubmitError(null)
119+
submittingRef.current = true
95120
try {
96121
await updateAgent(id, form as RegisterAgentData)
122+
setIsDirty(false)
97123
navigate(`/console/agents/${id}`)
98124
} catch (e) {
99125
setSubmitError(e instanceof Error ? e.message : "Failed to save")
126+
submittingRef.current = false
100127
} finally {
101128
setSubmitting(false)
102129
}
@@ -323,6 +350,24 @@ export function AgentEditPage() {
323350
{submitting ? t('wizard.saving') : t('wizard.saveChanges')}
324351
</Button>
325352
</div>
353+
354+
{/* Unsaved changes navigation blocker */}
355+
{blocker.state === "blocked" && (
356+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
357+
<div className="rounded-lg border border-border bg-card p-6 shadow-lg max-w-sm">
358+
<h3 className="text-lg font-semibold">{t('wizard.unsavedChangesTitle')}</h3>
359+
<p className="mt-2 text-sm text-muted-foreground">{t('wizard.unsavedChangesDesc')}</p>
360+
<div className="mt-4 flex justify-end gap-2">
361+
<Button variant="outline" size="sm" onClick={() => blocker.reset?.()}>
362+
{t('common.cancel')}
363+
</Button>
364+
<Button variant="destructive" size="sm" onClick={() => blocker.proceed?.()}>
365+
{t('wizard.discardChanges')}
366+
</Button>
367+
</div>
368+
</div>
369+
</div>
370+
)}
326371
</div>
327372
)
328373
}

0 commit comments

Comments
 (0)