mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): Utilize TanStack Query (#5096)
This commit is contained in:
parent
bb8b4a0b18
commit
becb17f0c8
@ -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"
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
13
frontend/__tests__/utils/extract-next-page-from-link.test.ts
Normal file
13
frontend/__tests__/utils/extract-next-page-from-link.test.ts
Normal 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);
|
||||
});
|
||||
44
frontend/__tests__/utils/handle-capture-consent.test.ts
Normal file
44
frontend/__tests__/utils/handle-capture-consent.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
139
frontend/package-lock.json
generated
139
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
82
frontend/src/context/auth-context.tsx
Normal file
82
frontend/src/context/auth-context.tsx
Normal 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 };
|
||||
55
frontend/src/context/user-prefs-context.tsx
Normal file
55
frontend/src/context/user-prefs-context.tsx
Normal 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 };
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
21
frontend/src/hooks/mutation/use-save-file.ts
Normal file
21
frontend/src/hooks/mutation/use-save-file.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
21
frontend/src/hooks/mutation/use-submit-feedback.ts
Normal file
21
frontend/src/hooks/mutation/use-submit-feedback.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
16
frontend/src/hooks/mutation/use-upload-files.ts
Normal file
16
frontend/src/hooks/mutation/use-upload-files.ts
Normal 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),
|
||||
});
|
||||
};
|
||||
14
frontend/src/hooks/query/use-ai-config-options.ts
Normal file
14
frontend/src/hooks/query/use-ai-config-options.ts
Normal 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,
|
||||
});
|
||||
8
frontend/src/hooks/query/use-config.ts
Normal file
8
frontend/src/hooks/query/use-config.ts
Normal 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,
|
||||
});
|
||||
40
frontend/src/hooks/query/use-github-user.ts
Normal file
40
frontend/src/hooks/query/use-github-user.ts
Normal 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;
|
||||
};
|
||||
19
frontend/src/hooks/query/use-is-authed.ts
Normal file
19
frontend/src/hooks/query/use-is-authed.ts
Normal 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
|
||||
});
|
||||
};
|
||||
28
frontend/src/hooks/query/use-latest-repo-commit.ts
Normal file
28
frontend/src/hooks/query/use-latest-repo-commit.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
17
frontend/src/hooks/query/use-list-file.ts
Normal file
17
frontend/src/hooks/query/use-list-file.ts
Normal 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`
|
||||
});
|
||||
};
|
||||
24
frontend/src/hooks/query/use-list-files.ts
Normal file
24
frontend/src/hooks/query/use-list-files.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
63
frontend/src/hooks/query/use-user-repositories.ts
Normal file
63
frontend/src/hooks/query/use-user-repositories.ts
Normal 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;
|
||||
};
|
||||
31
frontend/src/hooks/use-end-session.ts
Normal file
31
frontend/src/hooks/use-end-session.ts
Normal 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;
|
||||
};
|
||||
20
frontend/src/hooks/use-github-auth-url.ts
Normal file
20
frontend/src/hooks/use-github-auth-url.ts
Normal 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]);
|
||||
@ -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={
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import { redirect } from "@remix-run/react";
|
||||
import { clearSession } from "#/utils/clear-session";
|
||||
|
||||
export const clientAction = () => {
|
||||
clearSession();
|
||||
return redirect("/");
|
||||
};
|
||||
@ -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 });
|
||||
};
|
||||
@ -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 });
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -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 });
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
11
frontend/src/utils/extract-next-page-from-link.ts
Normal file
11
frontend/src/utils/extract-next-page-from-link.ts
Normal 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;
|
||||
};
|
||||
15
frontend/src/utils/handle-capture-consent.ts
Normal file
15
frontend/src/utils/handle-capture-consent.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
95
frontend/src/utils/settings-utils.ts
Normal file
95
frontend/src/utils/settings-utils.ts
Normal 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 };
|
||||
@ -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;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user