Fix issue #5315: OpenHands UI should pop up a dialog box to warn before closing when agent is running

This commit is contained in:
openhands 2024-11-28 16:44:45 +00:00
parent 3ac57a61a7
commit 3007bb1809
8 changed files with 513 additions and 1 deletions

View File

@ -0,0 +1,204 @@
import { renderHook } from "@testing-library/react";
import { useCloseWarning } from "#/hooks/use-close-warning";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { rootReducer } from "#/store";
import { UserPrefsProvider } from "#/context/user-prefs-context";
import AgentState from "#/types/agent-state";
import { test, expect, vi, beforeEach, afterEach } from "vitest";
const mockSettings = {
CLOSE_WARNING: "while_working",
LLM_MODEL: "",
LLM_BASE_URL: "",
AGENT: "",
LANGUAGE: "en",
LLM_API_KEY: "",
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
};
const createStore = (agentState = AgentState.FINISHED) => configureStore({
reducer: rootReducer,
preloadedState: {
agent: {
curAgentState: agentState,
},
fileState: {
files: [],
selectedPath: null,
modifiedFiles: {},
},
initalQuery: {
selectedRepository: null,
},
browser: {
url: "",
isLoading: false,
error: null,
},
chat: {
messages: [],
isLoading: false,
error: null,
},
code: {
content: "",
isLoading: false,
error: null,
},
cmd: {
output: "",
isLoading: false,
error: null,
},
jupyter: {
cells: [],
isLoading: false,
error: null,
},
securityAnalyzer: {
isLoading: false,
error: null,
},
status: {
isLoading: false,
error: null,
},
},
});
const mockStore = createStore();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={mockStore}>
<UserPrefsProvider initialSettings={mockSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
beforeEach(() => {
window.addEventListener = vi.fn();
window.removeEventListener = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should add and remove event listener", () => {
const { unmount } = renderHook(() => useCloseWarning(), { wrapper });
expect(window.addEventListener).toHaveBeenCalledWith(
"beforeunload",
expect.any(Function)
);
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith(
"beforeunload",
expect.any(Function)
);
});
test("should prevent unload when agent is working and setting is while_working", () => {
const store = createStore(AgentState.RUNNING);
const customWrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>
<UserPrefsProvider initialSettings={mockSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
renderHook(() => useCloseWarning(), { wrapper: customWrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
test("should not prevent unload when agent is not working and setting is while_working", () => {
renderHook(() => useCloseWarning(), { wrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
test("should always prevent unload when setting is always", () => {
const customSettings = {
...mockSettings,
CLOSE_WARNING: "always",
};
const customWrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={mockStore}>
<UserPrefsProvider initialSettings={customSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
renderHook(() => useCloseWarning(), { wrapper: customWrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
test("should never prevent unload when setting is never", () => {
const customSettings = {
...mockSettings,
CLOSE_WARNING: "never",
};
const customWrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={mockStore}>
<UserPrefsProvider initialSettings={customSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
renderHook(() => useCloseWarning(), { wrapper: customWrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});

View File

@ -351,6 +351,39 @@ export function SettingsForm({
>
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
</Switch>
<fieldset className="flex flex-col gap-2">
<label
htmlFor="close-warning"
className="font-[500] text-[#A3A3A3] text-xs"
>
{t("CONFIGURATION$CLOSE_WARNING_LABEL")}
</label>
<Autocomplete
isDisabled={disabled}
isRequired
id="close-warning"
name="close-warning"
aria-label="Close Warning"
defaultSelectedKey={settings.CLOSE_WARNING}
inputProps={{
classNames: {
inputWrapper:
"bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
},
}}
>
<AutocompleteItem key="always" value="always">
Always
</AutocompleteItem>
<AutocompleteItem key="while_working" value="while_working">
While agent is working
</AutocompleteItem>
<AutocompleteItem key="never" value="never">
Never
</AutocompleteItem>
</Autocomplete>
</fieldset>
</>
)}
</div>

View File

@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import { Dialog } from "@headlessui/react";
interface CloseWarningDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function CloseWarningDialog({
isOpen,
onClose,
onConfirm,
}: CloseWarningDialogProps) {
const { t } = useTranslation();
return (
<Dialog
open={isOpen}
onClose={onClose}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-sm rounded bg-white p-6">
<Dialog.Title className="text-lg font-medium leading-6 text-gray-900">
{t("CLOSE_WARNING$DIALOG_TITLE")}
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
{t("CLOSE_WARNING$DIALOG_MESSAGE")}
</Dialog.Description>
<div className="mt-4 flex justify-end space-x-2">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
onClick={onClose}
>
{t("CLOSE_WARNING$DIALOG_CANCEL")}
</button>
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
onClick={onConfirm}
>
{t("CLOSE_WARNING$DIALOG_CONFIRM")}
</button>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '#/store';
import AgentState from '#/types/agent-state';
import { useUserPrefs } from '#/context/user-prefs-context';
export function useCloseWarning() {
const [showWarning, setShowWarning] = useState(false);
const { settings } = useUserPrefs();
const agentState = useSelector((state: RootState) => state.agent.curAgentState);
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const isWorking = agentState === AgentState.RUNNING || agentState === AgentState.STARTING;
if (settings.CLOSE_WARNING === 'always' ||
(settings.CLOSE_WARNING === 'while_working' && isWorking)) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [agentState, settings.CLOSE_WARNING]);
return {
showWarning,
setShowWarning,
};
}

View File

@ -0,0 +1,86 @@
{
"CONFIGURATION$CLOSE_WARNING_LABEL": {
"en": "Close warning",
"zh-CN": "关闭警告",
"de": "Schließen-Warnung",
"ko-KR": "닫기 경고",
"no": "Lukkevarsel",
"zh-TW": "關閉警告",
"it": "Avviso di chiusura",
"pt": "Aviso de fechamento",
"es": "Aviso de cierre",
"ar": "تحذير الإغلاق",
"fr": "Avertissement de fermeture",
"tr": "Kapatma uyarısı"
},
"CONFIGURATION$CLOSE_WARNING_PLACEHOLDER": {
"en": "When to show close warning",
"zh-CN": "何时显示关闭警告",
"de": "Wann die Schließen-Warnung anzeigen",
"ko-KR": "닫기 경고를 표시할 시기",
"no": "Når skal lukkevarsel vises",
"zh-TW": "何時顯示關閉警告",
"it": "Quando mostrare l'avviso di chiusura",
"pt": "Quando mostrar aviso de fechamento",
"es": "Cuándo mostrar aviso de cierre",
"ar": "متى يظهر تحذير الإغلاق",
"fr": "Quand afficher l'avertissement de fermeture",
"tr": "Kapatma uyarısı ne zaman gösterilsin"
},
"CLOSE_WARNING$DIALOG_TITLE": {
"en": "Close OpenHands?",
"zh-CN": "关闭 OpenHands",
"de": "OpenHands schließen?",
"ko-KR": "OpenHands를 닫으시겠습니까?",
"no": "Lukk OpenHands?",
"zh-TW": "關閉 OpenHands",
"it": "Chiudere OpenHands?",
"pt": "Fechar OpenHands?",
"es": "¿Cerrar OpenHands?",
"ar": "إغلاق OpenHands؟",
"fr": "Fermer OpenHands ?",
"tr": "OpenHands kapatılsın mı?"
},
"CLOSE_WARNING$DIALOG_MESSAGE": {
"en": "The agent is currently working. Closing the window will terminate its work and any progress will be lost.",
"zh-CN": "智能体正在工作中。关闭窗口将终止其工作,所有进度都将丢失。",
"de": "Der Agent arbeitet gerade. Wenn Sie das Fenster schließen, wird seine Arbeit beendet und der Fortschritt geht verloren.",
"ko-KR": "에이전트가 현재 작업 중입니다. 창을 닫으면 작업이 종료되고 모든 진행 상황이 손실됩니다.",
"no": "Agenten jobber for øyeblikket. Å lukke vinduet vil avslutte arbeidet og all fremgang vil gå tapt.",
"zh-TW": "智能體正在工作中。關閉視窗將終止其工作,所有進度都將丟失。",
"it": "L'agente sta attualmente lavorando. La chiusura della finestra terminerà il suo lavoro e qualsiasi progresso andrà perso.",
"pt": "O agente está trabalhando no momento. Fechar a janela encerrará seu trabalho e todo o progresso será perdido.",
"es": "El agente está trabajando actualmente. Cerrar la ventana terminará su trabajo y se perderá todo el progreso.",
"ar": "الوكيل يعمل حاليا. سيؤدي إغلاق النافذة إلى إنهاء عمله وسيتم فقد أي تقدم.",
"fr": "L'agent est en train de travailler. Fermer la fenêtre mettra fin à son travail et tout progrès sera perdu.",
"tr": "Ajan şu anda çalışıyor. Pencereyi kapatmak işini sonlandıracak ve tüm ilerleme kaybedilecek."
},
"CLOSE_WARNING$DIALOG_CONFIRM": {
"en": "Close anyway",
"zh-CN": "仍然关闭",
"de": "Trotzdem schließen",
"ko-KR": "그래도 닫기",
"no": "Lukk likevel",
"zh-TW": "仍然關閉",
"it": "Chiudi comunque",
"pt": "Fechar mesmo assim",
"es": "Cerrar de todos modos",
"ar": "إغلاق على أي حال",
"fr": "Fermer quand même",
"tr": "Yine de kapat"
},
"CLOSE_WARNING$DIALOG_CANCEL": {
"en": "Cancel",
"zh-CN": "取消",
"de": "Abbrechen",
"ko-KR": "취소",
"no": "Avbryt",
"zh-TW": "取消",
"it": "Annulla",
"pt": "Cancelar",
"es": "Cancelar",
"ar": "إلغاء",
"fr": "Annuler",
"tr": "İptal"
}
}

View File

@ -2013,4 +2013,91 @@
"en": "Download as .zip",
"es": "Descargar como .zip"
}
,
"CONFIGURATION$CLOSE_WARNING_LABEL": {
"en": "Close warning",
"zh-CN": "关闭警告",
"de": "Schließen-Warnung",
"ko-KR": "닫기 경고",
"no": "Lukkevarsel",
"zh-TW": "關閉警告",
"it": "Avviso di chiusura",
"pt": "Aviso de fechamento",
"es": "Aviso de cierre",
"ar": "تحذير الإغلاق",
"fr": "Avertissement de fermeture",
"tr": "Kapatma uyarısı"
},
"CONFIGURATION$CLOSE_WARNING_PLACEHOLDER": {
"en": "When to show close warning",
"zh-CN": "何时显示关闭警告",
"de": "Wann die Schließen-Warnung anzeigen",
"ko-KR": "닫기 경고를 표시할 시기",
"no": "Når skal lukkevarsel vises",
"zh-TW": "何時顯示關閉警告",
"it": "Quando mostrare l'avviso di chiusura",
"pt": "Quando mostrar aviso de fechamento",
"es": "Cuándo mostrar aviso de cierre",
"ar": "متى يظهر تحذير الإغلاق",
"fr": "Quand afficher l'avertissement de fermeture",
"tr": "Kapatma uyarısı ne zaman gösterilsin"
},
"CLOSE_WARNING$DIALOG_TITLE": {
"en": "Close OpenHands?",
"zh-CN": "关闭 OpenHands",
"de": "OpenHands schließen?",
"ko-KR": "OpenHands를 닫으시겠습니까?",
"no": "Lukk OpenHands?",
"zh-TW": "關閉 OpenHands",
"it": "Chiudere OpenHands?",
"pt": "Fechar OpenHands?",
"es": "¿Cerrar OpenHands?",
"ar": "إغلاق OpenHands؟",
"fr": "Fermer OpenHands ?",
"tr": "OpenHands kapatılsın mı?"
},
"CLOSE_WARNING$DIALOG_MESSAGE": {
"en": "The agent is currently working. Closing the window will terminate its work and any progress will be lost.",
"zh-CN": "智能体正在工作中。关闭窗口将终止其工作,所有进度都将丢失。",
"de": "Der Agent arbeitet gerade. Wenn Sie das Fenster schließen, wird seine Arbeit beendet und der Fortschritt geht verloren.",
"ko-KR": "에이전트가 현재 작업 중입니다. 창을 닫으면 작업이 종료되고 모든 진행 상황이 손실됩니다.",
"no": "Agenten jobber for øyeblikket. Å lukke vinduet vil avslutte arbeidet og all fremgang vil gå tapt.",
"zh-TW": "智能體正在工作中。關閉視窗將終止其工作,所有進度都將丟失。",
"it": "L'agente sta attualmente lavorando. La chiusura della finestra terminerà il suo lavoro e qualsiasi progresso andrà perso.",
"pt": "O agente está trabalhando no momento. Fechar a janela encerrará seu trabalho e todo o progresso será perdido.",
"es": "El agente está trabajando actualmente. Cerrar la ventana terminará su trabajo y se perderá todo el progreso.",
"ar": "الوكيل يعمل حاليا. سيؤدي إغلاق النافذة إلى إنهاء عمله وسيتم فقد أي تقدم.",
"fr": "L'agent est en train de travailler. Fermer la fenêtre mettra fin à son travail et tout progrès sera perdu.",
"tr": "Ajan şu anda çalışıyor. Pencereyi kapatmak işini sonlandıracak ve tüm ilerleme kaybedilecek."
},
"CLOSE_WARNING$DIALOG_CONFIRM": {
"en": "Close anyway",
"zh-CN": "仍然关闭",
"de": "Trotzdem schließen",
"ko-KR": "그래도 닫기",
"no": "Lukk likevel",
"zh-TW": "仍然關閉",
"it": "Chiudi comunque",
"pt": "Fechar mesmo assim",
"es": "Cerrar de todos modos",
"ar": "إغلاق على أي حال",
"fr": "Fermer quand même",
"tr": "Yine de kapat"
},
"CLOSE_WARNING$DIALOG_CANCEL": {
"en": "Cancel",
"zh-CN": "取消",
"de": "Abbrechen",
"ko-KR": "취소",
"no": "Avbryt",
"zh-TW": "取消",
"it": "Annulla",
"pt": "Cancelar",
"es": "Cancelar",
"ar": "إلغاء",
"fr": "Annuler",
"tr": "İptal"
}
}

View File

@ -21,10 +21,13 @@ import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { useCloseWarning } from "#/hooks/use-close-warning";
import { CloseWarningDialog } from "#/components/modals/close-warning-dialog";
function App() {
const { token, gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const { showWarning, setShowWarning } = useCloseWarning();
const dispatch = useDispatch();
useConversationConfig();
@ -113,6 +116,11 @@ function App() {
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
<CloseWarningDialog
isOpen={showWarning}
onClose={() => setShowWarning(false)}
onConfirm={() => window.close()}
/>
</div>
</EventHandler>
</WsClientProvider>

View File

@ -1,4 +1,6 @@
export const LATEST_SETTINGS_VERSION = 3;
export const LATEST_SETTINGS_VERSION = 4;
export type CloseWarningMode = 'always' | 'while_working' | 'never';
export type Settings = {
LLM_MODEL: string;
@ -8,6 +10,7 @@ export type Settings = {
LLM_API_KEY: string;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
CLOSE_WARNING: CloseWarningMode;
};
export const DEFAULT_SETTINGS: Settings = {
@ -18,6 +21,7 @@ export const DEFAULT_SETTINGS: Settings = {
LLM_API_KEY: "",
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
CLOSE_WARNING: "while_working",
};
const validKeys = Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[];
@ -53,6 +57,9 @@ export const maybeMigrateSettings = () => {
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
localStorage.setItem("CLOSE_WARNING", DEFAULT_SETTINGS.CLOSE_WARNING);
}
};
/**
@ -71,6 +78,7 @@ export const getSettings = (): Settings => {
const apiKey = localStorage.getItem("LLM_API_KEY");
const confirmationMode = localStorage.getItem("CONFIRMATION_MODE") === "true";
const securityAnalyzer = localStorage.getItem("SECURITY_ANALYZER");
const closeWarning = localStorage.getItem("CLOSE_WARNING") as CloseWarningMode;
return {
LLM_MODEL: model || DEFAULT_SETTINGS.LLM_MODEL,
@ -80,6 +88,7 @@ export const getSettings = (): Settings => {
LLM_API_KEY: apiKey || DEFAULT_SETTINGS.LLM_API_KEY,
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
CLOSE_WARNING: closeWarning || DEFAULT_SETTINGS.CLOSE_WARNING,
};
};