From 3927fc3616f3600117d4f2aeb8621e89938f7b1e Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 22 Oct 2024 20:05:59 +0400 Subject: [PATCH] [ALL-594] chore(frontend): Add frontend error handling for failed requests (#4501) --- .../file-explorer/FileExplorer.test.tsx | 2 +- .../components/file-explorer/FileExplorer.tsx | 24 ++++-- .../src/components/file-explorer/TreeNode.tsx | 21 ++++-- .../project-menu/ProjectMenuCard.tsx | 9 ++- frontend/src/mocks/handlers.ts | 74 ++++++++++--------- .../_oh.app._index/code-editor-component.tsx | 3 +- frontend/src/routes/_oh.app._index/route.tsx | 43 +++++------ frontend/src/routes/_oh.tsx | 50 ++++++++----- frontend/src/utils/download-workspace.ts | 30 ++++---- 9 files changed, 145 insertions(+), 111 deletions(-) diff --git a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx index fcdd05a423..b1faa3c18b 100644 --- a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx +++ b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx @@ -16,7 +16,7 @@ vi.mock("../../services/fileService", async () => ({ })); const renderFileExplorerWithRunningAgentState = () => - renderWithProviders(, { + renderWithProviders(, { preloadedState: { agent: { curAgentState: AgentState.RUNNING, diff --git a/frontend/src/components/file-explorer/FileExplorer.tsx b/frontend/src/components/file-explorer/FileExplorer.tsx index 8362723b53..c6e2c249fe 100644 --- a/frontend/src/components/file-explorer/FileExplorer.tsx +++ b/frontend/src/components/file-explorer/FileExplorer.tsx @@ -90,12 +90,17 @@ function ExplorerActions({ ); } -function FileExplorer() { +interface FileExplorerProps { + error: string | null; +} + +function FileExplorer({ error }: FileExplorerProps) { const { revalidate } = useRevalidator(); const { paths, setPaths } = useFiles(); const [isHidden, setIsHidden] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false); + const { curAgentState } = useSelector((state: RootState) => state.agent); const fileInputRef = React.useRef(null); const dispatch = useDispatch(); @@ -158,7 +163,7 @@ function FileExplorer() { refreshWorkspace(); } - } catch (error) { + } catch (e) { // Handle unexpected errors (network issues, etc.) toast.error( `upload-error-${new Date().getTime()}`, @@ -230,11 +235,18 @@ function FileExplorer() { /> -
-
- + {!error && ( +
+
+ +
-
+ )} + {error && ( +
+

{error}

+
+ )}
!prev); } else if (token) { - setSelectedPath(path); const code = modifiedFiles[path] || files[path]; - const fetchedCode = await OpenHands.getFile(token, path); - if (!code || fetchedCode !== files[path]) { - setFileContent(path, fetchedCode); + try { + const fetchedCode = await OpenHands.getFile(token, path); + setSelectedPath(path); + if (!code || fetchedCode !== files[path]) { + setFileContent(path, fetchedCode); + } + } catch (error) { + toast.error("Failed to fetch file"); } } }; diff --git a/frontend/src/components/project-menu/ProjectMenuCard.tsx b/frontend/src/components/project-menu/ProjectMenuCard.tsx index f883382afb..d19949454b 100644 --- a/frontend/src/components/project-menu/ProjectMenuCard.tsx +++ b/frontend/src/components/project-menu/ProjectMenuCard.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useDispatch } from "react-redux"; +import toast from "react-hot-toast"; import EllipsisH from "#/assets/ellipsis-h.svg?react"; import { ModalBackdrop } from "../modals/modal-backdrop"; import { ConnectToGitHubModal } from "../modals/connect-to-github-modal"; @@ -64,7 +65,13 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB isConnectedToGitHub={isConnectedToGitHub} onConnectToGitHub={() => setConnectToGitHubModalOpen(true)} onPushToGitHub={handlePushToGitHub} - onDownloadWorkspace={downloadWorkspace} + onDownloadWorkspace={() => { + try { + downloadWorkspace(); + } catch (error) { + toast.error("Failed to download workspace"); + } + }} onClose={() => setContextMenuIsOpen(false)} /> )} diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 4d4af2d7a7..1e47d8beff 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,21 +1,25 @@ import { delay, http, HttpResponse } from "msw"; -export const handlers = [ - http.get("https://api.github.com/user/repos", ({ request }) => { - const token = request.headers - .get("Authorization") - ?.replace("Bearer", "") - .trim(); - - if (!token) { - return HttpResponse.json([], { status: 401 }); - } - +const openHandsHandlers = [ + http.get("http://localhost:3000/api/options/models", async () => { + await delay(); return HttpResponse.json([ - { id: 1, full_name: "octocat/hello-world" }, - { id: 2, full_name: "octocat/earth" }, + "gpt-3.5-turbo", + "gpt-4o", + "anthropic/claude-3.5", ]); }), + + http.get("http://localhost:3000/api/options/agents", async () => { + await delay(); + return HttpResponse.json(["CodeActAgent", "CoActAgent"]); + }), + + http.get("http://localhost:3000/api/options/security-analyzers", async () => { + await delay(); + return HttpResponse.json(["mock-invariant"]); + }), + http.get("http://localhost:3000/api/list-files", async ({ request }) => { await delay(); @@ -24,14 +28,16 @@ export const handlers = [ ?.replace("Bearer", "") .trim(); - if (!token) { - return HttpResponse.json([], { status: 401 }); - } - + if (!token) return HttpResponse.json([], { status: 401 }); return HttpResponse.json(["file1.ts", "dir1/file2.ts", "file3.ts"]); }), + + http.post("http://localhost:3000/api/save-file", () => + HttpResponse.json(null, { status: 200 }), + ), + http.get("http://localhost:3000/api/select-file", async ({ request }) => { - await delay(500); + await delay(); const token = request.headers .get("Authorization") @@ -51,26 +57,26 @@ export const handlers = [ return HttpResponse.json(null, { status: 404 }); }), - http.get("http://localhost:3000/api/options/agents", async () => { - await delay(); - return HttpResponse.json(["CodeActAgent", "CoActAgent"]); - }), - http.get("http://localhost:3000/api/options/models", async () => { - await delay(); +]; + +export const handlers = [ + ...openHandsHandlers, + http.get("https://api.github.com/user/repos", ({ request }) => { + const token = request.headers + .get("Authorization") + ?.replace("Bearer", "") + .trim(); + + if (!token) { + return HttpResponse.json([], { status: 401 }); + } + return HttpResponse.json([ - "gpt-3.5-turbo", - "gpt-4o", - "anthropic/claude-3.5", + { id: 1, full_name: "octocat/hello-world" }, + { id: 2, full_name: "octocat/earth" }, ]); }), http.post("http://localhost:3000/api/submit-feedback", async () => HttpResponse.json({ statusCode: 200 }, { status: 200 }), ), - http.post("http://localhost:3000/api/save-file", () => - HttpResponse.json(null, { status: 200 }), - ), - http.get("http://localhost:3000/api/options/security-analyzers", async () => { - await delay(); - return HttpResponse.json(["mock-invariant"]); - }), ]; diff --git a/frontend/src/routes/_oh.app._index/code-editor-component.tsx b/frontend/src/routes/_oh.app._index/code-editor-component.tsx index 63bc66097b..cf94ed863a 100644 --- a/frontend/src/routes/_oh.app._index/code-editor-component.tsx +++ b/frontend/src/routes/_oh.app._index/code-editor-component.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { VscCode } from "react-icons/vsc"; import { type editor } from "monaco-editor"; +import toast from "react-hot-toast"; import { I18nKey } from "#/i18n/declaration"; import { useFiles } from "#/context/files"; import OpenHands from "#/api/open-hands"; @@ -51,7 +52,7 @@ function CodeEditorCompoonent({ isReadOnly }: CodeEditorCompoonentProps) { const token = localStorage.getItem("token")?.toString(); if (token) await OpenHands.saveFile(token, selectedPath, content); } catch (error) { - // handle error + toast.error("Failed to save file"); } } } diff --git a/frontend/src/routes/_oh.app._index/route.tsx b/frontend/src/routes/_oh.app._index/route.tsx index 5cc533e15d..ba20e003f7 100644 --- a/frontend/src/routes/_oh.app._index/route.tsx +++ b/frontend/src/routes/_oh.app._index/route.tsx @@ -1,11 +1,7 @@ import React from "react"; import { useSelector } from "react-redux"; -import { - ClientActionFunctionArgs, - json, - useLoaderData, - useRouteError, -} from "@remix-run/react"; +import { json, useLoaderData, useRouteError } from "@remix-run/react"; +import toast from "react-hot-toast"; import { RootState } from "#/store"; import AgentState from "#/types/AgentState"; import FileExplorer from "#/components/file-explorer/FileExplorer"; @@ -20,21 +16,6 @@ export const clientLoader = async () => { return json({ token }); }; -export const clientAction = async ({ request }: ClientActionFunctionArgs) => { - const token = localStorage.getItem("token"); - - const formData = await request.formData(); - const file = formData.get("file")?.toString(); - - let selectedFileContent: string | null = null; - - if (file && token) { - selectedFileContent = await OpenHands.getFile(token, file); - } - - return json({ file, selectedFileContent }); -}; - export function ErrorBoundary() { const error = useRouteError(); @@ -57,13 +38,23 @@ function CodeEditor() { discardChanges, } = useFiles(); + const [errors, setErrors] = React.useState<{ getFiles: string | null }>({ + getFiles: null, + }); + const agentState = useSelector( (state: RootState) => state.agent.curAgentState, ); React.useEffect(() => { // only retrieve files if connected to WS to prevent requesting before runtime is ready - if (runtimeActive && token) OpenHands.getFiles(token).then(setPaths); + if (runtimeActive && token) { + OpenHands.getFiles(token) + .then(setPaths) + .catch(() => { + setErrors({ getFiles: "Failed to retrieve files" }); + }); + } }, [runtimeActive, token]); // Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules) @@ -77,13 +68,13 @@ function CodeEditor() { const handleSave = async () => { if (selectedPath) { - const content = saveNewFileContent(selectedPath); - + const content = modifiedFiles[selectedPath]; if (content && token) { try { await OpenHands.saveFile(token, selectedPath, content); + saveNewFileContent(selectedPath); } catch (error) { - // handle error + toast.error("Failed to save file"); } } } @@ -95,7 +86,7 @@ function CodeEditor() { return (
- +
{selectedPath && (
diff --git a/frontend/src/routes/_oh.tsx b/frontend/src/routes/_oh.tsx index 2d2597f2a4..7d31465b3e 100644 --- a/frontend/src/routes/_oh.tsx +++ b/frontend/src/routes/_oh.tsx @@ -91,6 +91,12 @@ export function ErrorBoundary() { ); } +type SettingsFormData = { + models: string[]; + agents: string[]; + securityAnalyzers: string[]; +}; + export default function MainApp() { const { stop, isConnected } = useSocket(); const navigation = useNavigation(); @@ -105,28 +111,31 @@ export default function MainApp() { const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] = React.useState(false); - const [data, setData] = React.useState<{ - models: string[]; - agents: string[]; - securityAnalyzers: string[]; - }>({ - models: [], - agents: [], - securityAnalyzers: [], - }); + const [settingsFormData, setSettingsFormData] = + React.useState({ + models: [], + agents: [], + securityAnalyzers: [], + }); + const [settingsFormError, setSettingsFormError] = React.useState< + string | null + >(null); React.useEffect(() => { // We fetch this here instead of the data loader because the server seems to block // the retrieval when the session is closing -- preventing the screen from rendering until // the fetch is complete (async () => { - const [models, agents, securityAnalyzers] = await Promise.all([ - OpenHands.getModels(), - OpenHands.getAgents(), - OpenHands.getSecurityAnalyzers(), - ]); - - setData({ models, agents, securityAnalyzers }); + try { + const [models, agents, securityAnalyzers] = await Promise.all([ + OpenHands.getModels(), + OpenHands.getAgents(), + OpenHands.getSecurityAnalyzers(), + ]); + setSettingsFormData({ models, agents, securityAnalyzers }); + } catch (error) { + setSettingsFormError("Failed to load settings, please reload the page"); + } })(); }, []); @@ -233,6 +242,9 @@ export default function MainApp() { {(!settingsIsUpdated || settingsModalIsOpen) && ( setSettingsModalIsOpen(false)}>
+ {settingsFormError && ( +

{settingsFormError}

+ )} AI Provider Configuration @@ -247,9 +259,9 @@ export default function MainApp() { )} setSettingsModalIsOpen(false)} />
diff --git a/frontend/src/utils/download-workspace.ts b/frontend/src/utils/download-workspace.ts index 90ae924544..1bbf30612d 100644 --- a/frontend/src/utils/download-workspace.ts +++ b/frontend/src/utils/download-workspace.ts @@ -4,22 +4,18 @@ import OpenHands from "#/api/open-hands"; * Downloads the current workspace as a .zip file. */ export const downloadWorkspace = async () => { - try { - const token = localStorage.getItem("token"); - if (!token) { - throw new Error("No token found"); - } - - const blob = await OpenHands.getWorkspaceZip(token); - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.setAttribute("download", "workspace.zip"); - document.body.appendChild(link); - link.click(); - link.parentNode?.removeChild(link); - } catch (e) { - console.error("Failed to download workspace as .zip", e); + const token = localStorage.getItem("token"); + if (!token) { + throw new Error("No token found"); } + + const blob = await OpenHands.getWorkspaceZip(token); + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", "workspace.zip"); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); };