From 6d813f0678846f093852e08d17df7abc0d10770c Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 19:18:51 +0000 Subject: [PATCH] Fix unsaved agent config navigation guard Co-Authored-By: Paperclip --- .../lib/use-unsaved-navigation-guard.test.tsx | 78 +++++++++++++++++++ ui/src/lib/use-unsaved-navigation-guard.ts | 25 ++++++ ui/src/pages/AgentDetail.tsx | 17 ++-- 3 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 ui/src/lib/use-unsaved-navigation-guard.test.tsx create mode 100644 ui/src/lib/use-unsaved-navigation-guard.ts diff --git a/ui/src/lib/use-unsaved-navigation-guard.test.tsx b/ui/src/lib/use-unsaved-navigation-guard.test.tsx new file mode 100644 index 00000000000..61d5aa7535f --- /dev/null +++ b/ui/src/lib/use-unsaved-navigation-guard.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { + createMemoryRouter, + RouterProvider, + useLocation, + useNavigate, +} from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useUnsavedNavigationGuard } from "./use-unsaved-navigation-guard"; + +describe("useUnsavedNavigationGuard", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it("prompts once and keeps dirty state when navigation is cancelled", async () => { + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + function GuardedPage() { + const navigate = useNavigate(); + const location = useLocation(); + const [dirty] = useState(true); + + useUnsavedNavigationGuard(dirty, "Discard changes?"); + + return ( + <> + + {location.pathname} + {String(dirty)} + + ); + } + + const router = createMemoryRouter( + [{ path: "*", element: }], + { initialEntries: ["/agents/test/configuration"] }, + ); + + flushSync(() => { + root.render(); + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + flushSync(() => { + container.querySelector("button")?.click(); + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(container.querySelector("[data-location]")?.textContent).toBe("/agents/test/configuration"); + expect(container.querySelector("[data-dirty]")?.textContent).toBe("true"); + + router.dispose(); + }); +}); diff --git a/ui/src/lib/use-unsaved-navigation-guard.ts b/ui/src/lib/use-unsaved-navigation-guard.ts new file mode 100644 index 00000000000..8666c600143 --- /dev/null +++ b/ui/src/lib/use-unsaved-navigation-guard.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; +import { useBlocker } from "@/lib/router"; + +export function useUnsavedNavigationGuard(when: boolean, message: string) { + const blocker = useBlocker(when); + const promptActiveRef = useRef(false); + + useEffect(() => { + if (blocker.state !== "blocked") { + promptActiveRef.current = false; + return; + } + + if (promptActiveRef.current) return; + promptActiveRef.current = true; + + if (window.confirm(message)) { + setTimeout(blocker.proceed, 0); + } else { + blocker.reset(); + } + }, [blocker, message]); + + return blocker; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 33965752acd..a39f1ed96d0 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, Link, Navigate, useBeforeUnload, useBlocker, type NavigateFunction } from "@/lib/router"; +import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router"; import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { agentsApi, @@ -38,6 +38,7 @@ import { type AgentConfigurationSectionId, } from "../components/agent-configuration-shell"; import { PageTabBar } from "../components/PageTabBar"; +import { useUnsavedNavigationGuard } from "@/lib/use-unsaved-navigation-guard"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities"; @@ -742,12 +743,7 @@ export function AgentDetail() { event.preventDefault(); event.returnValue = ""; }, [configDirty])); - const navigationBlocker = useBlocker(configDirty); - useEffect(() => { - if (navigationBlocker.state !== "blocked") return; - if (window.confirm("Discard your unsaved agent configuration changes?")) navigationBlocker.proceed(); - else navigationBlocker.reset(); - }, [navigationBlocker]); + useUnsavedNavigationGuard(configDirty, "Discard your unsaved agent configuration changes?"); const { data: agent, isLoading, error } = useQuery({ queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], @@ -756,6 +752,9 @@ export function AgentDetail() { }); const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; + const handleViewChange = useCallback((value: string) => { + navigate(`/agents/${canonicalAgentRef}/${value}`); + }, [canonicalAgentRef, navigate]); const agentLookupRef = agent?.id ?? routeAgentRef; const resolvedAgentId = agent?.id ?? null; const membershipsQuery = useResourceMemberships(resolvedCompanyId); @@ -1282,7 +1281,7 @@ export function AgentDetail() { {!urlRunId && ( navigate(`/agents/${canonicalAgentRef}/${value}`)} + onValueChange={isMobile ? undefined : handleViewChange} > navigate(`/agents/${canonicalAgentRef}/${value}`)} + onValueChange={isMobile ? handleViewChange : undefined} /> )}