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

This commit is contained in:
sp.wack 2024-10-22 20:05:59 +04:00 committed by GitHub
parent 864f81bc71
commit 3927fc3616
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 145 additions and 111 deletions

View File

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

View File

@ -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"

View File

@ -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");
}
}
};

View File

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

View File

@ -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"]);
}),
];

View File

@ -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");
}
}
}

View File

@ -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">

View File

@ -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>

View File

@ -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);
};