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}
/>
)}