Forráskód Böngészése

[ALL-594] chore(frontend): Add frontend error handling for failed requests (#4501)

sp.wack 1 éve
szülő
commit
3927fc3616

+ 1 - 1
frontend/__tests__/components/file-explorer/FileExplorer.test.tsx

@@ -16,7 +16,7 @@ vi.mock("../../services/fileService", async () => ({
 }));
 
 const renderFileExplorerWithRunningAgentState = () =>
-  renderWithProviders(<FileExplorer />, {
+  renderWithProviders(<FileExplorer error={null} />, {
     preloadedState: {
       agent: {
         curAgentState: AgentState.RUNNING,

+ 18 - 6
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<HTMLInputElement | null>(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() {
               />
             </div>
           </div>
-          <div className="overflow-auto flex-grow">
-            <div style={{ display: isHidden ? "none" : "block" }}>
-              <ExplorerTree files={paths} />
+          {!error && (
+            <div className="overflow-auto flex-grow">
+              <div style={{ display: isHidden ? "none" : "block" }}>
+                <ExplorerTree files={paths} />
+              </div>
             </div>
-          </div>
+          )}
+          {error && (
+            <div className="flex flex-col items-center justify-center h-full">
+              <p className="text-neutral-300 text-sm">{error}</p>
+            </div>
+          )}
         </div>
         <input
           data-testid="file-input"

+ 15 - 6
frontend/src/components/file-explorer/TreeNode.tsx

@@ -1,5 +1,6 @@
 import React from "react";
 import { useSelector } from "react-redux";
+import toast from "react-hot-toast";
 import { RootState } from "#/store";
 import FolderIcon from "../FolderIcon";
 import FileIcon from "../FileIcons";
@@ -60,8 +61,12 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
 
     const token = localStorage.getItem("token");
     if (token) {
-      const newChildren = await OpenHands.getFiles(token, path);
-      setChildren(newChildren);
+      try {
+        const newChildren = await OpenHands.getFiles(token, path);
+        setChildren(newChildren);
+      } catch (error) {
+        toast.error("Failed to fetch files");
+      }
     }
   };
 
@@ -77,12 +82,16 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
     if (isDirectory) {
       setIsOpen((prev) => !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");
       }
     }
   };

+ 8 - 1
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)}
         />
       )}

+ 40 - 34
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"]);
-  }),
 ];

+ 2 - 1
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");
           }
         }
       }

+ 17 - 26
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 (
     <div className="flex h-full w-full bg-neutral-900 relative">
-      <FileExplorer />
+      <FileExplorer error={errors.getFiles} />
       <div className="flex flex-col min-h-0 w-full">
         {selectedPath && (
           <div className="flex w-full items-center justify-between self-end p-2">

+ 31 - 19
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<SettingsFormData>({
+      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) && (
           <ModalBackdrop onClose={() => setSettingsModalIsOpen(false)}>
             <div className="bg-root-primary w-[384px] p-6 rounded-xl flex flex-col gap-2">
+              {settingsFormError && (
+                <p className="text-danger text-xs">{settingsFormError}</p>
+              )}
               <span className="text-xl leading-6 font-semibold -tracking-[0.01em">
                 AI Provider Configuration
               </span>
@@ -247,9 +259,9 @@ export default function MainApp() {
               )}
               <SettingsForm
                 settings={settings}
-                models={data.models}
-                agents={data.agents}
-                securityAnalyzers={data.securityAnalyzers}
+                models={settingsFormData.models}
+                agents={settingsFormData.agents}
+                securityAnalyzers={settingsFormData.securityAnalyzers}
                 onClose={() => setSettingsModalIsOpen(false)}
               />
             </div>

+ 12 - 16
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 token = localStorage.getItem("token");
+  if (!token) {
+    throw new Error("No token found");
+  }
 
-    const blob = await OpenHands.getWorkspaceZip(token);
+  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 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);
 };