feat(frontend): Utilize TanStack Query (#5096)

This commit is contained in:
sp.wack 2024-11-22 23:38:27 +04:00 committed by GitHub
parent bb8b4a0b18
commit becb17f0c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 1639 additions and 1316 deletions

View File

@ -10,7 +10,8 @@
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
"plugin:react-hooks/recommended",
"plugin:@tanstack/query/recommended"
],
"plugins": [
"prettier"

View File

@ -1,40 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { clearSession } from "../src/utils/clear-session";
import store from "../src/store";
import { initialState as browserInitialState } from "../src/state/browserSlice";
describe("clearSession", () => {
beforeEach(() => {
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
vi.stubGlobal("localStorage", localStorageMock);
// Set initial browser state to non-default values
store.dispatch({
type: "browser/setUrl",
payload: "https://example.com",
});
store.dispatch({
type: "browser/setScreenshotSrc",
payload: "base64screenshot",
});
});
it("should clear localStorage and reset browser state", () => {
clearSession();
// Verify localStorage items were removed
expect(localStorage.removeItem).toHaveBeenCalledWith("token");
expect(localStorage.removeItem).toHaveBeenCalledWith("repo");
// Verify browser state was reset
const state = store.getState();
expect(state.browser.url).toBe(browserInitialState.url);
expect(state.browser.screenshotSrc).toBe(browserInitialState.screenshotSrc);
});
});

View File

@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/feedback-form";
describe("FeedbackForm", () => {
@ -12,7 +13,9 @@ describe("FeedbackForm", () => {
});
it("should render correctly", () => {
render(<FeedbackForm polarity="positive" onClose={onCloseMock} />);
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
screen.getByLabelText("Email");
screen.getByLabelText("Private");
@ -23,7 +26,9 @@ describe("FeedbackForm", () => {
});
it("should switch between private and public permissions", async () => {
render(<FeedbackForm polarity="positive" onClose={onCloseMock} />);
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
const privateRadio = screen.getByLabelText("Private");
const publicRadio = screen.getByLabelText("Public");
@ -40,10 +45,11 @@ describe("FeedbackForm", () => {
});
it("should call onClose when the close button is clicked", async () => {
render(<FeedbackForm polarity="positive" onClose={onCloseMock} />);
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCloseMock).toHaveBeenCalled();
});
});

View File

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

View File

@ -1,7 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import * as Remix from "@remix-run/react";
import { UserActions } from "#/components/user-actions";
describe("UserActions", () => {
@ -9,14 +8,9 @@ describe("UserActions", () => {
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
const useFetcherSpy = vi.spyOn(Remix, "useFetcher");
// @ts-expect-error - Only returning the relevant properties for the test
useFetcherSpy.mockReturnValue({ state: "idle" });
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
useFetcherSpy.mockClear();
});
it("should render", () => {
@ -111,10 +105,8 @@ describe("UserActions", () => {
expect(onLogoutMock).not.toHaveBeenCalled();
});
it("should display the loading spinner", () => {
// @ts-expect-error - Only returning the relevant properties for the test
useFetcherSpy.mockReturnValue({ state: "loading" });
// FIXME: Spinner now provided through useQuery
it.skip("should display the loading spinner", () => {
render(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}

View File

@ -1,35 +1,153 @@
import { describe, it, test } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createRemixStub } from "@remix-run/testing";
import { screen, waitFor, within } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import userEvent from "@testing-library/user-event";
import MainApp from "#/routes/_oh";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import i18n from "#/i18n";
describe("frontend/routes/_oh", () => {
describe("brand logo", () => {
it.todo("should not do anything if the user is in the main screen");
it.todo(
"should be clickable and redirect to the main screen if the user is not in the main screen",
);
const RemixStub = createRemixStub([{ Component: MainApp, path: "/" }]);
const { userIsAuthenticatedMock, settingsAreUpToDateMock } = vi.hoisted(
() => ({
userIsAuthenticatedMock: vi.fn(),
settingsAreUpToDateMock: vi.fn(),
}),
);
beforeAll(() => {
vi.mock("#/utils/user-is-authenticated", () => ({
userIsAuthenticated: userIsAuthenticatedMock.mockReturnValue(true),
}));
vi.mock("#/services/settings", async (importOriginal) => ({
...(await importOriginal<typeof import("#/services/settings")>()),
settingsAreUpToDate: settingsAreUpToDateMock,
}));
});
describe("user menu", () => {
it.todo("should open the user menu when clicked");
afterEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe("logged out", () => {
it.todo("should display a placeholder");
test.todo("the logout option in the user menu should be disabled");
});
it("should render", async () => {
renderWithProviders(<RemixStub />);
await screen.findByTestId("root-layout");
});
describe("logged in", () => {
it.todo("should display the user's avatar");
it.todo("should log the user out when the logout option is clicked");
it("should render the AI config modal if the user is authed", async () => {
// Our mock return value is true by default
renderWithProviders(<RemixStub />);
await screen.findByTestId("ai-config-modal");
});
it("should render the AI config modal if settings are not up-to-date", async () => {
settingsAreUpToDateMock.mockReturnValue(false);
renderWithProviders(<RemixStub />);
await screen.findByTestId("ai-config-modal");
});
it("should not render the AI config modal if the settings are up-to-date", async () => {
settingsAreUpToDateMock.mockReturnValue(true);
renderWithProviders(<RemixStub />);
await waitFor(() => {
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
});
});
describe("config", () => {
it.todo("should open the config modal when clicked");
it.todo(
"should not save the config and close the config modal when the close button is clicked",
it("should capture the user's consent", async () => {
const user = userEvent.setup();
const handleCaptureConsentSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
it.todo(
"should save the config when the save button is clicked and close the modal",
);
it.todo("should warn the user about saving the config when in /app");
renderWithProviders(<RemixStub />);
// The user has not consented to tracking
const consentForm = await screen.findByTestId("user-capture-consent-form");
expect(handleCaptureConsentSpy).not.toHaveBeenCalled();
expect(localStorage.getItem("analytics-consent")).toBeNull();
const submitButton = within(consentForm).getByRole("button", {
name: /confirm preferences/i,
});
await user.click(submitButton);
// The user has now consented to tracking
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
expect(localStorage.getItem("analytics-consent")).toBe("true");
expect(
screen.queryByTestId("user-capture-consent-form"),
).not.toBeInTheDocument();
});
it("should not render the user consent form if the user has already made a decision", async () => {
localStorage.setItem("analytics-consent", "true");
renderWithProviders(<RemixStub />);
await waitFor(() => {
expect(
screen.queryByTestId("user-capture-consent-form"),
).not.toBeInTheDocument();
});
});
it("should render a new project button if a token is set", async () => {
localStorage.setItem("token", "test-token");
const { rerender } = renderWithProviders(<RemixStub />);
await screen.findByTestId("new-project-button");
localStorage.removeItem("token");
rerender(<RemixStub />);
await waitFor(() => {
expect(
screen.queryByTestId("new-project-button"),
).not.toBeInTheDocument();
});
});
// TODO: Move to e2e tests
it.skip("should update the i18n language when the language settings change", async () => {
const changeLanguageSpy = vi.spyOn(i18n, "changeLanguage");
const { rerender } = renderWithProviders(<RemixStub />);
// The default language is English
expect(changeLanguageSpy).toHaveBeenCalledWith("en");
localStorage.setItem("LANGUAGE", "es");
rerender(<RemixStub />);
expect(changeLanguageSpy).toHaveBeenCalledWith("es");
rerender(<RemixStub />);
// The language has not changed, so the spy should not have been called again
expect(changeLanguageSpy).toHaveBeenCalledTimes(2);
});
// FIXME: logoutCleanup has been replaced with a hook
it.skip("should call logoutCleanup after a logout", async () => {
const user = userEvent.setup();
localStorage.setItem("ghToken", "test-token");
// const logoutCleanupSpy = vi.spyOn(LogoutCleanup, "logoutCleanup");
renderWithProviders(<RemixStub />);
const userActions = await screen.findByTestId("user-actions");
const userAvatar = within(userActions).getByTestId("user-avatar");
await user.click(userAvatar);
const logout = within(userActions).getByRole("button", { name: /logout/i });
await user.click(logout);
// expect(logoutCleanupSpy).toHaveBeenCalled();
expect(localStorage.getItem("ghToken")).toBeNull();
});
});

View File

@ -1,53 +0,0 @@
import { afterEach } from "node:test";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cache } from "#/utils/cache";
describe("Cache", () => {
const testKey = "key";
const testData = { message: "Hello, world!" };
const testTTL = 1000; // 1 second
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("gets data from memory if not expired", () => {
cache.set(testKey, testData, testTTL);
expect(cache.get(testKey)).toEqual(testData);
});
it("should expire after 5 minutes by default", () => {
cache.set(testKey, testData);
expect(cache.get(testKey)).not.toBeNull();
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(cache.get(testKey)).toBeNull();
});
it("returns null if cached data is expired", () => {
cache.set(testKey, testData, testTTL);
vi.advanceTimersByTime(testTTL + 1);
expect(cache.get(testKey)).toBeNull();
});
it("deletes data from memory", () => {
cache.set(testKey, testData, testTTL);
cache.delete(testKey);
expect(cache.get(testKey)).toBeNull();
});
it("clears all data with the app prefix from memory", () => {
cache.set(testKey, testData, testTTL);
cache.set("anotherKey", { data: "More data" }, testTTL);
cache.clearAll();
expect(cache.get(testKey)).toBeNull();
expect(cache.get("anotherKey")).toBeNull();
});
});

View File

@ -0,0 +1,13 @@
import { expect, test } from "vitest";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
test("extractNextPageFromLink", () => {
const link = `<https://api.github.com/repositories/1300192/issues?page=2>; rel="prev", <https://api.github.com/repositories/1300192/issues?page=4>; rel="next", <https://api.github.com/repositories/1300192/issues?page=515>; rel="last", <https://api.github.com/repositories/1300192/issues?page=1>; rel="first"`;
expect(extractNextPageFromLink(link)).toBe(4);
const noNextLink = `<https://api.github.com/repositories/1300192/issues?page=2>; rel="prev", <https://api.github.com/repositories/1300192/issues?page=1>; rel="first"`;
expect(extractNextPageFromLink(noNextLink)).toBeNull();
const extra = `<https://api.github.com/user/repos?sort=pushed&page=2&per_page=3>; rel="next", <https://api.github.com/user/repos?sort=pushed&page=22&per_page=3>; rel="last"`;
expect(extractNextPageFromLink(extra)).toBe(2);
});

View File

@ -0,0 +1,44 @@
import posthog from "posthog-js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
describe("handleCaptureConsent", () => {
const optInSpy = vi.spyOn(posthog, "opt_in_capturing");
const optOutSpy = vi.spyOn(posthog, "opt_out_capturing");
const hasOptedInSpy = vi.spyOn(posthog, "has_opted_in_capturing");
const hasOptedOutSpy = vi.spyOn(posthog, "has_opted_out_capturing");
afterEach(() => {
vi.clearAllMocks();
});
it("should opt out of of capturing", () => {
handleCaptureConsent(false);
expect(optOutSpy).toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
});
it("should opt in to capturing if the user consents", () => {
handleCaptureConsent(true);
expect(optInSpy).toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
});
it("should not opt in to capturing if the user is already opted in", () => {
hasOptedInSpy.mockReturnValueOnce(true);
handleCaptureConsent(true);
expect(optInSpy).not.toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
});
it("should not opt out of capturing if the user is already opted out", () => {
hasOptedOutSpy.mockReturnValueOnce(true);
handleCaptureConsent(false);
expect(optOutSpy).not.toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
});
});

View File

@ -15,6 +15,7 @@
"@remix-run/node": "^2.11.2",
"@remix-run/react": "^2.11.2",
"@remix-run/serve": "^2.11.2",
"@tanstack/react-query": "^5.60.5",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@ -50,6 +51,7 @@
"@remix-run/dev": "^2.11.2",
"@remix-run/testing": "^2.11.2",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/eslint-plugin-query": "^5.60.1",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
@ -5812,6 +5814,143 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
}
},
"node_modules/@tanstack/eslint-plugin-query": {
"version": "5.60.1",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.60.1.tgz",
"integrity": "sha512-oCaWtFKa6WwX14fm/Sp486eTFXXgadiDzEYxhM/tiAlM+xzvPwp6ZHgR6sndmvYK+s/jbksDCTLIPS0PCH8L2g==",
"dev": true,
"dependencies": {
"@typescript-eslint/utils": "^8.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/scope-manager": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.14.0.tgz",
"integrity": "sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.14.0",
"@typescript-eslint/visitor-keys": "8.14.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/types": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.14.0.tgz",
"integrity": "sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.14.0.tgz",
"integrity": "sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.14.0",
"@typescript-eslint/visitor-keys": "8.14.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/utils": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.14.0.tgz",
"integrity": "sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.14.0",
"@typescript-eslint/types": "8.14.0",
"@typescript-eslint/typescript-estree": "8.14.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.14.0.tgz",
"integrity": "sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.14.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.60.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.60.5.tgz",
"integrity": "sha512-jiS1aC3XI3BJp83ZiTuDLerTmn9P3U95r6p+6/SNauLJaYxfIC4dMuWygwnBHIZxjn2zJqEpj3nysmPieoxfPQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.60.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.60.5.tgz",
"integrity": "sha512-M77bOsPwj1wYE56gk7iJvxGAr4IC12NWdIDhT+Eo8ldkWRHMvIR8I/rufIvT1OXoV/bl7EECwuRuMlxxWtvW2Q==",
"dependencies": {
"@tanstack/query-core": "5.60.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",

View File

@ -14,6 +14,7 @@
"@remix-run/node": "^2.11.2",
"@remix-run/react": "^2.11.2",
"@remix-run/serve": "^2.11.2",
"@tanstack/react-query": "^5.60.5",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@ -76,6 +77,7 @@
"@remix-run/dev": "^2.11.2",
"@remix-run/testing": "^2.11.2",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/eslint-plugin-query": "^5.60.1",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",

View File

@ -26,7 +26,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1:3000",
baseURL: "http://localhost:3001/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@ -72,8 +72,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev:mock -- --port 3000",
url: "http://127.0.0.1:3000",
command: "npm run dev:mock -- --port 3001",
url: "http://localhost:3001/",
reuseExistingServer: !process.env.CI,
},
});

View File

@ -27,82 +27,19 @@ export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
*/
export const retrieveGitHubUserRepositories = async (
token: string,
per_page = 30,
page = 1,
per_page = 30,
): Promise<Response> => {
const url = new URL("https://api.github.com/user/repos");
url.searchParams.append("sort", "pushed"); // sort by most recently pushed
url.searchParams.append("per_page", per_page.toString());
url.searchParams.append("page", page.toString());
url.searchParams.append("per_page", per_page.toString());
return fetch(url.toString(), {
headers: generateGitHubAPIHeaders(token),
});
};
/**
* Given a GitHub token, retrieves all repositories of the authenticated user
* @param token The GitHub token
* @returns A list of repositories or an error response
*/
export const retrieveAllGitHubUserRepositories = async (
token: string,
): Promise<GitHubRepository[] | GitHubErrorReponse> => {
const repositories: GitHubRepository[] = [];
// Fetch the first page to extract the last page number and get the first batch of data
const firstPageResponse = await retrieveGitHubUserRepositories(token, 100, 1);
if (!firstPageResponse.ok) {
return {
message: "Failed to fetch repositories",
documentation_url:
"https://docs.github.com/rest/reference/repos#list-repositories-for-the-authenticated-user",
status: firstPageResponse.status,
};
}
const firstPageData = await firstPageResponse.json();
repositories.push(...firstPageData);
// Check for pagination and extract the last page number
const link = firstPageResponse.headers.get("link");
const lastPageMatch = link?.match(/page=(\d+)>; rel="last"/);
const lastPage = lastPageMatch ? parseInt(lastPageMatch[1], 10) : 1;
// If there is only one page, return the fetched repositories
if (lastPage === 1) {
return repositories;
}
// Create an array of promises for the remaining pages
const promises = [];
for (let page = 2; page <= lastPage; page += 1) {
promises.push(retrieveGitHubUserRepositories(token, 100, page));
}
// Fetch all pages in parallel
const responses = await Promise.all(promises);
for (const response of responses) {
if (response.ok) {
// TODO: Is there a way to avoid using await within a loop?
// eslint-disable-next-line no-await-in-loop
const data = await response.json();
repositories.push(...data);
} else {
return {
message: "Failed to fetch repositories",
documentation_url:
"https://docs.github.com/rest/reference/repos#list-repositories-for-the-authenticated-user",
status: response.status,
};
}
}
return repositories;
};
/**
* Given a GitHub token, retrieves the authenticated user
* @param token The GitHub token
@ -114,6 +51,11 @@ export const retrieveGitHubUser = async (
const response = await fetch("https://api.github.com/user", {
headers: generateGitHubAPIHeaders(token),
});
if (!response.ok) {
throw new Error("Failed to retrieve user data");
}
const data = await response.json();
if (!isGitHubErrorReponse(data)) {
@ -149,5 +91,9 @@ export const retrieveLatestGitHubCommit = async (
headers: generateGitHubAPIHeaders(token),
});
if (!response.ok) {
throw new Error("Failed to retrieve latest commit");
}
return response.json();
};

View File

@ -1,5 +1,4 @@
import { request } from "#/services/api";
import { cache } from "#/utils/cache";
import {
SaveFileSuccessResponse,
FileUploadSuccessResponse,
@ -17,13 +16,13 @@ class OpenHands {
* @returns List of models available
*/
static async getModels(): Promise<string[]> {
const cachedData = cache.get<string[]>("models");
if (cachedData) return cachedData;
const response = await fetch("/api/options/models");
const data = await request("/api/options/models");
cache.set("models", data);
if (!response.ok) {
throw new Error("Failed to fetch models");
}
return data;
return response.json();
}
/**
@ -31,13 +30,13 @@ class OpenHands {
* @returns List of agents available
*/
static async getAgents(): Promise<string[]> {
const cachedData = cache.get<string[]>("agents");
if (cachedData) return cachedData;
const response = await fetch("/api/options/agents");
const data = await request(`/api/options/agents`);
cache.set("agents", data);
if (!response.ok) {
throw new Error("Failed to fetch agents");
}
return data;
return response.json();
}
/**
@ -45,23 +44,23 @@ class OpenHands {
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise<string[]> {
const cachedData = cache.get<string[]>("agents");
if (cachedData) return cachedData;
const response = await fetch("/api/options/security-analyzers");
const data = await request(`/api/options/security-analyzers`);
cache.set("security-analyzers", data);
if (!response.ok) {
throw new Error("Failed to fetch security analyzers");
}
return data;
return response.json();
}
static async getConfig(): Promise<GetConfigResponse> {
const cachedData = cache.get<GetConfigResponse>("config");
if (cachedData) return cachedData;
const response = await fetch("/config.json");
const data = await request("/config.json");
cache.set("config", data);
if (!response.ok) {
throw new Error("Failed to fetch config");
}
return data;
return response.json();
}
/**
@ -69,10 +68,21 @@ class OpenHands {
* @param path Path to list files from
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
*/
static async getFiles(path?: string): Promise<string[]> {
let url = "/api/list-files";
if (path) url += `?path=${encodeURIComponent(path)}`;
return request(url);
static async getFiles(token: string, path?: string): Promise<string[]> {
const url = new URL("/api/list-files", window.location.origin);
if (path) url.searchParams.append("path", path);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch files");
}
return response.json();
}
/**
@ -80,9 +90,21 @@ class OpenHands {
* @param path Full path of the file to retrieve
* @returns Content of the file
*/
static async getFile(path: string): Promise<string> {
const url = `/api/select-file?file=${encodeURIComponent(path)}`;
const data = await request(url);
static async getFile(token: string, path: string): Promise<string> {
const url = new URL("/api/select-file", window.location.origin);
url.searchParams.append("file", path);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch file");
}
const data = await response.json();
return data.code;
}
@ -93,16 +115,32 @@ class OpenHands {
* @returns Success message or error message
*/
static async saveFile(
token: string,
path: string,
content: string,
): Promise<SaveFileSuccessResponse | ErrorResponse> {
return request(`/api/save-file`, {
): Promise<SaveFileSuccessResponse> {
const response = await fetch("/api/save-file", {
method: "POST",
body: JSON.stringify({ filePath: path, content }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to save file");
}
const data = (await response.json()) as
| SaveFileSuccessResponse
| ErrorResponse;
if ("error" in data) {
throw new Error(data.error);
}
return data;
}
/**
@ -111,15 +149,78 @@ class OpenHands {
* @returns Success message or error message
*/
static async uploadFiles(
file: File[],
): Promise<FileUploadSuccessResponse | ErrorResponse> {
token: string,
files: File[],
): Promise<FileUploadSuccessResponse> {
const formData = new FormData();
file.forEach((f) => formData.append("files", f));
files.forEach((file) => formData.append("files", file));
return request(`/api/upload-files`, {
const response = await fetch("/api/upload-files", {
method: "POST",
body: formData,
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to upload files");
}
const data = (await response.json()) as
| FileUploadSuccessResponse
| ErrorResponse;
if ("error" in data) {
throw new Error(data.error);
}
return data;
}
/**
* Send feedback to the server
* @param data Feedback data
* @returns The stored feedback data
*/
static async submitFeedback(
token: string,
feedback: Feedback,
): Promise<FeedbackResponse> {
const response = await fetch("/api/submit-feedback", {
method: "POST",
body: JSON.stringify(feedback),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to submit feedback");
}
return response.json();
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful
*/
static async authenticate(
gitHubToken: string,
appMode: GetConfigResponse["APP_MODE"],
): Promise<boolean> {
if (appMode === "oss") return true;
const response = await fetch("/api/authenticate", {
method: "POST",
headers: {
"X-GitHub-Token": gitHubToken,
},
});
return response.ok;
}
/**
@ -131,21 +232,6 @@ class OpenHands {
return response.blob();
}
/**
* Send feedback to the server
* @param data Feedback data
* @returns The stored feedback data
*/
static async submitFeedback(data: Feedback): Promise<FeedbackResponse> {
return request(`/api/submit-feedback`, {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
});
}
/**
* @param code Code provided by GitHub
* @returns GitHub access token
@ -153,27 +239,19 @@ class OpenHands {
static async getGitHubAccessToken(
code: string,
): Promise<GitHubAccessTokenResponse> {
return request(`/api/github/callback`, {
const response = await fetch("/api/github/callback", {
method: "POST",
body: JSON.stringify({ code }),
headers: {
"Content-Type": "application/json",
},
});
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful
*/
static async authenticate(): Promise<Response> {
return request(
`/api/authenticate`,
{
method: "POST",
},
true,
);
if (!response.ok) {
throw new Error("Failed to get GitHub access token");
}
return response.json();
}
/**

View File

@ -1,4 +1,3 @@
import { useFetcher } from "@remix-run/react";
import { ModalBackdrop } from "./modals/modal-backdrop";
import ModalBody from "./modals/ModalBody";
import ModalButton from "./buttons/ModalButton";
@ -6,15 +5,31 @@ import {
BaseModalTitle,
BaseModalDescription,
} from "./modals/confirmation-modals/BaseModal";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
export function AnalyticsConsentFormModal() {
const fetcher = useFetcher({ key: "set-consent" });
interface AnalyticsConsentFormModalProps {
onClose: () => void;
}
export function AnalyticsConsentFormModal({
onClose,
}: AnalyticsConsentFormModalProps) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const analytics = formData.get("analytics") === "on";
handleCaptureConsent(analytics);
localStorage.setItem("analytics-consent", analytics.toString());
onClose();
};
return (
<ModalBackdrop>
<fetcher.Form
method="POST"
action="/set-consent"
<form
data-testid="user-capture-consent-form"
onSubmit={handleSubmit}
className="flex flex-col gap-2"
>
<ModalBody>
@ -36,7 +51,7 @@ export function AnalyticsConsentFormModal() {
className="bg-primary text-white w-full hover:opacity-80"
/>
</ModalBody>
</fetcher.Form>
</form>
</ModalBackdrop>
);
}

View File

@ -1,7 +1,6 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { useRouteLoaderData } from "@remix-run/react";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions";
@ -27,22 +26,22 @@ import {
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
import { clientLoader } from "#/routes/_oh";
import { downloadWorkspace } from "#/utils/download-workspace";
import { SuggestionItem } from "./suggestion-item";
import { useAuth } from "#/context/auth-context";
const isErrorMessage = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
export function ChatInterface() {
const { gitHubToken } = useAuth();
const { send, status, isLoadingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
const rootLoaderData = useRouteLoaderData<typeof clientLoader>("routes/_oh");
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
@ -175,7 +174,7 @@ export function ChatInterface() {
{(curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED) && (
<div className="flex flex-col gap-2 mb-2">
{rootLoaderData?.ghToken ? (
{gitHubToken ? (
<SuggestionItem
suggestion={{
label: "Push to GitHub",

View File

@ -1,12 +1,11 @@
import { IoLockClosed } from "react-icons/io5";
import { useRouteLoaderData } from "@remix-run/react";
import React from "react";
import { useSelector } from "react-redux";
import AgentControlBar from "./AgentControlBar";
import AgentStatusBar from "./AgentStatusBar";
import { ProjectMenuCard } from "./project-menu/ProjectMenuCard";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
import { isGitHubErrorReponse } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import { RootState } from "#/store";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
@ -19,22 +18,21 @@ export function Controls({
showSecurityLock,
lastCommitData,
}: ControlsProps) {
const rootData = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const appData = useRouteLoaderData<typeof appClientLoader>("routes/_oh.app");
const { gitHubToken } = useAuth();
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
);
const projectMenuCardData = React.useMemo(
() =>
rootData?.user &&
!isGitHubErrorReponse(rootData.user) &&
appData?.repo &&
lastCommitData
selectedRepository && lastCommitData
? {
avatar: rootData.user.avatar_url,
repoName: appData.repo,
repoName: selectedRepository,
lastCommit: lastCommitData,
avatar: null, // TODO: fetch repo avatar
}
: null,
[rootData, appData, lastCommitData],
[selectedRepository, lastCommitData],
);
return (
@ -55,7 +53,7 @@ export function Controls({
</div>
<ProjectMenuCard
isConnectedToGitHub={!!rootData?.ghToken}
isConnectedToGitHub={!!gitHubToken}
githubData={projectMenuCardData}
/>
</div>

View File

@ -1,12 +1,6 @@
import React from "react";
import {
useFetcher,
useLoaderData,
useRouteLoaderData,
} from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import {
useWsClient,
@ -24,17 +18,18 @@ import {
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
import store, { RootState } from "#/store";
import { createChatMessage } from "#/services/chatService";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { isGitHubErrorReponse } from "#/api/github";
import OpenHands from "#/api/open-hands";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState";
import { getSettings } from "#/services/settings";
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useAuth } from "#/context/auth-context";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserPrefs } from "#/context/user-prefs-context";
interface ServerError {
error: boolean | string;
@ -48,41 +43,48 @@ const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
export function EventHandler({ children }: React.PropsWithChildren) {
const { setToken, gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const { events, status, send } = useWsClient();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const fetcher = useFetcher();
const dispatch = useDispatch();
const { files, importedProjectZip, initialQuery } = useSelector(
(state: RootState) => state.initalQuery,
);
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
const endSession = useEndSession();
// FIXME: Bad practice - should be handled with state
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
);
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const userId = React.useMemo(() => {
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [data?.user]);
const userSettings = getSettings();
}, [user]);
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token) {
fetcher.submit({ token: event.token as string }, { method: "post" });
if (event.token && typeof event.token === "string") {
setToken(event.token);
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
fetcher.submit({}, { method: "POST", action: "/end-session" });
endSession();
return;
}
@ -120,9 +122,9 @@ export function EventHandler({ children }: React.PropsWithChildren) {
if (status === WsClientProviderStatus.ACTIVE) {
let additionalInfo = "";
if (ghToken && repo) {
send(getCloneRepoCommand(ghToken, repo));
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
if (gitHubToken && selectedRepository) {
send(getCloneRepoCommand(gitHubToken, selectedRepository));
additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
@ -157,35 +159,35 @@ export function EventHandler({ children }: React.PropsWithChildren) {
}, [status]);
React.useEffect(() => {
if (runtimeActive && userId && ghToken) {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(ghToken));
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, ghToken, runtimeActive]);
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
(async () => {
if (runtimeActive && importedProjectZip) {
// upload files action
try {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
await OpenHands.uploadFiles([file]);
dispatch(setImportedProjectZip(null));
} catch (error) {
toast.error("Failed to upload project files.");
}
}
})();
if (runtimeActive && importedProjectZip) {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
}
}, [runtimeActive, importedProjectZip]);
React.useEffect(() => {
if (userSettings.LLM_API_KEY) {
if (settings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [userSettings.LLM_API_KEY]);
}, [settings.LLM_API_KEY]);
return children;
}

View File

@ -2,7 +2,7 @@ import React from "react";
import hotToast from "react-hot-toast";
import ModalButton from "./buttons/ModalButton";
import { Feedback } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
const FEEDBACK_VERSION = "1.0";
const VIEWER_PAGE = "https://www.all-hands.dev/share";
@ -13,8 +13,6 @@ interface FeedbackFormProps {
}
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
const [isSubmitting, setIsSubmitting] = React.useState(false);
const copiedToClipboardToast = () => {
hotToast("Password copied to clipboard", {
icon: "📋",
@ -53,10 +51,11 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
);
};
const { mutate: submitFeedback, isPending } = useSubmitFeedback();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const formData = new FormData(event.currentTarget);
setIsSubmitting(true);
const email = formData.get("email")?.toString() || "";
const permissions = (formData.get("permissions")?.toString() ||
@ -71,11 +70,17 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
token: "",
};
const response = await OpenHands.submitFeedback(feedback);
const { message, feedback_id, password } = response.body; // eslint-disable-line
const link = `${VIEWER_PAGE}?share_id=${feedback_id}`;
shareFeedbackToast(message, link, password);
setIsSubmitting(false);
submitFeedback(
{ feedback },
{
onSuccess: (data) => {
const { message, feedback_id, password } = data.body; // eslint-disable-line
const link = `${VIEWER_PAGE}?share_id=${feedback_id}`;
shareFeedbackToast(message, link, password);
onClose();
},
},
);
};
return (
@ -109,13 +114,13 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
<div className="flex gap-2">
<ModalButton
disabled={isSubmitting}
disabled={isPending}
type="submit"
text="Submit"
className="bg-[#4465DB] grow"
/>
<ModalButton
disabled={isSubmitting}
disabled={isPending}
text="Cancel"
onClick={onClose}
className="bg-[#737373] grow"

View File

@ -5,13 +5,11 @@ import {
IoIosRefresh,
IoIosCloudUpload,
} from "react-icons/io";
import { useRevalidator } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import { IoFileTray } from "react-icons/io5";
import { useTranslation } from "react-i18next";
import { twMerge } from "tailwind-merge";
import AgentState from "#/types/AgentState";
import { setRefreshID } from "#/state/codeSlice";
import { addAssistantMessage } from "#/state/chatSlice";
import IconButton from "../IconButton";
import ExplorerTree from "./ExplorerTree";
@ -19,9 +17,10 @@ import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import OpenHands from "#/api/open-hands";
import { useFiles } from "#/context/files";
import { isOpenHandsErrorResponse } from "#/api/open-hands.utils";
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
import { useListFiles } from "#/hooks/query/use-list-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
interface ExplorerActionsProps {
onRefresh: () => void;
@ -95,13 +94,9 @@ function ExplorerActions({
interface FileExplorerProps {
isOpen: boolean;
onToggle: () => void;
error: string | null;
}
function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
const { revalidate } = useRevalidator();
const { paths, setPaths } = useFiles();
function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const [isDragging, setIsDragging] = React.useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
@ -112,62 +107,57 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
fileInputRef.current?.click(); // Trigger the file browser
};
const refreshWorkspace = () => {
if (
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.STOPPED
) {
return;
}
dispatch(setRefreshID(Math.random()));
OpenHands.getFiles().then(setPaths);
revalidate();
};
const { data: paths, refetch, error } = useListFiles();
const uploadFileData = async (files: FileList) => {
try {
const result = await OpenHands.uploadFiles(Array.from(files));
const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
const uploadedCount = data.uploaded_files.length;
const skippedCount = data.skipped_files.length;
if (isOpenHandsErrorResponse(result)) {
// Handle error response
toast.error(
`upload-error-${new Date().getTime()}`,
result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
);
return;
}
const uploadedCount = result.uploaded_files.length;
const skippedCount = result.skipped_files.length;
if (uploadedCount > 0) {
toast.success(
`upload-success-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
count: uploadedCount,
}),
);
}
if (skippedCount > 0) {
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
count: skippedCount,
});
toast.info(message);
}
if (uploadedCount === 0 && skippedCount === 0) {
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
}
refreshWorkspace();
} catch (e) {
// Handle unexpected errors (network issues, etc.)
toast.error(
`upload-error-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
if (uploadedCount > 0) {
toast.success(
`upload-success-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
count: uploadedCount,
}),
);
}
if (skippedCount > 0) {
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
count: skippedCount,
});
toast.info(message);
}
if (uploadedCount === 0 && skippedCount === 0) {
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
}
};
const handleUploadError = (e: Error) => {
toast.error(
`upload-error-${new Date().getTime()}`,
e.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
);
};
const { mutate: uploadFiles } = useUploadFiles();
const refreshWorkspace = () => {
if (
curAgentState !== AgentState.LOADING &&
curAgentState !== AgentState.STOPPED
) {
refetch();
}
};
const uploadFileData = (files: FileList) => {
uploadFiles(
{ files: Array.from(files) },
{ onSuccess: handleUploadSuccess, onError: handleUploadError },
);
refreshWorkspace();
};
const handleVSCodeClick = async (e: React.MouseEvent) => {
@ -265,13 +255,13 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
{!error && (
<div className="overflow-auto flex-grow min-h-0">
<div style={{ display: !isOpen ? "none" : "block" }}>
<ExplorerTree files={paths} />
<ExplorerTree files={paths || []} />
</div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-300 text-sm">{error}</p>
<p className="text-neutral-300 text-sm">{error.message}</p>
</div>
)}
{isOpen && (

View File

@ -1,12 +1,10 @@
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";
import OpenHands from "#/api/open-hands";
import { useFiles } from "#/context/files";
import { cn } from "#/utils/utils";
import { useListFiles } from "#/hooks/query/use-list-files";
import { useListFile } from "#/hooks/query/use-list-file";
interface TitleProps {
name: string;
@ -44,50 +42,34 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
selectedPath,
} = useFiles();
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const [children, setChildren] = React.useState<string[] | null>(null);
const refreshID = useSelector((state: RootState) => state.code.refreshID);
const isDirectory = path.endsWith("/");
const { data: paths } = useListFiles({
path,
enabled: isDirectory && isOpen,
});
const { data: fileContent, refetch } = useListFile({ path });
React.useEffect(() => {
if (fileContent) {
const code = modifiedFiles[path] || files[path];
if (!code || fileContent !== files[path]) {
setFileContent(path, fileContent);
}
}
}, [fileContent, path]);
const fileParts = path.split("/");
const filename =
fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2];
const isDirectory = path.endsWith("/");
const refreshChildren = async () => {
if (!isDirectory || !isOpen) {
setChildren(null);
return;
}
try {
const newChildren = await OpenHands.getFiles(path);
setChildren(newChildren);
} catch (error) {
toast.error("Failed to fetch files");
}
};
React.useEffect(() => {
(async () => {
await refreshChildren();
})();
}, [refreshID, isOpen]);
const handleClick = async () => {
if (isDirectory) {
setIsOpen((prev) => !prev);
} else {
const code = modifiedFiles[path] || files[path];
try {
const fetchedCode = await OpenHands.getFile(path);
setSelectedPath(path);
if (!code || fetchedCode !== files[path]) {
setFileContent(path, fetchedCode);
}
} catch (error) {
toast.error("Failed to fetch file");
}
if (isDirectory) setIsOpen((prev) => !prev);
else {
setSelectedPath(path);
await refetch();
}
};
@ -116,9 +98,9 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
)}
</button>
{isOpen && children && (
{isOpen && paths && (
<div className="ml-5">
{children.map((child, index) => (
{paths.map((child, index) => (
<TreeNode key={index} path={child} />
))}
</div>

View File

@ -4,19 +4,26 @@ import {
Input,
Switch,
} from "@nextui-org/react";
import { useFetcher, useLocation, useNavigate } from "@remix-run/react";
import { useLocation } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import React from "react";
import posthog from "posthog-js";
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
import { Settings } from "#/services/settings";
import { getDefaultSettings, Settings } from "#/services/settings";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { clientAction } from "#/routes/settings";
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
import ModalButton from "../buttons/ModalButton";
import { DangerModal } from "../modals/confirmation-modals/danger-modal";
import { I18nKey } from "#/i18n/declaration";
import {
extractSettings,
saveSettingsView,
updateSettingsVersion,
} from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserPrefs } from "#/context/user-prefs-context";
interface SettingsFormProps {
disabled?: boolean;
@ -35,19 +42,36 @@ export function SettingsForm({
securityAnalyzers,
onClose,
}: SettingsFormProps) {
const { saveSettings } = useUserPrefs();
const endSession = useEndSession();
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
const fetcher = useFetcher<typeof clientAction>();
const formRef = React.useRef<HTMLFormElement>(null);
React.useEffect(() => {
if (fetcher.data?.success) {
navigate("/");
const resetOngoingSession = () => {
if (location.pathname.startsWith("/app")) {
endSession();
onClose();
}
}, [fetcher.data, navigate, onClose]);
};
const handleFormSubmission = (formData: FormData) => {
const keys = Array.from(formData.keys());
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
const newSettings = extractSettings(formData);
saveSettings(newSettings);
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
updateSettingsVersion();
resetOngoingSession();
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
});
};
const advancedAlreadyInUse = React.useMemo(() => {
if (models.length > 0) {
@ -83,20 +107,17 @@ export function SettingsForm({
React.useState(false);
const [showWarningModal, setShowWarningModal] = React.useState(false);
const submitForm = (formData: FormData) => {
if (location.pathname === "/app") formData.set("end-session", "true");
fetcher.submit(formData, { method: "POST", action: "/settings" });
};
const handleConfirmResetSettings = () => {
const formData = new FormData(formRef.current ?? undefined);
formData.set("intent", "reset");
submitForm(formData);
saveSettings(getDefaultSettings());
resetOngoingSession();
posthog.capture("settings_reset");
onClose();
};
const handleConfirmEndSession = () => {
const formData = new FormData(formRef.current ?? undefined);
submitForm(formData);
handleFormSubmission(formData);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
@ -106,10 +127,11 @@ export function SettingsForm({
if (!apiKey) {
setShowWarningModal(true);
} else if (location.pathname === "/app") {
} else if (location.pathname.startsWith("/app")) {
setConfirmEndSessionModalOpen(true);
} else {
submitForm(formData);
handleFormSubmission(formData);
onClose();
}
};
@ -117,18 +139,15 @@ export function SettingsForm({
const formData = new FormData(formRef.current ?? undefined);
const apiKey = formData.get("api-key");
if (!apiKey) {
setShowWarningModal(true);
} else {
onClose();
}
if (!apiKey) setShowWarningModal(true);
else onClose();
};
const handleWarningConfirm = () => {
setShowWarningModal(false);
const formData = new FormData(formRef.current ?? undefined);
formData.set("api-key", ""); // Set null value for API key
submitForm(formData);
handleFormSubmission(formData);
onClose();
};
@ -138,11 +157,9 @@ export function SettingsForm({
return (
<div>
<fetcher.Form
<form
ref={formRef}
data-testid="settings-form"
method="POST"
action="/settings"
className="flex flex-col gap-6"
onSubmit={handleSubmit}
>
@ -267,9 +284,7 @@ export function SettingsForm({
aria-label="Agent"
data-testid="agent-input"
name="agent"
defaultSelectedKey={
fetcher.formData?.get("agent")?.toString() ?? settings.AGENT
}
defaultSelectedKey={settings.AGENT}
isClearable={false}
inputProps={{
classNames: {
@ -302,10 +317,7 @@ export function SettingsForm({
id="security-analyzer"
name="security-analyzer"
aria-label="Security Analyzer"
defaultSelectedKey={
fetcher.formData?.get("security-analyzer")?.toString() ??
settings.SECURITY_ANALYZER
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
inputProps={{
classNames: {
inputWrapper:
@ -346,7 +358,7 @@ export function SettingsForm({
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<ModalButton
disabled={disabled || fetcher.state === "submitting"}
disabled={disabled}
type="submit"
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
className="bg-[#4465DB] w-full"
@ -367,7 +379,7 @@ export function SettingsForm({
}}
/>
</div>
</fetcher.Form>
</form>
{confirmResetDefaultsModalOpen && (
<ModalBackdrop>

View File

@ -1,8 +1,5 @@
import React from "react";
import {
isGitHubErrorReponse,
retrieveAllGitHubUserRepositories,
} from "#/api/github";
import { isGitHubErrorReponse } from "#/api/github";
import { SuggestionBox } from "#/routes/_oh._index/suggestion-box";
import { ConnectToGitHubModal } from "./modals/connect-to-github-modal";
import { ModalBackdrop } from "./modals/modal-backdrop";
@ -12,9 +9,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
repositories: Awaited<
ReturnType<typeof retrieveAllGitHubUserRepositories>
> | null;
repositories: GitHubRepository[];
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitHubUser | null;
}
@ -57,7 +52,7 @@ export function GitHubRepositoriesSuggestionBox({
isLoggedIn ? (
<GitHubRepositorySelector
onSelect={handleSubmit}
repositories={repositories || []}
repositories={repositories}
/>
) : (
<ModalButton

View File

@ -1,4 +1,3 @@
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import {
@ -9,11 +8,11 @@ import ModalBody from "./ModalBody";
import ModalButton from "../buttons/ModalButton";
import FormFieldset from "../form/FormFieldset";
import { CustomInput } from "../form/custom-input";
import { clientLoader } from "#/routes/_oh";
import { clientAction as settingsClientAction } from "#/routes/settings";
import { clientAction as loginClientAction } from "#/routes/login";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
interface AccountSettingsModalProps {
onClose: () => void;
@ -28,41 +27,33 @@ function AccountSettingsModal({
gitHubError,
analyticsConsent,
}: AccountSettingsModalProps) {
const { gitHubToken, setGitHubToken, logout } = useAuth();
const { saveSettings } = useUserPrefs();
const { t } = useTranslation();
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
const settingsFetcher = useFetcher<typeof settingsClientAction>({
key: "settings",
});
const loginFetcher = useFetcher<typeof loginClientAction>({ key: "login" });
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const language = formData.get("language")?.toString();
const ghToken = formData.get("ghToken")?.toString();
const language = formData.get("language")?.toString();
const analytics = formData.get("analytics")?.toString() === "on";
const accountForm = new FormData();
const loginForm = new FormData();
if (ghToken) setGitHubToken(ghToken);
accountForm.append("intent", "account");
// The form returns the language label, so we need to find the corresponding
// language key to save it in the settings
if (language) {
const languageKey = AvailableLanguages.find(
({ label }) => label === language,
)?.value;
accountForm.append("language", languageKey ?? "en");
}
if (ghToken) loginForm.append("ghToken", ghToken);
accountForm.append("analytics", analytics.toString());
settingsFetcher.submit(accountForm, {
method: "POST",
action: "/settings",
});
loginFetcher.submit(loginForm, {
method: "POST",
action: "/login",
});
if (languageKey) saveSettings({ LANGUAGE: languageKey });
}
handleCaptureConsent(analytics);
const ANALYTICS = analytics.toString();
localStorage.setItem("analytics-consent", ANALYTICS);
onClose();
};
@ -88,7 +79,7 @@ function AccountSettingsModal({
name="ghToken"
label="GitHub Token"
type="password"
defaultValue={data?.ghToken ?? ""}
defaultValue={gitHubToken ?? ""}
/>
<BaseModalDescription>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
@ -106,15 +97,12 @@ function AccountSettingsModal({
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
</p>
)}
{data?.ghToken && !gitHubError && (
{gitHubToken && !gitHubError && (
<ModalButton
variant="text-like"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
onClick={() => {
settingsFetcher.submit(
{},
{ method: "POST", action: "/logout" },
);
logout();
onClose();
}}
className="text-danger self-start"
@ -133,10 +121,6 @@ function AccountSettingsModal({
<div className="flex flex-col gap-2 w-full">
<ModalButton
disabled={
settingsFetcher.state === "submitting" ||
loginFetcher.state === "submitting"
}
type="submit"
intent="account"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}

View File

@ -1,54 +0,0 @@
import { Form, useNavigation } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import {
BaseModalDescription,
BaseModalTitle,
} from "./confirmation-modals/BaseModal";
import ModalButton from "../buttons/ModalButton";
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
import ModalBody from "./ModalBody";
import { CustomInput } from "../form/custom-input";
import { I18nKey } from "#/i18n/declaration";
function ConnectToGitHubByTokenModal() {
const navigation = useNavigation();
const { t } = useTranslation();
return (
<ModalBody testID="auth-modal">
<div className="flex flex-col gap-2">
<AllHandsLogo width={69} height={46} className="self-center" />
<BaseModalTitle title="Ready to experience the future?" />
<BaseModalDescription description="Connect All Hands to your GitHub account to start building." />
</div>
<Form className="w-full flex flex-col gap-6" method="post" action="/">
<CustomInput label="GitHub Token" name="token" />
<label htmlFor="tos" className="flex gap-2">
<input
data-testid="accept-terms"
id="tos"
name="tos"
type="checkbox"
required
/>
<p className="text-xs text-[#A3A3A3]">
{t(
I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE,
)}{" "}
<span className="text-hyperlink">
{t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE)}
</span>
.
</p>
</label>
<ModalButton
type="submit"
text={t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE)}
className="bg-[#791B80] w-full"
disabled={navigation.state === "loading"}
/>
</Form>
</ModalBody>
);
}
export default ConnectToGitHubByTokenModal;

View File

@ -1,4 +1,3 @@
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import ModalBody from "./ModalBody";
import { CustomInput } from "../form/custom-input";
@ -7,19 +6,26 @@ import {
BaseModalDescription,
BaseModalTitle,
} from "./confirmation-modals/BaseModal";
import { clientLoader } from "#/routes/_oh";
import { clientAction } from "#/routes/login";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
interface ConnectToGitHubModalProps {
onClose: () => void;
}
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
const { gitHubToken, setGitHubToken } = useAuth();
const { t } = useTranslation();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const ghToken = formData.get("ghToken")?.toString();
if (ghToken) setGitHubToken(ghToken);
onClose();
};
return (
<ModalBody>
<div className="flex flex-col gap-2 self-start">
@ -40,18 +46,13 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
}
/>
</div>
<fetcher.Form
method="POST"
action="/login"
className="w-full flex flex-col gap-6"
onSubmit={onClose}
>
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-6">
<CustomInput
label="GitHub Token"
name="ghToken"
required
type="password"
defaultValue={data?.ghToken ?? ""}
defaultValue={gitHubToken ?? ""}
/>
<div className="flex flex-col gap-2 w-full">
@ -59,7 +60,6 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
testId="connect-to-github"
type="submit"
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
disabled={fetcher.state === "submitting"}
className="bg-[#791B80] w-full"
/>
<ModalButton
@ -68,7 +68,7 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
className="bg-[#737373] w-full"
/>
</div>
</fetcher.Form>
</form>
</ModalBody>
);
}

View File

@ -17,7 +17,7 @@ import { useWsClient } from "#/context/ws-client-provider";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
githubData: {
avatar: string;
avatar: string | null;
repoName: string;
lastCommit: GitHubCommit;
} | null;

View File

@ -5,7 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsProps {
repoName: string;
avatar: string;
avatar: string | null;
lastCommit: GitHubCommit;
}
@ -23,7 +23,7 @@ export function ProjectMenuDetails({
rel="noreferrer noopener"
className="flex items-center gap-2"
>
<img src={avatar} alt="" className="w-4 h-4 rounded-full" />
{avatar && <img src={avatar} alt="" className="w-4 h-4 rounded-full" />}
<span className="text-sm leading-6 font-semibold">{repoName}</span>
<ExternalLinkIcon width={16} height={16} />
</a>

View File

@ -1,5 +1,4 @@
import React from "react";
import { useFetcher } from "@remix-run/react";
import { AccountSettingsContextMenu } from "./context-menu/account-settings-context-menu";
import { UserAvatar } from "./user-avatar";
@ -14,8 +13,6 @@ export function UserActions({
onLogout,
user,
}: UserActionsProps) {
const loginFetcher = useFetcher({ key: "login" });
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
React.useState(false);
@ -39,11 +36,7 @@ export function UserActions({
return (
<div data-testid="user-actions" className="w-8 h-8 relative">
<UserAvatar
isLoading={loginFetcher.state !== "idle"}
avatarUrl={user?.avatar_url}
onClick={toggleAccountMenu}
/>
<UserAvatar avatarUrl={user?.avatar_url} onClick={toggleAccountMenu} />
{accountContextMenuIsVisible && (
<AccountSettingsContextMenu

View File

@ -0,0 +1,82 @@
import posthog from "posthog-js";
import React from "react";
interface AuthContextType {
token: string | null;
gitHubToken: string | null;
setToken: (token: string | null) => void;
setGitHubToken: (token: string | null) => void;
clearToken: () => void;
clearGitHubToken: () => void;
logout: () => void;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children }: React.PropsWithChildren) {
const [tokenState, setTokenState] = React.useState<string | null>(() =>
localStorage.getItem("token"),
);
const [gitHubTokenState, setGitHubTokenState] = React.useState<string | null>(
() => localStorage.getItem("ghToken"),
);
React.useLayoutEffect(() => {
setTokenState(localStorage.getItem("token"));
setGitHubTokenState(localStorage.getItem("ghToken"));
});
const setToken = (token: string | null) => {
setTokenState(token);
if (token) localStorage.setItem("token", token);
else localStorage.removeItem("token");
};
const setGitHubToken = (token: string | null) => {
setGitHubTokenState(token);
if (token) localStorage.setItem("ghToken", token);
else localStorage.removeItem("ghToken");
};
const clearToken = () => {
setTokenState(null);
localStorage.removeItem("token");
};
const clearGitHubToken = () => {
setGitHubTokenState(null);
localStorage.removeItem("ghToken");
};
const logout = () => {
clearGitHubToken();
posthog.reset();
};
const value = React.useMemo(
() => ({
token: tokenState,
gitHubToken: gitHubTokenState,
setToken,
setGitHubToken,
clearToken,
clearGitHubToken,
logout,
}),
[tokenState, gitHubTokenState],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
function useAuth() {
const context = React.useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within a AuthProvider");
}
return context;
}
export { AuthProvider, useAuth };

View File

@ -0,0 +1,55 @@
import React from "react";
import {
getSettings,
Settings,
saveSettings as updateAndSaveSettingsToLocalStorage,
settingsAreUpToDate as checkIfSettingsAreUpToDate,
} from "#/services/settings";
interface UserPrefsContextType {
settings: Settings;
settingsAreUpToDate: boolean;
saveSettings: (settings: Partial<Settings>) => void;
}
const UserPrefsContext = React.createContext<UserPrefsContextType | undefined>(
undefined,
);
function UserPrefsProvider({ children }: React.PropsWithChildren) {
const [settings, setSettings] = React.useState(getSettings());
const [settingsAreUpToDate, setSettingsAreUpToDate] = React.useState(
checkIfSettingsAreUpToDate(),
);
const saveSettings = (newSettings: Partial<Settings>) => {
updateAndSaveSettingsToLocalStorage(newSettings);
setSettings(getSettings());
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
};
const value = React.useMemo(
() => ({
settings,
settingsAreUpToDate,
saveSettings,
}),
[settings, settingsAreUpToDate],
);
return (
<UserPrefsContext.Provider value={value}>
{children}
</UserPrefsContext.Provider>
);
}
function useUserPrefs() {
const context = React.useContext(UserPrefsContext);
if (context === undefined) {
throw new Error("useUserPrefs must be used within a UserPrefsProvider");
}
return context;
}
export { UserPrefsProvider, useUserPrefs };

View File

@ -11,26 +11,23 @@ import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux";
import posthog from "posthog-js";
import "./i18n";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import store from "./store";
import OpenHands from "./api/open-hands";
import { useConfig } from "./hooks/query/use-config";
import { AuthProvider } from "./context/auth-context";
import { UserPrefsProvider } from "./context/user-prefs-context";
function PosthogInit() {
const [key, setKey] = React.useState<string | null>(null);
const { data: config } = useConfig();
React.useEffect(() => {
OpenHands.getConfig().then((config) => {
setKey(config.POSTHOG_CLIENT_KEY);
});
}, []);
React.useEffect(() => {
if (key) {
posthog.init(key, {
if (config?.POSTHOG_CLIENT_KEY) {
posthog.init(config.POSTHOG_CLIENT_KEY, {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
});
}
}, [key]);
}, [config]);
return null;
}
@ -48,14 +45,22 @@ async function prepareApp() {
}
}
const queryClient = new QueryClient();
prepareApp().then(() =>
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<Provider store={store}>
<RemixBrowser />
<PosthogInit />
<UserPrefsProvider>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RemixBrowser />
<PosthogInit />
</QueryClientProvider>
</AuthProvider>
</UserPrefsProvider>
</Provider>
</StrictMode>,
);

View File

@ -0,0 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
type SaveFileArgs = {
path: string;
content: string;
};
export const useSaveFile = () => {
const { token } = useAuth();
return useMutation({
mutationFn: ({ path, content }: SaveFileArgs) =>
OpenHands.saveFile(token || "", path, content),
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { Feedback } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
type SubmitFeedbackArgs = {
feedback: Feedback;
};
export const useSubmitFeedback = () => {
const { token } = useAuth();
return useMutation({
mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
OpenHands.submitFeedback(token || "", feedback),
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,16 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
type UploadFilesArgs = {
files: File[];
};
export const useUploadFiles = () => {
const { token } = useAuth();
return useMutation({
mutationFn: ({ files }: UploadFilesArgs) =>
OpenHands.uploadFiles(token || "", files),
});
};

View File

@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
const fetchAiConfigOptions = async () => ({
models: await OpenHands.getModels(),
agents: await OpenHands.getAgents(),
securityAnalyzers: await OpenHands.getSecurityAnalyzers(),
});
export const useAIConfigOptions = () =>
useQuery({
queryKey: ["ai-config-options"],
queryFn: fetchAiConfigOptions,
});

View File

@ -0,0 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useConfig = () =>
useQuery({
queryKey: ["config"],
queryFn: OpenHands.getConfig,
});

View File

@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { retrieveGitHubUser, isGitHubErrorReponse } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "./use-config";
export const useGitHubUser = () => {
const { gitHubToken } = useAuth();
const { data: config } = useConfig();
const user = useQuery({
queryKey: ["user", gitHubToken],
queryFn: async () => {
const data = await retrieveGitHubUser(gitHubToken!);
if (isGitHubErrorReponse(data)) {
throw new Error("Failed to retrieve user data");
}
return data;
},
enabled: !!gitHubToken && !!config?.APP_MODE,
retry: false,
});
React.useEffect(() => {
if (user.data) {
posthog.identify(user.data.login, {
company: user.data.company,
name: user.data.name,
email: user.data.email,
user: user.data.login,
mode: config?.APP_MODE || "oss",
});
}
}, [user.data]);
return user;
};

View File

@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import OpenHands from "#/api/open-hands";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useIsAuthed = () => {
const { gitHubToken } = useAuth();
const { data: config } = useConfig();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
return useQuery({
queryKey: ["user", "authenticated", gitHubToken, appMode],
queryFn: () => OpenHands.authenticate(gitHubToken || "", appMode!),
enabled: !!appMode,
staleTime: 1000 * 60 * 5, // 5 minutes
});
};

View File

@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { retrieveLatestGitHubCommit, isGitHubErrorReponse } from "#/api/github";
import { useAuth } from "#/context/auth-context";
interface UseLatestRepoCommitConfig {
repository: string | null;
}
export const useLatestRepoCommit = (config: UseLatestRepoCommitConfig) => {
const { gitHubToken } = useAuth();
return useQuery({
queryKey: ["latest_commit", gitHubToken, config.repository],
queryFn: async () => {
const data = await retrieveLatestGitHubCommit(
gitHubToken!,
config.repository!,
);
if (isGitHubErrorReponse(data)) {
throw new Error("Failed to retrieve latest commit");
}
return data[0];
},
enabled: !!gitHubToken && !!config.repository,
});
};

View File

@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
interface UseListFileConfig {
path: string;
}
export const useListFile = (config: UseListFileConfig) => {
const { token } = useAuth();
return useQuery({
queryKey: ["file", token, config.path],
queryFn: () => OpenHands.getFile(token || "", config.path),
enabled: false, // don't fetch by default, trigger manually via `refetch`
});
};

View File

@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
interface UseListFilesConfig {
path?: string;
enabled?: boolean;
}
export const useListFiles = (config?: UseListFilesConfig) => {
const { token } = useAuth();
const { status } = useWsClient();
const isActive = status === WsClientProviderStatus.ACTIVE;
return useQuery({
queryKey: ["files", token, config?.path],
queryFn: () => OpenHands.getFiles(token!, config?.path),
enabled: isActive && config?.enabled && !!token,
});
};

View File

@ -0,0 +1,63 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import {
isGitHubErrorReponse,
retrieveGitHubUserRepositories,
} from "#/api/github";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { useAuth } from "#/context/auth-context";
interface UserRepositoriesQueryFnProps {
pageParam: number;
ghToken: string;
}
const userRepositoriesQueryFn = async ({
pageParam,
ghToken,
}: UserRepositoriesQueryFnProps) => {
const response = await retrieveGitHubUserRepositories(
ghToken,
pageParam,
100,
);
if (!response.ok) {
throw new Error("Failed to fetch repositories");
}
const data = (await response.json()) as GitHubRepository | GitHubErrorReponse;
if (isGitHubErrorReponse(data)) {
throw new Error(data.message);
}
const link = response.headers.get("link") ?? "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
};
export const useUserRepositories = () => {
const { gitHubToken } = useAuth();
const repos = useInfiniteQuery({
queryKey: ["repositories", gitHubToken],
queryFn: async ({ pageParam }) =>
userRepositoriesQueryFn({ pageParam, ghToken: gitHubToken! }),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: !!gitHubToken,
});
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached
// (nextui autocomplete doesn't support onEndReached nor is it compatible for extending)
const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage } = repos;
React.useEffect(() => {
if (!isFetchingNextPage && isSuccess && hasNextPage) {
fetchNextPage();
}
}, [isFetchingNextPage, isSuccess, hasNextPage, fetchNextPage]);
return repos;
};

View File

@ -0,0 +1,31 @@
import { useDispatch } from "react-redux";
import { useNavigate } from "@remix-run/react";
import { useAuth } from "#/context/auth-context";
import {
initialState as browserInitialState,
setScreenshotSrc,
setUrl,
} from "#/state/browserSlice";
import { clearSelectedRepository } from "#/state/initial-query-slice";
export const useEndSession = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { clearToken } = useAuth();
/**
* End the current session by clearing the token and redirecting to the home page.
*/
const endSession = () => {
clearToken();
dispatch(clearSelectedRepository());
// Reset browser state to initial values
dispatch(setUrl(browserInitialState.url));
dispatch(setScreenshotSrc(browserInitialState.screenshotSrc));
navigate("/");
};
return endSession;
};

View File

@ -0,0 +1,20 @@
import React from "react";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
interface UseGitHubAuthUrlConfig {
gitHubToken: string | null;
appMode: GetConfigResponse["APP_MODE"] | null;
gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
}
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
React.useMemo(() => {
if (config.appMode === "saas" && !config.gitHubToken)
return generateGitHubAuthUrl(
config.gitHubClientId || "",
new URL(window.location.href),
);
return null;
}, [config.gitHubToken, config.appMode, config.gitHubClientId]);

View File

@ -1,82 +1,41 @@
import {
Await,
ClientActionFunctionArgs,
ClientLoaderFunctionArgs,
defer,
redirect,
useLoaderData,
useRouteLoaderData,
} from "@remix-run/react";
import { useLocation, useNavigate } from "@remix-run/react";
import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { SuggestionBox } from "./suggestion-box";
import { TaskForm } from "./task-form";
import { HeroHeading } from "./hero-heading";
import { retrieveAllGitHubUserRepositories } from "#/api/github";
import store from "#/store";
import {
setImportedProjectZip,
setInitialQuery,
} from "#/state/initial-query-slice";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import OpenHands from "#/api/open-hands";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box";
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let isSaas = false;
let githubClientId: string | null = null;
try {
const config = await OpenHands.getConfig();
isSaas = config.APP_MODE === "saas";
githubClientId = config.GITHUB_CLIENT_ID;
} catch (error) {
isSaas = false;
githubClientId = null;
}
const ghToken = localStorage.getItem("ghToken");
const token = localStorage.getItem("token");
if (token) return redirect("/app");
let repositories: ReturnType<
typeof retrieveAllGitHubUserRepositories
> | null = null;
if (ghToken) {
const data = retrieveAllGitHubUserRepositories(ghToken);
repositories = data;
}
let githubAuthUrl: string | null = null;
if (isSaas && githubClientId) {
const requestUrl = new URL(request.url);
githubAuthUrl = generateGitHubAuthUrl(githubClientId, requestUrl);
}
return defer({ repositories, githubAuthUrl });
};
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const formData = await request.formData();
const q = formData.get("q")?.toString();
if (q) store.dispatch(setInitialQuery(q));
posthog.capture("initial_query_submitted", {
query_character_length: q?.length,
});
return redirect("/app");
};
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { useAuth } from "#/context/auth-context";
function Home() {
const { token, gitHubToken } = useAuth();
const dispatch = useDispatch();
const rootData = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
const location = useLocation();
const navigate = useNavigate();
const formRef = React.useRef<HTMLFormElement>(null);
const { data: config } = useConfig();
const { data: user } = useGitHubUser();
const { data: repositories } = useUserRepositories();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config?.APP_MODE || null,
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
});
React.useEffect(() => {
if (token) navigate("/app");
}, [location.pathname]);
return (
<div
data-testid="root-index"
@ -88,25 +47,15 @@ function Home() {
<TaskForm ref={formRef} />
</div>
<div className="flex gap-4 w-full">
<React.Suspense
fallback={
<SuggestionBox
title="Open a Repo"
content="Loading repositories..."
/>
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
repositories={
repositories?.pages.flatMap((page) => page.data) || []
}
>
<Await resolve={repositories}>
{(resolvedRepositories) => (
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
repositories={resolvedRepositories}
gitHubAuthUrl={githubAuthUrl}
user={rootData?.user || null}
/>
)}
</Await>
</React.Suspense>
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
// onEndReached={}
/>
<SuggestionBox
title="+ Import Project"
content={

View File

@ -1,8 +1,13 @@
import React from "react";
import { Form, useNavigation } from "@remix-run/react";
import { useNavigate, useNavigation } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import posthog from "posthog-js";
import { RootState } from "#/store";
import { addFile, removeFile } from "#/state/initial-query-slice";
import {
addFile,
removeFile,
setInitialQuery,
} from "#/state/initial-query-slice";
import { SuggestionBubble } from "#/components/suggestion-bubble";
import { SUGGESTIONS } from "#/utils/suggestions";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
@ -16,6 +21,7 @@ import { cn } from "#/utils/utils";
export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
const dispatch = useDispatch();
const navigation = useNavigation();
const navigate = useNavigate();
const { selectedRepository, files } = useSelector(
(state: RootState) => state.initalQuery,
@ -51,13 +57,26 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
return "What do you want to build?";
}, [selectedRepository]);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
if (q) dispatch(setInitialQuery(q));
posthog.capture("initial_query_submitted", {
query_character_length: q?.length,
});
navigate("/app");
};
return (
<div className="flex flex-col gap-2 w-full">
<Form
<form
ref={ref}
method="post"
onSubmit={handleSubmit}
className="flex flex-col items-center gap-2"
replace
>
<SuggestionBubble
suggestion={suggestion}
@ -95,7 +114,7 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
disabled={navigation.state === "submitting"}
/>
</div>
</Form>
</form>
<UploadImageInput
onUpload={async (uploadedFiles) => {
const promises = uploadedFiles.map(convertImageToBase64);

View File

@ -2,10 +2,9 @@ import { Editor, EditorProps } from "@monaco-editor/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
import toast from "react-hot-toast";
import { I18nKey } from "#/i18n/declaration";
import { useFiles } from "#/context/files";
import OpenHands from "#/api/open-hands";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
interface CodeEditorComponentProps {
onMount: EditorProps["onMount"];
@ -25,6 +24,8 @@ function CodeEditorComponent({
saveFileContent: saveNewFileContent,
} = useFiles();
const { mutate: saveFile } = useSaveFile();
const handleEditorChange = (value: string | undefined) => {
if (selectedPath && value) modifyFileContent(selectedPath, value);
};
@ -39,11 +40,7 @@ function CodeEditorComponent({
const content = saveNewFileContent(selectedPath);
if (content) {
try {
await OpenHands.saveFile(selectedPath, content);
} catch (error) {
toast.error("Failed to save file");
}
saveFile({ path: selectedPath, content });
}
}
};
@ -66,34 +63,42 @@ function CodeEditorComponent({
);
}
const fileContent = modifiedFiles[selectedPath] || files[selectedPath];
const fileContent: string | undefined =
modifiedFiles[selectedPath] || files[selectedPath];
if (isBase64Image(fileContent)) {
return (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img src={fileContent} alt={selectedPath} className="object-contain" />
</section>
);
if (fileContent) {
if (isBase64Image(fileContent)) {
return (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img
src={fileContent}
alt={selectedPath}
className="object-contain"
/>
</section>
);
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
return (
<Editor
data-testid="code-editor"

View File

@ -1,16 +1,15 @@
import React from "react";
import { useSelector } from "react-redux";
import { json, useRouteError } from "@remix-run/react";
import toast from "react-hot-toast";
import { useRouteError } from "@remix-run/react";
import { editor } from "monaco-editor";
import { EditorProps } from "@monaco-editor/react";
import { RootState } from "#/store";
import AgentState from "#/types/AgentState";
import FileExplorer from "#/components/file-explorer/FileExplorer";
import OpenHands from "#/api/open-hands";
import CodeEditorComponent from "./code-editor-component";
import { useFiles } from "#/context/files";
import { EditorActions } from "#/components/editor-actions";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
const ASSET_FILE_TYPES = [
".png",
@ -24,11 +23,6 @@ const ASSET_FILE_TYPES = [
".ogg",
];
export const clientLoader = async () => {
const token = localStorage.getItem("token");
return json({ token });
};
export function ErrorBoundary() {
const error = useRouteError();
@ -41,17 +35,18 @@ export function ErrorBoundary() {
}
function CodeEditor() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const {
setPaths,
selectedPath,
modifiedFiles,
saveFileContent: saveNewFileContent,
discardChanges,
} = useFiles();
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
const editorRef = React.useRef<editor.IStandaloneCodeEditor | null>(null);
const { mutate: saveFile } = useSaveFile();
const toggleFileExplorer = () => {
setFileExplorerIsOpen((prev) => !prev);
editorRef.current?.layout({ width: 0, height: 0 });
@ -71,24 +66,10 @@ function CodeEditor() {
monaco.editor.setTheme("oh-dark");
};
const [errors, setErrors] = React.useState<{ getFiles: string | null }>({
getFiles: null,
});
const agentState = useSelector(
(state: RootState) => state.agent.curAgentState,
);
React.useEffect(() => {
if (curAgentState === AgentState.INIT) {
OpenHands.getFiles()
.then(setPaths)
.catch(() => {
setErrors({ getFiles: "Failed to retrieve files" });
});
}
}, [curAgentState]);
// Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules)
const isEditingAllowed = React.useMemo(
() =>
@ -102,12 +83,8 @@ function CodeEditor() {
if (selectedPath) {
const content = modifiedFiles[selectedPath];
if (content) {
try {
await OpenHands.saveFile(selectedPath, content);
saveNewFileContent(selectedPath);
} catch (error) {
toast.error("Failed to save file");
}
saveFile({ path: selectedPath, content });
saveNewFileContent(selectedPath);
}
}
};
@ -122,11 +99,7 @@ function CodeEditor() {
return (
<div className="flex h-full bg-neutral-900 relative">
<FileExplorer
isOpen={fileExplorerIsOpen}
onToggle={toggleFileExplorer}
error={errors.getFiles}
/>
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
<div className="w-full">
{selectedPath && !isAssetFileType && (
<div className="flex w-full items-center justify-between self-end p-2">

View File

@ -1,16 +1,10 @@
import { useDisclosure } from "@nextui-org/react";
import React from "react";
import {
Outlet,
useLoaderData,
json,
ClientActionFunctionArgs,
} from "@remix-run/react";
import { useDispatch } from "react-redux";
import { getSettings } from "#/services/settings";
import { Outlet } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import Security from "../components/modals/security/Security";
import { Controls } from "#/components/controls";
import store from "#/store";
import { RootState } from "#/store";
import { Container } from "#/components/container";
import { clearMessages } from "#/state/chatSlice";
import { clearTerminal } from "#/state/commandSlice";
@ -18,64 +12,32 @@ import { useEffectOnce } from "#/utils/use-effect-once";
import CodeIcon from "#/icons/code.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/icons/list-type-number.svg?react";
import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
import { clearJupyter } from "#/state/jupyterSlice";
import { FilesProvider } from "#/context/files";
import { ChatInterface } from "#/components/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "#/components/event-handler";
export const clientLoader = async () => {
const ghToken = localStorage.getItem("ghToken");
const repo =
store.getState().initalQuery.selectedRepository ||
localStorage.getItem("repo");
const settings = getSettings();
const token = localStorage.getItem("token");
if (repo) localStorage.setItem("repo", repo);
let lastCommit: GitHubCommit | null = null;
if (ghToken && repo) {
const data = await retrieveLatestGitHubCommit(ghToken, repo);
if (isGitHubErrorReponse(data)) {
// TODO: Handle error
console.error("Failed to retrieve latest commit", data);
} else {
[lastCommit] = data;
}
}
return json({
settings,
token,
ghToken,
repo,
lastCommit,
});
};
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const formData = await request.formData();
const token = formData.get("token")?.toString();
const ghToken = formData.get("ghToken")?.toString();
if (token) localStorage.setItem("token", token);
if (ghToken) localStorage.setItem("ghToken", ghToken);
return json(null);
};
import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
function App() {
const { token, gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const dispatch = useDispatch();
const { settings, token, ghToken, lastCommit } =
useLoaderData<typeof clientLoader>();
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
);
const { data: latestGitHubCommit } = useLatestRepoCommit({
repository: selectedRepository,
});
const secrets = React.useMemo(
() => [ghToken, token].filter((secret) => secret !== null),
[ghToken, token],
() => [gitHubToken, token].filter((secret) => secret !== null),
[gitHubToken, token],
);
const Terminal = React.useMemo(
@ -99,7 +61,7 @@ function App() {
<WsClientProvider
enabled
token={token}
ghToken={ghToken}
ghToken={gitHubToken}
settings={settings}
>
<EventHandler>
@ -141,7 +103,7 @@ function App() {
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings.SECURITY_ANALYZER}
lastCommitData={lastCommit}
lastCommitData={latestGitHubCommit || null}
/>
</div>
<Security

View File

@ -1,19 +1,11 @@
import React from "react";
import {
defer,
useRouteError,
isRouteErrorResponse,
useNavigation,
useLocation,
useLoaderData,
useFetcher,
Outlet,
ClientLoaderFunctionArgs,
} from "@remix-run/react";
import posthog from "posthog-js";
import { useDispatch } from "react-redux";
import { retrieveGitHubUser, isGitHubErrorReponse } from "#/api/github";
import OpenHands from "#/api/open-hands";
import CogTooth from "#/assets/cog-tooth";
import { SettingsForm } from "#/components/form/settings-form";
import AccountSettingsModal from "#/components/modals/AccountSettingsModal";
@ -22,78 +14,21 @@ import { LoadingSpinner } from "#/components/modals/LoadingProject";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { UserActions } from "#/components/user-actions";
import i18n from "#/i18n";
import { getSettings, settingsAreUpToDate } from "#/services/settings";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/icons/new-project.svg?react";
import DocsIcon from "#/icons/docs.svg?react";
import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { WaitlistModal } from "#/components/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/analytics-consent-form-modal";
import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState";
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
try {
const config = await OpenHands.getConfig();
window.__APP_MODE__ = config.APP_MODE;
window.__GITHUB_CLIENT_ID__ = config.GITHUB_CLIENT_ID;
} catch (error) {
window.__APP_MODE__ = "oss";
window.__GITHUB_CLIENT_ID__ = null;
}
let token = localStorage.getItem("token");
const ghToken = localStorage.getItem("ghToken");
const analyticsConsent = localStorage.getItem("analytics-consent");
const userConsents = analyticsConsent === "true";
if (!userConsents) {
posthog.opt_out_capturing();
} else if (userConsents && !posthog.has_opted_in_capturing()) {
posthog.opt_in_capturing();
}
let isAuthed = false;
let githubAuthUrl: string | null = null;
let user: GitHubUser | GitHubErrorReponse | null = null;
try {
isAuthed = await userIsAuthenticated();
if (!isAuthed && window.__GITHUB_CLIENT_ID__) {
const requestUrl = new URL(request.url);
githubAuthUrl = generateGitHubAuthUrl(
window.__GITHUB_CLIENT_ID__,
requestUrl,
);
}
} catch (error) {
isAuthed = false;
githubAuthUrl = null;
}
if (ghToken) user = await retrieveGitHubUser(ghToken);
const settings = getSettings();
await i18n.changeLanguage(settings.LANGUAGE);
const settingsIsUpdated = settingsAreUpToDate();
if (!settingsIsUpdated) {
localStorage.removeItem("token");
token = null;
}
// Store the results in cache
return defer({
token,
ghToken,
isAuthed,
githubAuthUrl,
user,
settingsIsUpdated,
settings,
analyticsConsent,
});
};
import { useConfig } from "#/hooks/query/use-config";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserPrefs } from "#/context/user-prefs-context";
export function ErrorBoundary() {
const error = useRouteError();
@ -127,107 +62,70 @@ export function ErrorBoundary() {
);
}
type SettingsFormData = {
models: string[];
agents: string[];
securityAnalyzers: string[];
};
export default function MainApp() {
const navigation = useNavigation();
const { token, gitHubToken, clearToken, logout } = useAuth();
const { settings, settingsAreUpToDate } = useUserPrefs();
const location = useLocation();
const {
token,
ghToken,
user,
isAuthed,
githubAuthUrl,
settingsIsUpdated,
settings,
analyticsConsent,
} = useLoaderData<typeof clientLoader>();
const logoutFetcher = useFetcher({ key: "logout" });
const endSessionFetcher = useFetcher({ key: "end-session" });
const dispatch = useDispatch();
const endSession = useEndSession();
// FIXME: Bad practice to use localStorage directly
const analyticsConsent = localStorage.getItem("analytics-consent");
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
const [settingsFormData, setSettingsFormData] =
React.useState<SettingsFormData>({
models: [],
agents: [],
securityAnalyzers: [],
});
const [settingsFormError, setSettingsFormError] = React.useState<
string | null
>(null);
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
);
const config = useConfig();
const user = useGitHubUser();
const {
data: isAuthed,
isFetched,
isFetching: isFetchingAuth,
} = useIsAuthed();
const aiConfigOptions = useAIConfigOptions();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config.data?.APP_MODE || null,
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
});
React.useEffect(() => {
if (user && !isGitHubErrorReponse(user)) {
posthog.identify(user.login, {
company: user.company,
name: user.name,
email: user.email,
user: user.login,
mode: window.__APP_MODE__ || "oss",
});
if (isFetched && !isAuthed) clearToken();
}, [isFetched, isAuthed]);
React.useEffect(() => {
if (settings.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
}
}, [user]);
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 () => {
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");
}
})();
}, []);
}, [settings.LANGUAGE]);
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
if (isGitHubErrorReponse(user)) {
if (user.isError) {
setAccountSettingsModalOpen(true);
}
}, [user]);
const handleUserLogout = () => {
logoutFetcher.submit(
{},
{
method: "POST",
action: "/logout",
},
);
};
}, [user.isError]);
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
// local storage
if (isGitHubErrorReponse(user)) handleUserLogout();
if (user.isError) logout();
setAccountSettingsModalOpen(false);
};
const handleEndSession = () => {
setStartNewProjectModalIsOpen(false);
dispatch(setCurrentAgentState(AgentState.LOADING));
// call new session action and redirect to '/'
endSessionFetcher.submit(new FormData(), {
method: "POST",
action: "/end-session",
});
endSession();
};
return (
@ -237,8 +135,8 @@ export default function MainApp() {
>
<aside className="px-1 flex flex-col gap-1">
<div className="w-[34px] h-[34px] flex items-center justify-center">
{navigation.state === "loading" && <LoadingSpinner size="small" />}
{navigation.state !== "loading" && (
{user.isLoading && <LoadingSpinner size="small" />}
{!user.isLoading && (
<button
type="button"
aria-label="All Hands Logo"
@ -253,12 +151,8 @@ export default function MainApp() {
</div>
<nav className="py-[18px] flex flex-col items-center gap-[18px]">
<UserActions
user={
user && !isGitHubErrorReponse(user)
? { avatar_url: user.avatar_url }
: undefined
}
onLogout={handleUserLogout}
user={user.data ? { avatar_url: user.data.avatar_url } : undefined}
onLogout={logout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
<button
@ -280,6 +174,7 @@ export default function MainApp() {
</a>
{!!token && (
<button
data-testid="new-project-button"
type="button"
aria-label="Start new project"
onClick={() => setStartNewProjectModalIsOpen(true)}
@ -293,11 +188,16 @@ export default function MainApp() {
<Outlet />
</div>
{isAuthed && (!settingsIsUpdated || settingsModalIsOpen) && (
{isAuthed && (!settingsAreUpToDate || 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>
<div
data-testid="ai-config-modal"
className="bg-root-primary w-[384px] p-6 rounded-xl flex flex-col gap-2"
>
{aiConfigOptions.error && (
<p className="text-danger text-xs">
{aiConfigOptions.error.message}
</p>
)}
<span className="text-xl leading-6 font-semibold -tracking-[0.01em">
AI Provider Configuration
@ -308,13 +208,22 @@ export default function MainApp() {
<p className="text-xs text-danger">
Changing settings during an active session will end the session
</p>
<SettingsForm
settings={settings}
models={settingsFormData.models}
agents={settingsFormData.agents}
securityAnalyzers={settingsFormData.securityAnalyzers}
onClose={() => setSettingsModalIsOpen(false)}
/>
{aiConfigOptions.isLoading && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{aiConfigOptions.data && (
<SettingsForm
settings={settings}
models={aiConfigOptions.data?.models}
agents={aiConfigOptions.data?.agents}
securityAnalyzers={aiConfigOptions.data?.securityAnalyzers}
onClose={() => {
setSettingsModalIsOpen(false);
}}
/>
)}
</div>
</ModalBackdrop>
)}
@ -323,7 +232,7 @@ export default function MainApp() {
<AccountSettingsModal
onClose={handleAccountSettingsModalClose}
selectedLanguage={settings.LANGUAGE}
gitHubError={isGitHubErrorReponse(user)}
gitHubError={user.isError}
analyticsConsent={analyticsConsent}
/>
</ModalBackdrop>
@ -346,10 +255,14 @@ export default function MainApp() {
/>
</ModalBackdrop>
)}
{!isAuthed && (
<WaitlistModal ghToken={ghToken} githubAuthUrl={githubAuthUrl} />
{!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas" && (
<WaitlistModal ghToken={gitHubToken} githubAuthUrl={gitHubAuthUrl} />
)}
{consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => setConsentFormIsOpen(false)}
/>
)}
{!analyticsConsent && <AnalyticsConsentFormModal />}
</div>
);
}

View File

@ -1,7 +0,0 @@
import { redirect } from "@remix-run/react";
import { clearSession } from "#/utils/clear-session";
export const clientAction = () => {
clearSession();
return redirect("/");
};

View File

@ -1,9 +0,0 @@
import { ClientActionFunctionArgs, json } from "@remix-run/react";
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const formData = await request.formData();
const githubToken = formData.get("ghToken")?.toString();
if (githubToken) localStorage.setItem("ghToken", githubToken);
return json({ success: true });
};

View File

@ -1,13 +0,0 @@
import { json } from "@remix-run/react";
import posthog from "posthog-js";
import { cache } from "#/utils/cache";
export const clientAction = () => {
const ghToken = localStorage.getItem("ghToken");
if (ghToken) localStorage.removeItem("ghToken");
cache.clearAll();
posthog.reset();
return json({ success: true });
};

View File

@ -1,34 +1,34 @@
import {
ClientLoaderFunctionArgs,
json,
redirect,
useLoaderData,
} from "@remix-run/react";
import { useNavigate, useSearchParams } from "@remix-run/react";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import OpenHands from "#/api/open-hands";
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
const url = new URL(request.url);
const code = url.searchParams.get("code");
if (code) {
const { access_token: accessToken } =
await OpenHands.getGitHubAccessToken(code);
localStorage.setItem("ghToken", accessToken);
return redirect("/");
}
return json({ error: "No code provided" }, { status: 400 });
};
import { useAuth } from "#/context/auth-context";
function OAuthGitHubCallback() {
const { error } = useLoaderData<typeof clientLoader>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setGitHubToken } = useAuth();
const code = searchParams.get("code");
const { data, isSuccess, error } = useQuery({
queryKey: ["access_token", code],
queryFn: () => OpenHands.getGitHubAccessToken(code!),
enabled: !!code,
});
React.useEffect(() => {
if (isSuccess) {
setGitHubToken(data.access_token);
navigate("/");
}
}, [isSuccess]);
if (error) {
return (
<div>
<h1>Error</h1>
<p>{error}</p>
<p>{error.message}</p>
</div>
);
}

View File

@ -1,9 +0,0 @@
import { ClientActionFunctionArgs, json } from "@remix-run/react";
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const formData = await request.formData();
const userConsents = formData.get("analytics") === "on";
localStorage.setItem("analytics-consent", userConsents.toString());
return json(null);
};

View File

@ -1,108 +0,0 @@
import { ClientActionFunctionArgs, json } from "@remix-run/react";
import posthog from "posthog-js";
import {
getDefaultSettings,
LATEST_SETTINGS_VERSION,
maybeMigrateSettings,
saveSettings,
Settings,
settingsAreUpToDate,
} from "#/services/settings";
const requestedToEndSession = (formData: FormData) =>
formData.get("end-session")?.toString() === "true";
const removeSessionTokenAndSelectedRepo = () => {
const token = localStorage.getItem("token");
const repo = localStorage.getItem("repo");
if (token) localStorage.removeItem("token");
if (repo) localStorage.removeItem("repo");
};
// This is the route for saving settings. It only exports the action function.
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const formData = await request.formData();
const intent = formData.get("intent")?.toString();
if (intent === "account") {
const LANGUAGE = formData.get("language")?.toString();
if (LANGUAGE) saveSettings({ LANGUAGE });
const ANALYTICS = formData.get("analytics")?.toString() ?? "false";
localStorage.setItem("analytics-consent", ANALYTICS);
return json({ success: true });
}
if (intent === "reset") {
saveSettings(getDefaultSettings());
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_reset");
return json({ success: true });
}
const keys = Array.from(formData.keys());
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
let customModel: string | undefined;
let baseUrl: string | undefined;
let confirmationMode = false;
let securityAnalyzer: string | undefined;
if (isUsingAdvancedOptions) {
customModel = formData.get("custom-model")?.toString();
baseUrl = formData.get("base-url")?.toString();
confirmationMode = keys.includes("confirmation-mode");
if (confirmationMode) {
// only set securityAnalyzer if confirmationMode is enabled
securityAnalyzer = formData.get("security-analyzer")?.toString();
}
}
const provider = formData.get("llm-provider")?.toString();
const model = formData.get("llm-model")?.toString();
const LLM_MODEL = customModel || `${provider}/${model}`.toLowerCase();
const LLM_API_KEY = formData.get("api-key")?.toString();
const AGENT = formData.get("agent")?.toString();
const LANGUAGE = formData.get("language")?.toString();
const LLM_BASE_URL = baseUrl;
const CONFIRMATION_MODE = confirmationMode;
const SECURITY_ANALYZER = securityAnalyzer;
const settings: Partial<Settings> = {
LLM_MODEL,
LLM_API_KEY,
AGENT,
LANGUAGE,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
};
saveSettings(settings);
// store for settings view
localStorage.setItem(
"use-advanced-options",
isUsingAdvancedOptions ? "true" : "false",
);
// If the settings version is different from the current version, update it.
if (!settingsAreUpToDate()) {
maybeMigrateSettings();
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),
);
}
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_saved", {
LLM_MODEL,
LLM_API_KEY: LLM_API_KEY ? "SET" : "UNSET",
});
return json({ success: true });
};

View File

@ -1,32 +1,5 @@
const TOKEN_KEY = "token";
const GITHUB_TOKEN_KEY = "ghToken";
const getToken = (): string => localStorage.getItem(TOKEN_KEY) ?? "";
const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
const getGitHubToken = (): string =>
localStorage.getItem(GITHUB_TOKEN_KEY) ?? "";
const setGitHubToken = (token: string): void => {
localStorage.setItem(GITHUB_TOKEN_KEY, token);
};
const clearGitHubToken = (): void => {
localStorage.removeItem(GITHUB_TOKEN_KEY);
};
export {
getToken,
setToken,
clearToken,
getGitHubToken,
setGitHubToken,
clearGitHubToken,
};
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const getGitHubToken = () => localStorage.getItem(GITHUB_TOKEN_KEY);

View File

@ -1,61 +0,0 @@
type CacheKey = string;
type CacheEntry<T> = {
data: T;
expiration: number;
};
class Cache {
private defaultTTL = 5 * 60 * 1000; // 5 minutes
private cacheMemory: Record<string, string> = {};
/**
* Retrieve the cached data from memory
* @param key The key to be retrieved from memory
* @returns The data stored in memory
*/
public get<T>(key: CacheKey): T | null {
const cachedEntry = this.cacheMemory[key];
if (cachedEntry) {
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
if (Date.now() < expiration) return data;
this.delete(key); // Remove expired cache
}
return null;
}
/**
* Store the data in memory with expiration
* @param key The key to be stored in memory
* @param data The data to be stored in memory
* @param ttl The time to live for the data in milliseconds
* @returns void
*/
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
const expiration = Date.now() + ttl;
const entry: CacheEntry<T> = { data, expiration };
this.cacheMemory[key] = JSON.stringify(entry);
}
/**
* Remove the data from memory
* @param key The key to be removed from memory
* @returns void
*/
public delete(key: CacheKey): void {
delete this.cacheMemory[key];
}
/**
* Clear all data
* @returns void
*/
public clearAll(): void {
Object.keys(this.cacheMemory).forEach((key) => {
delete this.cacheMemory[key];
});
}
}
export const cache = new Cache();

View File

@ -1,21 +0,0 @@
import store from "#/store";
import { initialState as browserInitialState } from "#/state/browserSlice";
/**
* Clear the session data from the local storage and reset relevant Redux state
*/
export const clearSession = () => {
// Clear local storage
localStorage.removeItem("token");
localStorage.removeItem("repo");
// Reset browser state to initial values
store.dispatch({
type: "browser/setUrl",
payload: browserInitialState.url,
});
store.dispatch({
type: "browser/setScreenshotSrc",
payload: browserInitialState.screenshotSrc,
});
};

View File

@ -0,0 +1,11 @@
/**
* Extracts the next page number from a GitHub API link header.
* @param link The GitHub API link header
* @returns The next page number or null if there is no next page
*/
export const extractNextPageFromLink = (link: string): number | null => {
const regex = /<[^>]*[?&]page=(\d+)(?:&[^>]*)?>; rel="next"/;
const match = link.match(regex);
return match ? parseInt(match[1], 10) : null;
};

View File

@ -0,0 +1,15 @@
import posthog from "posthog-js";
/**
* Handle user consent for tracking
* @param consent Whether the user consents to tracking
*/
export const handleCaptureConsent = (consent: boolean) => {
if (consent && !posthog.has_opted_in_capturing()) {
posthog.opt_in_capturing();
}
if (!consent && !posthog.has_opted_out_capturing()) {
posthog.opt_out_capturing();
}
};

View File

@ -0,0 +1,95 @@
import {
settingsAreUpToDate,
maybeMigrateSettings,
LATEST_SETTINGS_VERSION,
Settings,
} from "#/services/settings";
const extractBasicFormData = (formData: FormData) => {
const provider = formData.get("llm-provider")?.toString();
const model = formData.get("llm-model")?.toString();
const LLM_MODEL = `${provider}/${model}`.toLowerCase();
const LLM_API_KEY = formData.get("api-key")?.toString();
const AGENT = formData.get("agent")?.toString();
const LANGUAGE = formData.get("language")?.toString();
return {
LLM_MODEL,
LLM_API_KEY,
AGENT,
LANGUAGE,
};
};
const extractAdvancedFormData = (formData: FormData) => {
const keys = Array.from(formData.keys());
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
let CUSTOM_LLM_MODEL: string | undefined;
let LLM_BASE_URL: string | undefined;
let CONFIRMATION_MODE = false;
let SECURITY_ANALYZER: string | undefined;
if (isUsingAdvancedOptions) {
CUSTOM_LLM_MODEL = formData.get("custom-model")?.toString();
LLM_BASE_URL = formData.get("base-url")?.toString();
CONFIRMATION_MODE = keys.includes("confirmation-mode");
if (CONFIRMATION_MODE) {
// only set securityAnalyzer if confirmationMode is enabled
SECURITY_ANALYZER = formData.get("security-analyzer")?.toString();
}
}
return {
CUSTOM_LLM_MODEL,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
};
};
const extractSettings = (formData: FormData): Partial<Settings> => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
extractBasicFormData(formData);
const {
CUSTOM_LLM_MODEL,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
} = extractAdvancedFormData(formData);
return {
LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
LLM_API_KEY,
AGENT,
LANGUAGE,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
};
};
const saveSettingsView = (view: "basic" | "advanced") => {
localStorage.setItem(
"use-advanced-options",
view === "advanced" ? "true" : "false",
);
};
/**
* Updates the settings version in local storage if the current settings are not up to date.
* If the settings are outdated, it attempts to migrate them before updating the version.
*/
const updateSettingsVersion = () => {
if (!settingsAreUpToDate()) {
maybeMigrateSettings();
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),
);
}
};
export { extractSettings, saveSettingsView, updateSettingsVersion };

View File

@ -1,20 +0,0 @@
import OpenHands from "#/api/open-hands";
import { cache } from "./cache";
export const userIsAuthenticated = async () => {
if (window.__APP_MODE__ === "oss") return true;
const cachedData = cache.get<boolean>("user_is_authenticated");
if (cachedData) return cachedData;
let authenticated = false;
try {
await OpenHands.authenticate();
authenticated = true;
} catch (error) {
authenticated = false;
}
cache.set("user_is_authenticated", authenticated, 3 * 60 * 1000); // cache for 3 minutes
return authenticated;
};

View File

@ -5,8 +5,11 @@ import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppStore, RootState, rootReducer } from "./src/store";
import { WsClientProvider } from "#/context/ws-client-provider";
import { AuthProvider } from "#/context/auth-context";
import { UserPrefsProvider } from "#/context/user-prefs-context";
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
@ -35,7 +38,20 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
return (
<Provider store={store}>
<WsClientProvider enabled={true} token={null} ghToken={null} settings={null}>{children}</WsClientProvider>
<UserPrefsProvider>
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
<WsClientProvider
enabled
token={null}
ghToken={null}
settings={null}
>
{children}
</WsClientProvider>
</QueryClientProvider>
</AuthProvider>
</UserPrefsProvider>
</Provider>
);
}

View File

@ -85,3 +85,15 @@ test.fail(
expect(await userMessage.textContent()).toBe(testQuery);
},
);
test("redirect to /app if token is present", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("token", "test");
});
await page.waitForURL("/app");
expect(page.url()).toBe("http://localhost:3001/app");
});