mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
[ALL-594] chore(frontend): Add frontend error handling for failed requests (#4501)
This commit is contained in:
parent
864f81bc71
commit
3927fc3616
@ -16,7 +16,7 @@ vi.mock("../../services/fileService", async () => ({
|
||||
}));
|
||||
|
||||
const renderFileExplorerWithRunningAgentState = () =>
|
||||
renderWithProviders(<FileExplorer />, {
|
||||
renderWithProviders(<FileExplorer error={null} />, {
|
||||
preloadedState: {
|
||||
agent: {
|
||||
curAgentState: AgentState.RUNNING,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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"]);
|
||||
}),
|
||||
];
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user