fix(frontend): remove SaaS and enterprise terminology (#88)

* fix: remove SaaS and enterprise terminology

* refactor: remove unrelated file
This commit is contained in:
Hiep Le
2026-05-04 21:27:45 +07:00
committed by GitHub
parent c27acde8a0
commit dd8a44231f
99 changed files with 359 additions and 3821 deletions
-1
View File
@@ -17,5 +17,4 @@ VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxie
# Mocking / test helpers
VITE_MOCK_API="false" # Enable/disable API mocking with MSW
VITE_MOCK_SAAS="false" # Simulate SaaS mode in development
# VITE_GITHUB_TOKEN="" # GitHub token for repository access (used in some tests)
+4 -4
View File
@@ -13,7 +13,7 @@
- `VITE_WORKER_URLS` as a comma-separated list of browser worker URLs if you want the Browser tab to probe exposed app hosts.
- `VITE_ENABLE_BROWSER_TOOLS=false` to omit `BrowserToolSet` from new conversation payloads.
- Default working-dir fallback is now the relative path `workspace/project` (exported as `DEFAULT_WORKING_DIR` from `src/api/agent-server-config.ts`); git-path heuristics and the default PLAN preview path should reuse that constant instead of hardcoding `/workspace/project`.
- The UI keeps most OpenHands routes/layout intact, but SaaS/org/billing/integration behavior is intentionally hidden or stubbed via the fabricated OSS config because there is no separate app backend.
- The UI keeps most OpenHands routes/layout intact, but hosted-only behavior (org, account management, integrations) has been removed via the fabricated OSS config because there is no separate app backend.
- Verification command: `npm run typecheck && npm run build`.
- GitHub automation now includes `.github/workflows/ci.yml` for `npm ci`, `npm test`, and `npm run build`, plus `.github/dependabot.yml` with weekly npm/github-actions updates gated by a 7-day cooldown.
- Direct `dependencies` and `devDependencies` in `package.json` are exact-pinned (no caret ranges); reproducible installs should use the committed `package-lock.json` plus `npm ci`, and targeted transitive fixes still belong in `overrides`.
@@ -27,10 +27,10 @@
- Shared TypeScript-client adapters live in `src/api/typescript-client.ts`; prefer those helpers for agent-server-backed REST/workspace/event/VS Code calls before falling back to `open-hands-axios`.
- Local verification/build gotchas:
- `npm run typecheck` assumes generated translation types exist; run `npm run make-i18n` first if `src/i18n/declaration.ts` is missing.
- Phase-1 OSS cleanup removed SaaS-only auth/org/billing/onboarding/payment/invitation codepaths, routes, and tests. Keep `integrations`, `git-settings`, `secrets`, MCP settings, and other local/self-hosted flows intact when simplifying OSS behavior.
- When merging main into the Phase-1 OSS cleanup branch, keep the new agent-server compatibility bootstrap in `src/root.tsx`, but do not reintroduce SaaS invitation cleanup or enterprise CTA chrome in the OSS user menu; the OSS account menu should just render settings links plus Docs.
- The OSS cleanup removed hosted-only auth, org, account, onboarding, and invitation codepaths, routes, and tests. Keep `integrations`, `git-settings`, `secrets`, MCP settings, and other local/self-hosted flows intact when simplifying OSS behavior.
- When merging main into this branch, keep the new agent-server compatibility bootstrap in `src/root.tsx`, but do not reintroduce hosted-only invitation cleanup or marketing CTA chrome in the OSS user menu; the OSS account menu should just render settings links plus Docs.
- During the Phase-1 OSS cleanup audit, the runtime SaaS removals held up, but route-level regression coverage for still-active OSS settings pages had been deleted too aggressively. Keep focused tests for local/self-hosted screens like `app-settings`, `llm-settings`, `git-settings`, `mcp-settings`, and `secrets-settings` even when stripping SaaS-only code.
- During the OSS cleanup audit, the runtime removals held up, but route-level regression coverage for still-active OSS settings pages had been deleted too aggressively. Keep focused tests for local/self-hosted screens like `app-settings`, `llm-settings`, `git-settings`, `mcp-settings`, and `secrets-settings` even when stripping hosted-only code.
- `npm run dev:mock` needs MSW handlers for the direct agent-server routes used by the adapted frontend, not the original OpenHands mock paths. Key routes that must stay covered are:
- bootstrap/model loading: `/server_info`, `/api/llm/models/verified`, `/api/llm/providers`
-3
View File
@@ -49,8 +49,6 @@ If you want to run the frontend without a live backend, use:
```sh
npm run dev:mock
# or
npm run dev:mock:saas
```
## Build and test
@@ -108,7 +106,6 @@ You can create a `.env` file in the project directory with these variables based
| `VITE_WORKER_URLS` | Optional comma-separated worker/app URLs for the Browser tab | - |
| `VITE_ENABLE_BROWSER_TOOLS` | Set to `false` to omit `BrowserToolSet` from new conversation payloads | `true` |
| `VITE_MOCK_API` | Enable/disable API mocking with MSW | `false` |
| `VITE_MOCK_SAAS` | Simulate SaaS mode in development | `false` |
| `VITE_USE_TLS` | Use HTTPS/WSS for the Vite proxy target | `false` |
| `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` |
| `VITE_INSECURE_SKIP_VERIFY` | Skip TLS certificate verification for proxied backend requests | `false` |
+2 -14
View File
@@ -13,7 +13,7 @@ The same mock handlers work in both environments, so you write them once and reu
## Relevant Files
- `src/mocks/handlers.ts` - Main handler registry that combines all domain handlers
- `src/mocks/*-handlers.ts` - Domain-specific handlers (auth, billing, conversation, etc.)
- `src/mocks/*-handlers.ts` - Domain-specific handlers (auth, conversation, etc.)
- `src/mocks/browser.ts` - Browser setup for development mode
- `src/mocks/node.ts` - Node.js setup for tests
- `vitest.setup.ts` - Global test setup with MSW lifecycle hooks
@@ -25,21 +25,9 @@ The same mock handlers work in both environments, so you write them once and reu
```sh
# Run with API mocking enabled
npm run dev:mock
# Run with API mocking + SaaS mode simulation
npm run dev:mock:saas
```
These commands set `VITE_MOCK_API=true` which activates the MSW Service Worker to intercept requests.
> [!NOTE]
> **OpenHands vs SaaS Mode**
>
> OpenHands runs in two modes:
> - **OpenHands mode**: For local/self-hosted deployments where users provide their own LLM API keys and configure git providers manually
> - **SaaS mode**: For the cloud offering with billing, managed API keys, and OAuth-based GitHub integration
>
> Use `dev:mock:saas` when working on SaaS-specific features like billing, API key management, or subscription flows.
This command sets `VITE_MOCK_API=true` which activates the MSW Service Worker to intercept requests.
## Writing Tests
+1 -4
View File
@@ -12,8 +12,6 @@ describe("OptionService", () => {
it("returns config in mock mode without a live backend", async () => {
const config = await OptionService.getConfig();
expect(config.app_mode).toBe("oss");
expect(config.feature_flags.deployment_mode).toBe("self_hosted");
expect(config.feature_flags.hide_integrations_page).toBe(false);
expect(config.updated_at).toBeTruthy();
});
@@ -60,9 +58,8 @@ describe("OptionService", () => {
);
await expect(OptionService.getConfig()).resolves.toMatchObject({
app_mode: "oss",
feature_flags: expect.objectContaining({
deployment_mode: "self_hosted",
hide_integrations_page: false,
}),
});
});
-1
View File
@@ -12,7 +12,6 @@ const buildProviders = (
bitbucket_data_center: { token: "", host: null },
azure_devops: { token: "", host: null },
forgejo: { token: "", host: null },
enterprise_sso: { token: "", host: null },
...overrides,
});
@@ -127,7 +127,7 @@ describe("ChatInterface - Chat Suggestions", () => {
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { app_mode: "local" },
data: {},
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
@@ -1,9 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
import OptionService from "#/api/option-service/option-service.api";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -111,36 +109,4 @@ describe("ExpandableMessage", () => {
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
});
it("should render the out of credits message when the user is out of credits", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - partial mock for testing
getConfigSpy.mockResolvedValue({
app_mode: "saas",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
});
const RouterStub = createRoutesStub([
{
Component: () => (
<ExpandableMessage
id="STATUS$ERROR_LLM_OUT_OF_CREDITS"
message=""
type=""
/>
),
path: "/",
},
]);
renderWithProviders(<RouterStub />);
await screen.findByTestId("out-of-credits");
});
});
@@ -124,7 +124,7 @@ describe("ChatInterface – message display continuity (spec 3.1)", () => {
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { app_mode: "local" },
data: {},
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
@@ -31,9 +31,7 @@ const {
},
})),
useConfigMock: vi.fn(() => ({
data: {
app_mode: "oss",
},
data: {},
})),
}));
@@ -579,288 +577,3 @@ describe("ConversationNameContextMenu", () => {
});
});
describe("ConversationNameContextMenu - Share Link Functionality", () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined);
const mockOnCopyShareLink = vi.fn();
const mockOnTogglePublic = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
onClose: mockOnClose,
onTogglePublic: mockOnTogglePublic,
onCopyShareLink: mockOnCopyShareLink,
shareUrl: "https://example.com/shared/conversations/test-id",
};
vi.mock("#/hooks/mutation/use-update-conversation-public-flag", () => ({
useUpdateConversationPublicFlag: () => ({
mutate: vi.fn(),
}),
}));
beforeAll(() => {
// Mock navigator.clipboard
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
readText: vi.fn(),
},
writable: true,
configurable: true,
});
});
beforeEach(() => {
mockWriteText.mockClear();
mockDisplaySuccessToast.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("should display copy and open buttons when conversation is public", () => {
// Arrange
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
app_mode: "saas",
},
});
// Act
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
// Assert
expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument();
expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument();
});
it("should not display share buttons when conversation is not public", () => {
// Arrange
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: false,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
app_mode: "saas",
},
});
// Act
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
// Assert
expect(
screen.queryByTestId("copy-share-link-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("open-share-link-button"),
).not.toBeInTheDocument();
});
it("should call copy handler when copy button is clicked", async () => {
// Arrange
const user = userEvent.setup();
const shareUrl = "https://example.com/shared/conversations/test-id";
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
app_mode: "saas",
},
});
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} shareUrl={shareUrl} />,
);
const copyButton = screen.getByTestId("copy-share-link-button");
// Act
await user.click(copyButton);
// Assert
expect(mockOnCopyShareLink).toHaveBeenCalledTimes(1);
});
it("should have correct attributes for open share link button", () => {
// Arrange
const shareUrl = "https://example.com/shared/conversations/test-id";
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
app_mode: "saas",
},
});
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} shareUrl={shareUrl} />,
);
const openButton = screen.getByTestId("open-share-link-button");
// Assert
expect(openButton).toHaveAttribute("href", shareUrl);
expect(openButton).toHaveAttribute("target", "_blank");
expect(openButton).toHaveAttribute("rel", "noopener noreferrer");
});
it("should display correct tooltips for share buttons", () => {
// Arrange
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
app_mode: "saas",
},
});
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
// Assert
const copyButton = screen.getByTestId("copy-share-link-button");
const openButton = screen.getByTestId("open-share-link-button");
expect(copyButton).toHaveAttribute("title", "Copy to Clipboard");
expect(openButton).toHaveAttribute("title", "Open in New Tab");
});
describe("Integration with ConversationName component", () => {
beforeEach(() => {
// Default mocks for public V1 conversation in SAAS mode
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
app_mode: "saas",
},
});
});
it("should copy share URL to clipboard and show success toast when copy button is clicked through ConversationName", async () => {
// Arrange
const user = userEvent.setup();
const expectedUrl =
"http://localhost:3000/shared/conversations/test-conversation-id";
// Ensure navigator.clipboard is properly mocked
if (!navigator.clipboard) {
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
readText: vi.fn(),
},
writable: true,
configurable: true,
});
} else {
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(
mockWriteText,
);
}
renderConversationNameWithRouter();
// Open context menu by clicking ellipsis
const ellipsisButton = screen.getByRole("button", { hidden: true });
await user.click(ellipsisButton);
// Wait for context menu to appear and find share publicly button
const sharePubliclyButton = await screen.findByTestId(
"share-publicly-button",
);
expect(sharePubliclyButton).toBeInTheDocument();
// Find copy button
const copyButton = screen.getByTestId("copy-share-link-button");
// Act
await user.click(copyButton);
// Assert - clipboard.writeText is async, so we need to wait
await waitFor(
() => {
expect(mockWriteText).toHaveBeenCalledWith(expectedUrl);
expect(mockDisplaySuccessToast).toHaveBeenCalledWith(
"Link copied to clipboard",
);
},
{ timeout: 2000, container: document.body },
);
});
it("should show both copy and open buttons when conversation is public", async () => {
// Arrange
const user = userEvent.setup();
renderConversationNameWithRouter();
// Act - open context menu
const ellipsisButton = screen.getByRole("button", { hidden: true });
await user.click(ellipsisButton);
// Wait for context menu
const sharePubliclyButton = await screen.findByTestId(
"share-publicly-button",
);
// Assert
expect(sharePubliclyButton).toBeInTheDocument();
expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument();
expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument();
});
});
});
@@ -1,159 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { HomepageCTA } from "#/components/features/home/homepage-cta";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"CTA$ENTERPRISE_TITLE": "Get OpenHands for Enterprise",
"CTA$ENTERPRISE_DESCRIPTION":
"Cloud allows you to access OpenHands anywhere and coordinate with your team like never before",
"CTA$LEARN_MORE": "Learn More",
};
return translations[key] || key;
},
i18n: { language: "en" },
}),
};
});
// Mock local storage
vi.mock("#/utils/local-storage", () => ({
setCTADismissed: vi.fn(),
}));
// Mock useTracking hook
const mockTrackSaasSelfhostedInquiry = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
}),
}));
import { setCTADismissed } from "#/utils/local-storage";
describe("HomepageCTA", () => {
const mockSetShouldShowCTA = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const renderHomepageCTA = () => {
return render(<HomepageCTA setShouldShowCTA={mockSetShouldShowCTA} />);
};
describe("rendering", () => {
it("renders the enterprise title", () => {
renderHomepageCTA();
expect(
screen.getByText("Get OpenHands for Enterprise"),
).toBeInTheDocument();
});
it("renders the enterprise description", () => {
renderHomepageCTA();
expect(
screen.getByText(/Cloud allows you to access OpenHands anywhere/),
).toBeInTheDocument();
});
it("renders the Learn More link", () => {
renderHomepageCTA();
const link = screen.getByRole("link", { name: "Learn More" });
expect(link).toBeInTheDocument();
});
it("renders the close button with correct aria-label", () => {
renderHomepageCTA();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
});
describe("close button behavior", () => {
it("calls setCTADismissed with 'homepage' when close button is clicked", async () => {
const user = userEvent.setup();
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(setCTADismissed).toHaveBeenCalledWith("homepage");
});
it("calls setShouldShowCTA with false when close button is clicked", async () => {
const user = userEvent.setup();
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(mockSetShouldShowCTA).toHaveBeenCalledWith(false);
});
it("calls both setCTADismissed and setShouldShowCTA in order", async () => {
const user = userEvent.setup();
const callOrder: string[] = [];
vi.mocked(setCTADismissed).mockImplementation(() => {
callOrder.push("setCTADismissed");
});
mockSetShouldShowCTA.mockImplementation(() => {
callOrder.push("setShouldShowCTA");
});
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(callOrder).toEqual(["setCTADismissed", "setShouldShowCTA"]);
});
});
describe("Learn More link behavior", () => {
it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => {
const user = userEvent.setup();
renderHomepageCTA();
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
await user.click(learnMoreLink);
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "home_page",
});
});
it("has correct href and target attributes", () => {
renderHomepageCTA();
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
expect(learnMoreLink).toHaveAttribute(
"href",
"https://openhands.dev/enterprise/",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
});
});
describe("accessibility", () => {
it("close button is focusable", () => {
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
expect(closeButton).not.toHaveAttribute("tabindex", "-1");
});
it("Learn More link is focusable", () => {
renderHomepageCTA();
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
expect(learnMoreLink).not.toHaveAttribute("tabindex", "-1");
});
});
});
@@ -30,7 +30,7 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { app_mode: "saas" },
data: {},
isLoading: false,
}),
}));
@@ -83,7 +83,7 @@ function renderSdkSectionPage(
});
mockUseConfig.mockReturnValue({
data: { app_mode: "oss" },
data: {},
isLoading: false,
});
mockUseSearchParams.mockReturnValue([{ get: () => null }, vi.fn()]);
@@ -98,7 +98,7 @@ function renderSdkSectionPage(
beforeEach(() => {
vi.restoreAllMocks();
mockUseConfig.mockReturnValue({
data: { app_mode: "oss" },
data: {},
isLoading: false,
});
mockUseSearchParams.mockReturnValue([{ get: () => null }, vi.fn()]);
+1 -1
View File
@@ -14,7 +14,7 @@ vi.mock("#/hooks/use-settings-nav-items", () => ({
}));
describe("UserActions", () => {
it("shows the OSS user menu on hover without SaaS-only actions", async () => {
it("shows the OSS user menu on hover without hosted-only actions", async () => {
const user = userEvent.setup();
render(
@@ -12,7 +12,7 @@ import {
// Mock useConfig
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { APP_MODE: "saas" },
data: {},
}),
}));
@@ -10,7 +10,7 @@ import { renderWithProviders } from "test-utils";
// Mock useConfig
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { APP_MODE: "saas" },
data: {},
}),
}));
@@ -23,7 +23,6 @@ const buildProviders = (
bitbucket_data_center: { token: "", host: null },
azure_devops: { token: "", host: null },
forgejo: { token: "", host: null },
enterprise_sso: { token: "", host: null },
...overrides,
});
-8
View File
@@ -5,11 +5,9 @@ import React from "react";
import { useGitUser } from "#/hooks/query/use-git-user";
import UserService from "#/api/user-service/user-service.api";
import * as useShouldShowUserFeaturesModule from "#/hooks/use-should-show-user-features";
import * as useConfigModule from "#/hooks/query/use-config";
import { AxiosError } from "axios";
vi.mock("#/hooks/use-should-show-user-features");
vi.mock("#/hooks/query/use-config");
vi.mock("#/api/user-service/user-service.api");
const identifyMock = vi.fn();
@@ -25,11 +23,6 @@ describe("useGitUser", () => {
vi.clearAllMocks();
vi.mocked(useShouldShowUserFeaturesModule.useShouldShowUserFeatures).mockReturnValue(true);
vi.mocked(useConfigModule.useConfig).mockReturnValue({
data: { app_mode: "oss" },
isLoading: false,
error: null,
} as any);
});
const createWrapper = () => {
@@ -88,7 +81,6 @@ describe("useGitUser", () => {
name: "The Octocat",
email: "octocat@example.com",
user: "octocat",
mode: "oss",
});
});
});
@@ -13,28 +13,22 @@ vi.mock("#/hooks/query/use-config", () => ({
const createConfig = (
feature_flags: Partial<WebClientConfig["feature_flags"]> = {},
): WebClientConfig => ({
app_mode: "oss",
posthog_client_key: null,
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_billing_page: true,
hide_integrations_page: false,
deployment_mode: "self_hosted",
...feature_flags,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: null,
});
describe("useSettingsNavItems", () => {
-158
View File
@@ -1,158 +0,0 @@
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
} from "vitest";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMockAgentErrorEvent,
createMockConversationErrorEvent,
} from "#/mocks/mock-ws-helpers";
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
import { ConnectionStatusComponent } from "./helpers/websocket-test-components";
// Mock the tracking function
const mockTrackCreditLimitReached = vi.fn();
// Mock useTracking hook
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackCreditLimitReached: mockTrackCreditLimitReached,
trackLoginButtonClick: vi.fn(),
trackConversationCreated: vi.fn(),
trackPushButtonClick: vi.fn(),
trackPullButtonClick: vi.fn(),
trackCreatePrButtonClick: vi.fn(),
trackGitProviderConnected: vi.fn(),
trackUserSignupCompleted: vi.fn(),
trackCreditsPurchased: vi.fn(),
}),
}));
// Mock useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: null,
isLoading: false,
error: null,
}),
}));
// MSW WebSocket mock setup
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
beforeAll(() => {
// The global MSW server from vitest.setup.ts is already running
// We just need to start our WebSocket-specific server
mswServer.listen({ onUnhandledRequest: "bypass" });
});
afterEach(() => {
// Clear all mocks before each test
mockTrackCreditLimitReached.mockClear();
mswServer.resetHandlers();
// Clean up any React components
cleanup();
});
afterAll(async () => {
// Close the WebSocket MSW server
mswServer.close();
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
});
// Helper function to render components with all necessary providers
function renderWithProviders(
children: React.ReactNode,
conversationId = "test-conversation-123",
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-123",
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversationUrl}
sessionApiKey={null}
>
{children}
</ConversationWebSocketProvider>
</QueryClientProvider>,
);
}
describe("PostHog Analytics Tracking", () => {
describe("Credit Limit Tracking", () => {
it("should NOT track credit_limit_reached for non-budget errors", async () => {
// Create a regular error without budget/credit keywords
const mockRegularErrorEvent = createMockAgentErrorEvent({
error: "Failed to execute command: Permission denied",
});
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockRegularErrorEvent));
}),
);
renderWithProviders(<ConnectionStatusComponent />);
// Wait for connection and error to be processed
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Verify that credit_limit_reached was NOT tracked
expect(mockTrackCreditLimitReached).not.toHaveBeenCalled();
});
it("should track credit_limit_reached when ConversationErrorEvent contains budget error", async () => {
const mockBudgetConversationError = createMockConversationErrorEvent({
detail:
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
});
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockBudgetConversationError));
}),
);
renderWithProviders(<ConnectionStatusComponent />);
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
await waitFor(() => {
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: "test-conversation-123",
}),
);
});
});
});
});
+1 -1
View File
@@ -29,7 +29,7 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({ data: { APP_MODE: "saas" } }),
useConfig: () => ({ data: {} }),
}));
function renderLaunchRoute(searchParams: string) {
@@ -21,13 +21,11 @@ vi.mock("#/hooks/query/use-config", () => ({
}));
const DEFAULT_FEATURE_FLAGS = {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
};
@@ -64,7 +62,6 @@ describe("MainApp - Auth refetch behavior", () => {
});
useConfigMock.mockReturnValue({
data: {
app_mode: "saas",
github_client_id: "test-client-id",
feature_flags: DEFAULT_FEATURE_FLAGS,
},
-9
View File
@@ -22,42 +22,33 @@ describe("settings route", () => {
it("prefers OSS fallback routes only", () => {
expect(
getFirstAvailablePath({
enable_billing: false,
hide_llm_settings: true,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_billing_page: true,
hide_integrations_page: false,
deployment_mode: "self_hosted",
}),
).toBe("/settings/mcp");
});
it("redirects hidden OSS settings pages to the first available route", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
app_mode: "oss",
posthog_client_key: null,
feature_flags: {
enable_billing: false,
hide_llm_settings: true,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_billing_page: true,
hide_integrations_page: false,
deployment_mode: "self_hosted",
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: null,
});
const response = (await clientLoader({
+2 -2
View File
@@ -38,7 +38,7 @@ describe("TaskSuggestions empty states", () => {
it("OSS mode + no providers → shows Git provider empty state", () => {
(useConfig as any).mockReturnValue({
data: { app_mode: "oss" },
data: {},
});
(useUserProviders as any).mockReturnValue({
@@ -58,7 +58,7 @@ describe("TaskSuggestions empty states", () => {
it("OSS mode + providers exist but no tasks → shows no tasks message", () => {
(useConfig as any).mockReturnValue({
data: { app_mode: "oss" },
data: {},
});
(useUserProviders as any).mockReturnValue({
-61
View File
@@ -5,8 +5,6 @@ import {
setLoginMethod,
getLoginMethod,
clearLoginData,
setCTADismissed,
isCTADismissed,
} from "#/utils/local-storage";
describe("local-storage utilities", () => {
@@ -31,9 +29,6 @@ describe("local-storage utilities", () => {
setLoginMethod(LoginMethod.AZURE_DEVOPS);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("azure_devops");
setLoginMethod(LoginMethod.ENTERPRISE_SSO);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("enterprise_sso");
setLoginMethod(LoginMethod.BITBUCKET_DATA_CENTER);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket_data_center");
});
@@ -81,60 +76,4 @@ describe("local-storage utilities", () => {
});
});
});
describe("CTA utilities", () => {
describe("isCTADismissed", () => {
it("returns false when CTA has not been dismissed", () => {
expect(isCTADismissed("homepage")).toBe(false);
});
it("returns true when CTA has been dismissed", () => {
localStorage.setItem("homepage-cta-dismissed", "true");
expect(isCTADismissed("homepage")).toBe(true);
});
it("returns false when storage value is not 'true'", () => {
localStorage.setItem("homepage-cta-dismissed", "false");
expect(isCTADismissed("homepage")).toBe(false);
localStorage.setItem("homepage-cta-dismissed", "invalid");
expect(isCTADismissed("homepage")).toBe(false);
});
});
describe("setCTADismissed", () => {
it("sets the CTA as dismissed in local storage", () => {
setCTADismissed("homepage");
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
});
it("generates correct key for homepage location", () => {
setCTADismissed("homepage");
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
});
});
describe("storage key format", () => {
it("uses the correct key format: {location}-cta-dismissed", () => {
setCTADismissed("homepage");
// Verify key exists with correct format
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
// Verify other keys don't exist
expect(localStorage.getItem("cta-dismissed")).toBeNull();
expect(localStorage.getItem("homepage")).toBeNull();
});
});
describe("persistence", () => {
it("dismissed state persists across multiple reads", () => {
setCTADismissed("homepage");
expect(isCTADismissed("homepage")).toBe(true);
expect(isCTADismissed("homepage")).toBe(true);
expect(isCTADismissed("homepage")).toBe(true);
});
});
});
});
+4 -4
View File
@@ -241,7 +241,7 @@ describe("sdk settings schema helpers", () => {
"critic.enabled": true,
"llm.api_key": "new-key",
"llm.litellm_extra_body": JSON.stringify(
{ metadata: { tier: "enterprise" } },
{ metadata: { tier: "sample" } },
null,
2,
),
@@ -258,7 +258,7 @@ describe("sdk settings schema helpers", () => {
critic: { enabled: true },
llm: {
api_key: "new-key",
litellm_extra_body: { metadata: { tier: "enterprise" } },
litellm_extra_body: { metadata: { tier: "sample" } },
},
});
});
@@ -289,7 +289,7 @@ describe("sdk settings schema helpers", () => {
"critic.enabled": true,
"critic.mode": "all_actions",
"llm.litellm_extra_body": JSON.stringify(
{ metadata: { tier: "enterprise" } },
{ metadata: { tier: "sample" } },
null,
2,
),
@@ -329,7 +329,7 @@ describe("sdk settings schema helpers", () => {
llm: {
model: "anthropic/claude-sonnet-4-20250514",
timeout: 90,
litellm_extra_body: { metadata: { tier: "enterprise" } },
litellm_extra_body: { metadata: { tier: "sample" } },
},
critic: { enabled: true, mode: "all_actions" },
});
Vendored
-24
View File
@@ -1,29 +1,5 @@
interface Window {
__APP_MODE__?: "saas" | "oss";
__GITHUB_CLIENT_ID__?: string | null;
Reo?: {
init: (config: { clientID: string }) => void;
identify: (identity: {
username: string;
type: "github" | "email";
other_identities?: Array<{
username: string;
type: "github" | "email";
}>;
firstname?: string;
lastname?: string;
company?: string;
}) => void;
};
grecaptcha?: {
enterprise: {
ready: (callback: () => void) => void;
execute: (
siteKey: string,
options: { action: string },
) => Promise<string>;
};
};
}
declare module "postcss-prefix-selector" {
+2 -3
View File
@@ -54,10 +54,9 @@
"dev": "npm run dev:safe",
"dev:safe": "node scripts/dev-safe.mjs",
"dev:frontend": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev",
"dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
"build": "npm run build:app",
"build:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router build",
"build:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router build",
"start": "npx sirv-cli build/ --single",
"test": "npm run make-i18n && vitest run",
"test:e2e": "playwright test",
+1 -1
View File
@@ -75,7 +75,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev:mock:saas -- --port 3001",
command: "npm run dev:mock -- --port 3001",
url: "http://localhost:3001/",
reuseExistingServer: !process.env.CI,
},
+4 -4
View File
@@ -93,7 +93,7 @@ export const useFeature = (id: string) => {
| Item | Convention | Example |
|------|------------|---------|
| Directory | `feature-service/` | `billing-service/` |
| Service file | `feature-service.api.ts` | `billing-service.api.ts` |
| Types file | `feature.types.ts` | `billing.types.ts` |
| Export name | `featureService` | `billingService` |
| Directory | `feature-service/` | `secrets-service/` |
| Service file | `feature-service.api.ts` | `secrets-service.api.ts` |
| Types file | `feature.types.ts` | `secrets.types.ts` |
| Export name | `featureService` | `secretsService` |
+1 -1
View File
@@ -1,5 +1,5 @@
import { createServerClient, type ServerInfo } from "#/api/typescript-client";
import { HttpError } from "@openhands/typescript-client/client/http-client";
import { createServerClient, type ServerInfo } from "#/api/typescript-client";
export const MINIMUM_SUPPORTED_AGENT_SERVER_VERSION = "1.17.0";
const AGENT_SERVER_INFO_TIMEOUT_MS = 5000;
+14 -4
View File
@@ -8,13 +8,18 @@ import type {
SearchProvidersParams,
} from "./config-service.types";
function filterByQuery<T extends { name: string }>(items: T[], query?: string): T[] {
function filterByQuery<T extends { name: string }>(
items: T[],
query?: string,
): T[] {
if (!query) {
return items;
}
const normalizedQuery = query.toLowerCase();
return items.filter((item) => item.name.toLowerCase().includes(normalizedQuery));
return items.filter((item) =>
item.name.toLowerCase().includes(normalizedQuery),
);
}
function filterByVerified<T extends { verified: boolean }>(
@@ -47,7 +52,9 @@ class ConfigService {
]);
const provider = params.provider__eq ?? null;
const verifiedNames = new Set(provider ? (verifiedByProvider?.[provider] ?? []) : []);
const verifiedNames = new Set(
provider ? (verifiedByProvider?.[provider] ?? []) : [],
);
const verifiedItems: LLMModel[] = [...verifiedNames].map((name) => ({
provider,
name,
@@ -96,7 +103,10 @@ class ConfigService {
}));
const items = limitItems(
filterByVerified(filterByQuery(providerItems, params.query), params.verified__eq),
filterByVerified(
filterByQuery(providerItems, params.query),
params.verified__eq,
),
params.limit,
);
@@ -4,7 +4,11 @@ import {
GetTrajectoryResponse,
FileUploadSuccessResponse,
} from "../open-hands.types";
import { createRemoteEventsList, createRemoteWorkspace, createVSCodeClient } from "../typescript-client";
import {
createRemoteEventsList,
createRemoteWorkspace,
createVSCodeClient,
} from "../typescript-client";
import { V1AppConversation } from "./v1-conversation-service.types";
class ConversationService {
@@ -38,8 +42,11 @@ class ConversationService {
static async getVSCodeUrl(
_conversationId: string,
): Promise<GetVSCodeUrlResponse> {
const vscode_url = await createVSCodeClient(this.getClientOverrides()).getUrl({
baseUrl: typeof window !== "undefined" ? window.location.origin : undefined,
const vscode_url = await createVSCodeClient(
this.getClientOverrides(),
).getUrl({
baseUrl:
typeof window !== "undefined" ? window.location.origin : undefined,
workspaceDir: getAgentServerWorkingDir(),
});
@@ -29,7 +29,6 @@ import type {
V1AppConversationPage,
V1AppConversationStartRequest,
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1RuntimeConversationInfo,
V1SendMessageRequest,
V1SendMessageResponse,
@@ -40,10 +39,13 @@ class V1ConversationService {
conversationId: string,
message: V1SendMessageRequest,
): Promise<V1SendMessageResponse> {
await createHttpClient().post(`/api/conversations/${conversationId}/events`, {
...message,
run: true,
});
await createHttpClient().post(
`/api/conversations/${conversationId}/events`,
{
...message,
run: true,
},
);
return message;
}
@@ -74,7 +76,7 @@ class V1ConversationService {
"/api/conversations",
payload,
);
const data = response.data;
const { data } = response;
return {
id: data.id,
@@ -113,7 +115,8 @@ class V1ConversationService {
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
const vscode_url = await createVSCodeClient({ sessionApiKey }).getUrl({
baseUrl: typeof window !== "undefined" ? window.location.origin : undefined,
baseUrl:
typeof window !== "undefined" ? window.location.origin : undefined,
workspaceDir: getAgentServerWorkingDir(),
});
@@ -125,10 +128,9 @@ class V1ConversationService {
_conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<{ success: boolean }> {
const response = await createHttpClient({ sessionApiKey }).post<{ success: boolean }>(
`/api/conversations/${conversationId}/pause`,
{},
);
const response = await createHttpClient({ sessionApiKey }).post<{
success: boolean;
}>(`/api/conversations/${conversationId}/pause`, {});
return response.data;
}
@@ -139,10 +141,9 @@ class V1ConversationService {
question: string,
sessionApiKey?: string | null,
): Promise<{ response: string }> {
const response = await createHttpClient({ sessionApiKey }).post<{ response: string }>(
`/api/conversations/${conversationId}/ask_agent`,
{ question },
);
const response = await createHttpClient({ sessionApiKey }).post<{
response: string;
}>(`/api/conversations/${conversationId}/ask_agent`, { question });
return response.data;
}
@@ -152,10 +153,9 @@ class V1ConversationService {
_conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<{ success: boolean }> {
const response = await createHttpClient({ sessionApiKey }).post<{ success: boolean }>(
`/api/conversations/${conversationId}/run`,
{},
);
const response = await createHttpClient({ sessionApiKey }).post<{
success: boolean;
}>(`/api/conversations/${conversationId}/run`, {});
return response.data;
}
@@ -165,12 +165,13 @@ class V1ConversationService {
): Promise<(V1AppConversation | null)[]> {
if (ids.length === 0) return [];
const response = await createHttpClient().get<(DirectConversationInfo | null)[]>(
"/api/conversations",
{ params: { ids } },
);
const response = await createHttpClient().get<
(DirectConversationInfo | null)[]
>("/api/conversations", { params: { ids } });
return response.data.map((item) => (item ? toV1AppConversation(item) : null));
return response.data.map((item) =>
item ? toV1AppConversation(item) : null,
);
}
static async uploadFile(
@@ -226,7 +227,9 @@ class V1ConversationService {
}
static async getSkills(conversationId: string): Promise<GetSkillsResponse> {
const [conversation] = await this.batchGetAppConversations([conversationId]);
const [conversation] = await this.batchGetAppConversations([
conversationId,
]);
return loadSkillsForConversation(conversation);
}
@@ -242,7 +245,7 @@ class V1ConversationService {
const response = await createHttpClient({ sessionApiKey }).get<
DirectConversationInfo & { stats?: V1RuntimeConversationInfo["stats"] }
>(`/api/conversations/${conversationId}`);
const data = response.data;
const { data } = response;
return {
id: data.id,
@@ -260,7 +263,8 @@ class V1ConversationService {
cache_read_tokens:
data.metrics.accumulated_token_usage.cache_read_tokens ?? 0,
cache_write_tokens:
data.metrics.accumulated_token_usage.cache_write_tokens ?? 0,
data.metrics.accumulated_token_usage.cache_write_tokens ??
0,
context_window:
data.metrics.accumulated_token_usage.context_window ?? 0,
per_turn_token:
@@ -271,7 +275,9 @@ class V1ConversationService {
: null,
created_at: data.created_at,
updated_at: data.updated_at,
status: (data.execution_status as V1RuntimeConversationInfo["status"]) ?? "idle",
status:
(data.execution_status as V1RuntimeConversationInfo["status"]) ??
"idle",
stats: data.stats ?? { usage_to_metrics: {} },
};
}
@@ -310,8 +316,12 @@ class V1ConversationService {
conversationId: string,
title: string,
): Promise<V1AppConversation> {
await createHttpClient().patch(`/api/conversations/${conversationId}`, { title });
const [conversation] = await this.batchGetAppConversations([conversationId]);
await createHttpClient().patch(`/api/conversations/${conversationId}`, {
title,
});
const [conversation] = await this.batchGetAppConversations([
conversationId,
]);
return conversation as V1AppConversation;
}
}
+6 -2
View File
@@ -12,7 +12,9 @@ class EventService {
request: ConfirmationResponseRequest,
sessionApiKey?: string | null,
): Promise<ConfirmationResponseResponse> {
const response = await createHttpClient({ sessionApiKey }).post<ConfirmationResponseResponse>(
const response = await createHttpClient({
sessionApiKey,
}).post<ConfirmationResponseResponse>(
`/api/conversations/${conversationId}/events/respond_to_confirmation`,
request,
);
@@ -33,7 +35,9 @@ class EventService {
limit = 100,
sessionApiKey?: string | null,
) {
const page = await createRemoteEventsList(conversationId, { sessionApiKey }).search({
const page = await createRemoteEventsList(conversationId, {
sessionApiKey,
}).search({
limit,
});
+13 -3
View File
@@ -51,7 +51,13 @@ class GitService {
pageId?: string,
limit = 30,
): Promise<BranchPage> {
return this.getRepositoryBranches(repository, provider, query, pageId, limit);
return this.getRepositoryBranches(
repository,
provider,
query,
pageId,
limit,
);
}
static async getUserInstallations(
@@ -63,12 +69,16 @@ class GitService {
}
static async getGitChanges(_conversationId: string): Promise<GitChange[]> {
const changes = await createRemoteWorkspace().gitChanges(getAgentServerWorkingDir());
const changes = await createRemoteWorkspace().gitChanges(
getAgentServerWorkingDir(),
);
return changes.map((change) => ({
path: change.path,
status: mapAnyGitStatusToV0Status(
String(change.status) as Parameters<typeof mapAnyGitStatusToV0Status>[0],
String(change.status) as Parameters<
typeof mapAnyGitStatusToV0Status
>[0],
),
}));
}
+6 -2
View File
@@ -14,12 +14,16 @@ class V1GitService {
}).gitChanges(path);
if (!Array.isArray(changes)) {
throw new Error("Invalid response from runtime - runtime may be unavailable");
throw new Error(
"Invalid response from runtime - runtime may be unavailable",
);
}
return changes.map((change) => ({
status: mapAnyGitStatusToV0Status(
String(change.status) as Parameters<typeof mapAnyGitStatusToV0Status>[0],
String(change.status) as Parameters<
typeof mapAnyGitStatusToV0Status
>[0],
),
path: change.path,
}));
+12 -3
View File
@@ -1,7 +1,16 @@
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios";
import { getAgentServerBaseUrl, getAgentServerHeaders } from "./agent-server-config";
import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import {
getAgentServerBaseUrl,
getAgentServerHeaders,
} from "./agent-server-config";
function serializeParams(params: Record<string, unknown> | URLSearchParams): string {
function serializeParams(
params: Record<string, unknown> | URLSearchParams,
): string {
if (params instanceof URLSearchParams) {
return params.toString();
}
@@ -34,27 +34,21 @@ class OptionService {
await ensureCompatibleAgentServer();
return {
app_mode: "oss",
posthog_client_key: null,
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_billing_page: true,
hide_integrations_page: false,
deployment_mode: "self_hosted",
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: null,
};
}
}
-8
View File
@@ -1,7 +1,5 @@
import { Provider } from "#/types/settings";
export type DeploymentMode = "cloud" | "self_hosted";
/**
* Structured response from ``GET /api/options/models``.
*
@@ -20,27 +18,21 @@ export interface ModelsResponse {
}
export interface WebClientFeatureFlags {
enable_billing: boolean;
hide_llm_settings: boolean;
enable_jira: boolean;
enable_jira_dc: boolean;
enable_linear: boolean;
hide_users_page: boolean;
hide_billing_page: boolean;
hide_integrations_page: boolean;
deployment_mode?: DeploymentMode;
}
export interface WebClientConfig {
app_mode: "saas" | "oss";
posthog_client_key: string | null;
feature_flags: WebClientFeatureFlags;
providers_configured: Provider[];
maintenance_start_time: string | null;
auth_url: string | null;
recaptcha_site_key: string | null;
faulty_models: string[];
error_message: string | null;
updated_at: string;
github_app_slug: string | null;
}
@@ -9,11 +9,14 @@ class PendingMessageService {
conversationId: string,
message: QueuePendingMessageRequest,
): Promise<PendingMessageResponse> {
await createHttpClient().post(`/api/conversations/${conversationId}/events`, {
...message,
role: "user",
run: true,
});
await createHttpClient().post(
`/api/conversations/${conversationId}/events`,
{
...message,
role: "user",
run: true,
},
);
return {
id: `${conversationId}:${Date.now()}`,
@@ -14,7 +14,8 @@ export class SandboxService {
static async batchGetSandboxes(
ids: string[],
): Promise<(V1SandboxInfo | null)[]> {
const conversations = await V1ConversationService.batchGetAppConversations(ids);
const conversations =
await V1ConversationService.batchGetAppConversations(ids);
return conversations.map((conversation) =>
conversation ? createSandboxInfo(conversation) : null,
);
+13 -3
View File
@@ -35,7 +35,11 @@ const readStoredGitProviders = (): StoredGitProviderTokens => {
return Object.fromEntries(
Object.entries(parsed).flatMap(([provider, value]) => {
if (!(provider in ProviderOptions) || !value || typeof value !== "object") {
if (
!(provider in ProviderOptions) ||
!value ||
typeof value !== "object"
) {
return [];
}
@@ -74,14 +78,20 @@ const writeStoredGitProviders = (providers: StoredGitProviderTokens) => {
return;
}
window.localStorage.setItem(GIT_PROVIDER_STORAGE_KEY, JSON.stringify(providers));
window.localStorage.setItem(
GIT_PROVIDER_STORAGE_KEY,
JSON.stringify(providers),
);
};
const buildProviderTokensSet = (
providers: StoredGitProviderTokens,
): Partial<Record<Provider, string | null>> =>
Object.fromEntries(
Object.entries(providers).map(([provider, value]) => [provider, value?.host ?? null]),
Object.entries(providers).map(([provider, value]) => [
provider,
value?.host ?? null,
]),
) as Partial<Record<Provider, string | null>>;
export class SecretsService {
+3 -4
View File
@@ -27,10 +27,9 @@ export const sharedConversationService = {
async getSharedConversation(
conversationId: string,
): Promise<SharedConversation | null> {
const response = await createHttpClient().get<(SharedConversation | null)[]>(
"/api/shared-conversations",
{ params: { ids: conversationId } },
);
const response = await createHttpClient().get<
(SharedConversation | null)[]
>("/api/shared-conversations", { params: { ids: conversationId } });
return response.data[0] || null;
},
+2 -1
View File
@@ -5,7 +5,6 @@ import {
SkillsClient,
VSCodeClient,
} from "@openhands/typescript-client/clients";
export type { ServerInfo } from "@openhands/typescript-client";
import { HttpClient } from "@openhands/typescript-client/client/http-client";
import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list";
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
@@ -16,6 +15,8 @@ import {
getAgentServerWorkingDir,
} from "./agent-server-config";
export type { ServerInfo } from "@openhands/typescript-client";
interface TypeScriptClientOverrides {
host?: string;
apiKey?: string | null;
@@ -1,7 +1,6 @@
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { NavigationLink } from "#/components/shared/navigation-link";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
@@ -39,24 +38,7 @@ export function ErrorMessageBanner({
)}
data-testid="error-message-banner-content"
>
{isI18nKey ? (
<Trans
ns="openhands"
i18nKey={message}
components={{
a: (
<NavigationLink
className="underline font-bold cursor-pointer"
to="/settings/billing"
>
link
</NavigationLink>
),
}}
/>
) : (
message
)}
{isI18nKey ? <Trans ns="openhands" i18nKey={message} /> : message}
</div>
{shouldShowToggle && (
@@ -1,13 +1,10 @@
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
import CheckCircle from "#/icons/check-circle-solid.svg?react";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { NavigationLink } from "#/components/shared/navigation-link";
import { cn } from "#/utils/utils";
import { MarkdownRenderer } from "../markdown/markdown-renderer";
import { MonoComponent } from "./mono-component";
@@ -35,8 +32,7 @@ export function ExpandableMessage({
observation,
action,
}: ExpandableMessageProps) {
const { data: config } = useConfig();
const { t, i18n } = useTranslation("openhands");
const { i18n } = useTranslation("openhands");
const [showDetails, setShowDetails] = useState(true);
const [details, setDetails] = useState(message);
const [translationId, setTranslationId] = useState<string | undefined>(id);
@@ -93,31 +89,6 @@ export function ExpandableMessage({
const statusIconClasses = "h-4 w-4 ml-2 inline";
if (
config?.feature_flags?.enable_billing &&
config?.app_mode === "saas" &&
id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS
) {
return (
<div
data-testid="out-of-credits"
className="flex gap-2 items-center justify-start border-l-2 pl-2 my-2 py-2 border-danger"
>
<div className="text-sm w-full">
<div className="font-bold text-danger">
{t(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS)}
</div>
<NavigationLink
className="mt-2 mb-2 w-full h-10 rounded-sm flex items-center justify-center gap-2 bg-primary text-[#0D0F11]"
to="/settings/billing"
>
{t(I18nKey.BILLING$CLICK_TO_TOP_UP)}
</NavigationLink>
</div>
</div>
);
}
return (
<div
className={cn(
@@ -22,7 +22,7 @@ export function ContextMenuContainer({
ref={ref}
data-testid={testId}
className={cn(
// Base styling - same for ALL modes (SaaS, OSS, mobile, desktop)
// Base styling
"absolute rounded-[12px] p-[25px]",
"bg-[#050505] border border-[#242424]",
"text-white overflow-hidden z-[9999]",
@@ -1,4 +1,4 @@
import { FaBitbucket, FaGithub, FaGitlab, FaUserShield } from "react-icons/fa6";
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { FaCodeBranch } from "react-icons/fa";
import { IconType } from "react-icons/lib";
import { RepositorySelection } from "#/api/open-hands.types";
@@ -14,7 +14,6 @@ const providerIcon: Partial<Record<Provider, IconType>> = {
bitbucket_data_center: FaBitbucket,
github: FaGithub,
gitlab: FaGitlab,
enterprise_sso: FaUserShield,
};
export function ConversationRepoLink({
@@ -6,8 +6,6 @@ import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { Divider } from "#/ui/divider";
import { I18nKey } from "#/i18n/declaration";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useConfig } from "#/hooks/query/use-config";
import EditIcon from "#/icons/u-edit.svg?react";
import RobotIcon from "#/icons/u-robot.svg?react";
@@ -16,8 +14,6 @@ import DownloadIcon from "#/icons/u-download.svg?react";
import CreditCardIcon from "#/icons/u-credit-card.svg?react";
import CloseIcon from "#/icons/u-close.svg?react";
import DeleteIcon from "#/icons/u-delete.svg?react";
import LinkIcon from "#/icons/link-external.svg?react";
import CopyIcon from "#/icons/copy.svg?react";
import { ConversationNameContextMenuIconText } from "./conversation-name-context-menu-icon-text";
import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants";
@@ -35,10 +31,7 @@ interface ConversationNameContextMenuProps {
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowHooks?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onTogglePublic?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCopyShareLink?: (event: React.MouseEvent<HTMLButtonElement>) => void;
shareUrl?: string;
position?: "top" | "bottom";
}
@@ -51,19 +44,13 @@ export function ConversationNameContextMenu({
onShowAgentTools,
onShowSkills,
onShowHooks,
onTogglePublic,
onDownloadConversation,
onCopyShareLink,
shareUrl,
position = "bottom",
}: ConversationNameContextMenuProps) {
const isMobile = useBreakpoint();
const { t } = useTranslation("openhands");
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { data: conversation } = useActiveConversation();
const { data: config } = useConfig();
const shouldShowPublicSharing = config?.app_mode === "saas" && onTogglePublic;
const hasTools = Boolean(onShowAgentTools || onShowSkills || onShowHooks);
const hasInfo = Boolean(onDisplayCost);
const hasControl = Boolean(onStop || onDelete);
@@ -164,49 +151,6 @@ export function ConversationNameContextMenu({
</ContextMenuListItem>
)}
{shouldShowPublicSharing && (
<ContextMenuListItem
testId="share-publicly-button"
onClick={onTogglePublic}
className={contextMenuListItemClassName}
>
<div className="flex items-center gap-2 justify-between w-full hover:bg-[#5C5D62] rounded h-[30px]">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={conversation?.public || false}
className="w-4 h-4 ml-2 cursor-pointer"
/>
<span>{t(I18nKey.CONVERSATION$SHARE_PUBLICLY)}</span>
</div>
{conversation?.public && shareUrl && onCopyShareLink && (
<div className="flex items-center gap-1">
<button
type="button"
data-testid="copy-share-link-button"
onClick={onCopyShareLink}
className="p-1 hover:bg-[#717888] rounded cursor-pointer"
title={t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)}
>
<CopyIcon width={16} height={16} />
</button>
<a
data-testid="open-share-link-button"
href={shareUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1 hover:bg-[#717888] rounded cursor-pointer"
title={t(I18nKey.BUTTON$OPEN_IN_NEW_TAB)}
>
<LinkIcon width={16} height={16} />
</a>
</div>
)}
</div>
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem
testId="stop-button"
@@ -35,9 +35,6 @@ export function ConversationName() {
handleShowAgentTools,
handleShowSkills,
handleShowHooks,
handleTogglePublic,
handleCopyShareLink,
shareUrl,
handleConfirmDelete,
handleConfirmStop,
metricsModalVisible,
@@ -187,9 +184,6 @@ export function ConversationName() {
}
onShowSkills={shouldShowSkills ? handleShowSkills : undefined}
onShowHooks={shouldShowHooks ? handleShowHooks : undefined}
onTogglePublic={handleTogglePublic}
shareUrl={shareUrl}
onCopyShareLink={handleCopyShareLink}
onDownloadConversation={
shouldShowDownloadConversation
? handleDownloadConversation
@@ -55,8 +55,6 @@ export function GitProviderDropdown({
return "Bitbucket Data Center";
case "azure_devops":
return "Azure DevOps";
case "enterprise_sso":
return "Enterprise SSO";
default:
// Fallback for any future provider types
return (
@@ -7,7 +7,7 @@ import React, {
} from "react";
import { useCombobox } from "downshift";
import { useTranslation } from "react-i18next";
import { Provider, ProviderOptions } from "#/types/settings";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
import { useDebounce } from "#/hooks/use-debounce";
import { cn } from "#/utils/utils";
@@ -20,7 +20,6 @@ import { EmptyState } from "../shared/empty-state";
import { useUrlSearch } from "./use-url-search";
import { useRepositoryData } from "./use-repository-data";
import { GenericDropdownMenu } from "../shared/generic-dropdown-menu";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import RepoIcon from "#/icons/repo.svg?react";
import { useHomeStore } from "#/stores/home-store";
@@ -46,7 +45,6 @@ export function GitRepoDropdown({
onChange,
}: GitRepoDropdownProps) {
const { t } = useTranslation("openhands");
const { data: config } = useConfig();
const { recentRepositories: storedRecentRepositories } = useHomeStore();
const [inputValue, setInputValue] = useState("");
const [localSelectedItem, setLocalSelectedItem] =
@@ -289,34 +287,6 @@ export function GitRepoDropdown({
);
}, [recentRepositories, localSelectedItem, getItemProps, t]);
// Create sticky footer item for GitHub provider
const stickyFooterItem = useMemo(() => {
if (
!config?.github_app_slug ||
provider !== ProviderOptions.github ||
config.app_mode !== "saas"
)
return null;
const githubHref = `https://github.com/apps/${config.github_app_slug}/installations/new`;
return (
<a
href={githubHref}
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full px-2 py-2 text-sm text-white hover:bg-[#5C5D62] rounded-md transition-colors duration-150 font-normal"
onMouseDown={(e) => {
// Prevent downshift from closing the menu when clicking the sticky footer
e.preventDefault();
e.stopPropagation();
}}
>
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
);
}, [provider, config, t]);
return (
<div className={cn("relative", className)}>
<div className="relative">
@@ -374,7 +344,6 @@ export function GitRepoDropdown({
renderItem={renderItem}
renderEmptyState={renderEmptyState}
stickyTopItem={stickyTopItem}
stickyFooterItem={stickyFooterItem}
testId="git-repo-dropdown-menu"
numberOfRecentItems={recentRepositories.length}
itemKey={(repo) => repo.id}
@@ -83,7 +83,6 @@ export function useRepositoryData(
]);
// Auto-load more repositories when there aren't enough items to create a scrollable dropdown
// This is particularly important for SaaS mode with installations that might have very few repos
useEffect(() => {
const shouldAutoLoad =
!disabled &&
@@ -1,76 +0,0 @@
import { useTranslation } from "react-i18next";
import { Dispatch, SetStateAction } from "react";
import { Card } from "#/ui/card";
import { CardTitle } from "#/ui/card-title";
import { Typography } from "#/ui/typography";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { setCTADismissed } from "#/utils/local-storage";
import { useTracking } from "#/hooks/use-tracking";
import CloseIcon from "#/icons/close.svg?react";
interface HomepageCTAProps {
setShouldShowCTA: Dispatch<SetStateAction<boolean>>;
}
export function HomepageCTA({ setShouldShowCTA }: HomepageCTAProps) {
const { t } = useTranslation("openhands");
const { trackSaasSelfhostedInquiry } = useTracking();
const handleClose = () => {
setCTADismissed("homepage");
setShouldShowCTA(false);
};
const handleLearnMoreClick = () => {
trackSaasSelfhostedInquiry({ location: "home_page" });
};
return (
<Card theme="dark" className={cn("w-[320px] cta-card-gradient")}>
<button
type="button"
onClick={handleClose}
className={cn(
"absolute top-3 right-3 size-7 rounded-full",
"border border-[#242424] bg-[#0A0A0A]",
"flex items-center justify-center",
"text-white/60 hover:text-white cursor-pointer",
"shadow-[0px_1px_2px_-1px_#0000001A,0px_1px_3px_0px_#0000001A]",
)}
aria-label="Close"
>
<CloseIcon width={16} height={16} />
</button>
<div className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<CardTitle className="font-inter font-semibold text-xl leading-7 tracking-normal text-[#FAFAFA]">
{t(I18nKey.CTA$ENTERPRISE_TITLE)}
</CardTitle>
<Typography.Text className="font-inter font-normal text-sm leading-5 tracking-normal text-[#8C8C8C]">
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
</Typography.Text>
</div>
<a
data-testid="homepage-cta-learn-more"
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"w-fit h-10 px-4 rounded",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-white hover:text-black",
"font-semibold text-sm",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</div>
</Card>
);
}
@@ -7,7 +7,6 @@ import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { cn, getDisplayedTaskGroups, getTotalTaskCount } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { GitRepository } from "#/types/git";
import { useConfig } from "#/hooks/query/use-config";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Typography } from "#/ui/typography";
@@ -19,12 +18,10 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
const { t } = useTranslation("openhands");
const [isExpanded, setIsExpanded] = useState(false);
const { data: config } = useConfig();
const { data: tasks, isLoading } = useSuggestedTasks();
const { providers } = useUserProviders();
const isOSS = config?.app_mode === "oss";
const hasNoProviders = isOSS && providers.length === 0;
const hasNoProviders = providers.length === 0;
const suggestedTasks = filterFor
? tasks?.filter(
@@ -1,37 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useConfig } from "#/hooks/query/use-config";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { BrandButton } from "../brand-button";
export function ConfigureAzureDevOpsAnchor() {
const { t } = useTranslation("openhands");
const { data: config } = useConfig();
const authUrl = useAuthUrl({
appMode: config?.app_mode ?? null,
identityProvider: "azure_devops",
authUrl: config?.auth_url,
});
const handleOAuthFlow = () => {
if (!authUrl) {
return;
}
window.location.href = authUrl;
};
return (
<div data-testid="configure-azure-devops-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={handleOAuthFlow}
>
{t(I18nKey.AZURE_DEVOPS$CONNECT_ACCOUNT)}
</BrandButton>
</div>
);
}
@@ -49,7 +49,6 @@ export function Sidebar() {
"Something went wrong while fetching settings. Please reload the page.",
);
} else if (
config?.app_mode === "oss" &&
settingsErrorStatus === 404 &&
!config?.feature_flags?.hide_llm_settings
) {
@@ -60,7 +59,6 @@ export function Sidebar() {
isFetchingSettings,
settingsIsError,
settingsErrorStatus,
config?.app_mode,
config?.feature_flags?.hide_llm_settings,
]);
+56 -55
View File
@@ -8,11 +8,10 @@ interface NavigationLinkClassNameState {
isActive: boolean;
}
export interface NavigationLinkProps
extends Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
"className" | "href"
> {
export interface NavigationLinkProps extends Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
"className" | "href"
> {
to: string;
replace?: boolean;
end?: boolean;
@@ -40,54 +39,56 @@ function isPathActive(currentPath: string, to: string, end: boolean) {
export const NavigationLink = React.forwardRef<
HTMLAnchorElement,
NavigationLinkProps
>(function NavigationLink(
{
to,
replace = false,
end = false,
onClick,
className,
children,
target,
rel,
...props
>(
(
{
to,
replace = false,
end = false,
onClick,
className,
children,
target,
rel,
...props
},
ref,
) => {
const { currentPath, navigate } = useNavigation();
const isActive = isPathActive(currentPath, to, end);
const resolvedClassName =
typeof className === "function" ? className({ isActive }) : className;
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
isModifiedEvent(event) ||
target === "_blank"
) {
return;
}
event.preventDefault();
navigate(to, { replace } satisfies NavigationOptions);
};
return (
<a
{...props}
ref={ref}
href={to}
target={target}
rel={rel}
onClick={handleClick}
className={resolvedClassName}
aria-current={isActive ? "page" : undefined}
>
{children}
</a>
);
},
ref,
) {
const { currentPath, navigate } = useNavigation();
const isActive = isPathActive(currentPath, to, end);
const resolvedClassName =
typeof className === "function" ? className({ isActive }) : className;
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
isModifiedEvent(event) ||
target === "_blank"
) {
return;
}
event.preventDefault();
navigate(to, { replace } satisfies NavigationOptions);
};
return (
<a
{...props}
ref={ref}
href={to}
target={target}
rel={rel}
onClick={handleClick}
className={resolvedClassName}
aria-current={isActive ? "page" : undefined}
>
{children}
</a>
);
});
);
-78
View File
@@ -1,78 +0,0 @@
import { I18nKey } from "#/i18n/declaration";
export type OnboardingAppMode = "cloud" | "self-hosted" | "oss";
interface BaseOnboardingQuestion {
id: string;
app_mode: OnboardingAppMode[];
questionKey: I18nKey;
subtitleKey?: I18nKey;
}
interface InputQuestion extends BaseOnboardingQuestion {
type: "input";
inputOptions: { key: I18nKey; id: string }[];
}
interface SingleSelectQuestion extends BaseOnboardingQuestion {
type: "single";
answerOptions: { key: I18nKey; id: string }[];
}
interface MultiSelectQuestion extends BaseOnboardingQuestion {
type: "multi";
answerOptions: { key: I18nKey; id: string }[];
}
export type OnboardingQuestion =
| InputQuestion
| SingleSelectQuestion
| MultiSelectQuestion;
export const ONBOARDING_FORM: OnboardingQuestion[] = [
{
id: "use_case",
type: "multi",
app_mode: ["cloud", "self-hosted"],
questionKey: I18nKey.ONBOARDING$USE_CASE_QUESTION,
subtitleKey: I18nKey.ONBOARDING$SELECT_MULTIPLE_SUBTITLE,
answerOptions: [
{ key: I18nKey.ONBOARDING$USE_CASE_NEW_FEATURES, id: "new_features" },
{
key: I18nKey.ONBOARDING$USE_CASE_APP_FROM_SCRATCH,
id: "app_from_scratch",
},
{ key: I18nKey.ONBOARDING$USE_CASE_FIXING_BUGS, id: "fixing_bugs" },
{ key: I18nKey.ONBOARDING$USE_CASE_REFACTORING, id: "refactoring" },
{
key: I18nKey.ONBOARDING$USE_CASE_AUTOMATING_TASKS,
id: "automating_tasks",
},
{ key: I18nKey.ONBOARDING$USE_CASE_NOT_SURE, id: "not_sure" },
],
},
{
id: "role",
type: "single",
app_mode: ["cloud"],
questionKey: I18nKey.ONBOARDING$ROLE_QUESTION,
subtitleKey: I18nKey.ONBOARDING$SELECT_ONE_SUBTITLE,
answerOptions: [
{
key: I18nKey.ONBOARDING$ROLE_SOFTWARE_ENGINEER,
id: "software_engineer",
},
{
key: I18nKey.ONBOARDING$ROLE_ENGINEERING_MANAGER,
id: "engineering_manager",
},
{ key: I18nKey.ONBOARDING$ROLE_CTO_FOUNDER, id: "cto_founder" },
{
key: I18nKey.ONBOARDING$ROLE_PRODUCT_OPERATIONS,
id: "product_operations",
},
{ key: I18nKey.ONBOARDING$ROLE_STUDENT_HOBBYIST, id: "student_hobbyist" },
{ key: I18nKey.ONBOARDING$ROLE_OTHER, id: "other" },
],
},
];
+10 -44
View File
@@ -46,11 +46,9 @@ import type {
import EventService from "#/api/event-service/event-service.api";
import PendingMessageService from "#/api/pending-message-service/pending-message-service.api";
import { useConversationStore } from "#/stores/conversation-store";
import { isBudgetOrCreditError, trackError } from "#/utils/error-handler";
import { useTracking } from "#/hooks/use-tracking";
import { trackError } from "#/utils/error-handler";
import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-file";
import useMetricsStore from "#/stores/metrics-store";
import { I18nKey } from "#/i18n/declaration";
import { useConversationHistory } from "#/hooks/query/use-conversation-history";
import { setConversationState } from "#/utils/conversation-local-storage";
@@ -108,7 +106,6 @@ export function ConversationWebSocketProvider({
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { setExecutionStatus } = useV1ConversationStateStore();
const { appendInput, appendOutput } = useCommandStore();
const { trackCreditLimitReached } = useTracking();
// History loading state - separate per connection
const [isLoadingHistoryMain, setIsLoadingHistoryMain] = useState(true);
@@ -139,24 +136,9 @@ export function ConversationWebSocketProvider({
const isPlanFilePath = (path: string | null): boolean =>
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
// Helper to handle error clearing logic for non-error events.
// Budget/credit errors persist until an agent event proves the LLM is working.
const handleNonErrorEvent = useCallback(
(event: { source?: string }) => {
const currentError = useErrorMessageStore.getState().errorMessage;
const isBudgetError =
currentError === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS;
const isAgentEvent = event.source === "agent";
// Budget errors persist until agent proves LLM is working
if (isBudgetError && !isAgentEvent) {
return; // Keep budget error visible
}
removeErrorMessage();
},
[removeErrorMessage],
);
const handleNonErrorEvent = useCallback(() => {
removeErrorMessage();
}, [removeErrorMessage]);
// Helper function to update metrics from stats event
const updateMetricsFromStats = useCallback(
@@ -390,16 +372,9 @@ export function ConversationWebSocketProvider({
},
posthog,
});
if (isBudgetOrCreditError(errorEvent.detail)) {
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
trackCreditLimitReached({
conversationId: conversationId || "unknown",
});
} else {
setErrorMessage(errorEvent.detail);
}
setErrorMessage(errorEvent.detail);
} else {
handleNonErrorEvent(event);
handleNonErrorEvent();
}
// Track credit limit reached if AgentErrorEvent has budget-related error
@@ -492,7 +467,6 @@ export function ConversationWebSocketProvider({
isLoadingHistoryMain,
expectedEventCountMain,
setErrorMessage,
removeErrorMessage,
removeOptimisticUserMessage,
queryClient,
conversationId,
@@ -500,7 +474,7 @@ export function ConversationWebSocketProvider({
appendInput,
appendOutput,
updateMetricsFromStats,
trackCreditLimitReached,
handleNonErrorEvent,
posthog,
],
);
@@ -547,16 +521,9 @@ export function ConversationWebSocketProvider({
},
posthog,
});
if (isBudgetOrCreditError(errorEvent.detail)) {
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
trackCreditLimitReached({
conversationId: conversationId || "unknown",
});
} else {
setErrorMessage(errorEvent.detail);
}
setErrorMessage(errorEvent.detail);
} else {
handleNonErrorEvent(event);
handleNonErrorEvent();
}
// Handle AgentErrorEvent specifically
@@ -672,7 +639,6 @@ export function ConversationWebSocketProvider({
isLoadingHistoryPlanning,
expectedEventCountPlanning,
setErrorMessage,
removeErrorMessage,
removeOptimisticUserMessage,
queryClient,
subConversations,
@@ -683,7 +649,7 @@ export function ConversationWebSocketProvider({
readConversationFile,
setPlanContent,
updateMetricsFromStats,
trackCreditLimitReached,
handleNonErrorEvent,
posthog,
],
);
@@ -61,8 +61,7 @@ export const useCreateConversation = () => {
);
return {
conversation_id:
conversation.app_conversation_id || conversation.id,
conversation_id: conversation.app_conversation_id || conversation.id,
session_api_key: null,
url: conversation.agent_server_url,
is_v1: true,
@@ -1,23 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { openHands } from "#/api/open-hands-axios";
import {
LLM_API_KEY_QUERY_KEY,
LlmApiKeyResponse,
} from "#/hooks/query/use-llm-api-key";
export function useRefreshLlmApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await openHands.post<LlmApiKeyResponse>(
"/api/keys/llm/byor/refresh",
);
return data;
},
onSuccess: () => {
// Invalidate the LLM API key query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [LLM_API_KEY_QUERY_KEY] });
},
});
}
@@ -1,41 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { openHands } from "#/api/open-hands-axios";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SubmitOnboardingArgs = {
selections: Record<string, string | string[]>;
};
export const useSubmitOnboarding = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ selections }: SubmitOnboardingArgs) => {
// Mark onboarding as complete
await openHands.post("/api/complete_onboarding");
return { selections };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
const finalRedirectUrl = "/";
// Check if the redirect URL is an external URL (starts with http or https)
if (
finalRedirectUrl.startsWith("http://") ||
finalRedirectUrl.startsWith("https://")
) {
// For external URLs, redirect using window.location
window.location.href = finalRedirectUrl;
} else {
// For internal routes, use navigate
navigate(finalRedirectUrl);
}
},
onError: (error) => {
displayErrorToast(error.message);
window.location.href = "/";
},
});
};
@@ -1,70 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { I18nKey } from "#/i18n/declaration";
import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
export const useUpdateConversationPublicFlag = () => {
const queryClient = useQueryClient();
const { t } = useTranslation("openhands");
return useMutation({
mutationFn: (variables: { conversationId: string; isPublic: boolean }) =>
V1ConversationService.updateConversationPublicFlag(
variables.conversationId,
variables.isPublic,
),
onMutate: async (variables) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: ["user", "conversation", variables.conversationId],
});
// Snapshot the previous value
const previousConversation = queryClient.getQueryData([
"user",
"conversation",
variables.conversationId,
]);
// Optimistically update the conversation
queryClient.setQueryData(
["user", "conversation", variables.conversationId],
(old: unknown) =>
old && typeof old === "object"
? { ...old, public: variables.isPublic }
: old,
);
return { previousConversation };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousConversation) {
queryClient.setQueryData(
["user", "conversation", variables.conversationId],
context.previousConversation,
);
}
displayErrorToast(
t(I18nKey.CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING),
);
},
onSuccess: () => {
displaySuccessToast(t(I18nKey.CONVERSATION$PUBLIC_SHARING_UPDATED));
},
onSettled: (data, error, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({
queryKey: ["user", "conversation", variables.conversationId],
});
// Also invalidate the conversations list to update any cached data
queryClient.invalidateQueries({
queryKey: ["user", "conversations"],
});
},
});
};
+1 -3
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import GitService from "#/api/git-service/git-service.api";
import { useUserProviders } from "../use-user-providers";
@@ -10,7 +9,6 @@ import { shouldUseInstallationRepos } from "#/utils/utils";
* Get the first page of app installations for the provider given.
*/
export const useAppInstallations = (selectedProvider: Provider | null) => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
@@ -20,7 +18,7 @@ export const useAppInstallations = (selectedProvider: Provider | null) => {
enabled:
userIsAuthenticated &&
!!selectedProvider &&
shouldUseInstallationRepos(selectedProvider, config?.app_mode),
shouldUseInstallationRepos(selectedProvider),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
+1 -3
View File
@@ -1,5 +1,4 @@
import { useInfiniteQuery, InfiniteData } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { useAppInstallations } from "./use-app-installations";
import { RepositoryPage } from "../../types/git";
@@ -20,12 +19,11 @@ type Cursor = InstallationCursor | UserCursor;
export function useGitRepositories(options: UseGitRepositoriesOptions) {
const { provider, pageSize = 30, enabled = true } = options;
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: page } = useAppInstallations(provider);
const installations = page?.items;
const useInstallationRepos = provider
? shouldUseInstallationRepos(provider, config?.app_mode)
? shouldUseInstallationRepos(provider)
: false;
const repos = useInfiniteQuery<
+1 -4
View File
@@ -1,13 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { usePostHog } from "posthog-js/react";
import { useConfig } from "./use-config";
import UserService from "#/api/user-service/user-service.api";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
export const useGitUser = () => {
const posthog = usePostHog();
const { data: config } = useConfig();
const shouldFetchUser = useShouldShowUserFeatures();
const user = useQuery({
@@ -26,10 +24,9 @@ export const useGitUser = () => {
name: user.data.name,
email: user.data.email,
user: user.data.login,
mode: config?.app_mode || "oss",
});
}
}, [config?.app_mode, posthog, user.data]);
}, [posthog, user.data]);
return user;
};
-51
View File
@@ -1,51 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { openHands } from "#/api/open-hands-axios";
import { useConfig } from "./use-config";
export const LLM_API_KEY_QUERY_KEY = "llm-api-key";
export interface LlmApiKeyResponse {
key: string | null;
}
export interface LlmApiKeyError {
isPaymentRequired: boolean;
message?: string;
}
export function useLlmApiKey() {
const { data: config } = useConfig();
const query = useQuery({
queryKey: [LLM_API_KEY_QUERY_KEY],
enabled: config?.app_mode === "saas",
queryFn: async () => {
const { data } =
await openHands.get<LlmApiKeyResponse>("/api/keys/llm/byor");
return data;
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
retry: (failureCount, error) => {
// Don't retry on 402 Payment Required
if (error instanceof AxiosError && error.response?.status === 402) {
return false;
}
return failureCount < 3;
},
// Disable global error toast - we handle 402 errors in the UI
meta: { disableToast: true },
});
// Check if the error is a 402 Payment Required
const isPaymentRequired =
query.error instanceof AxiosError && query.error.response?.status === 402;
return {
data: query.data,
error: query.error,
isLoading: query.isLoading,
isPaymentRequired,
};
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { parseMcpConfig } from "#/utils/mcp-config";
export const getErrorStatus = (error: unknown): number | undefined => {
if (typeof error === "object" && error !== null && "status" in error) {
const status = (error as { status?: unknown }).status;
const { status } = error as { status?: unknown };
if (typeof status === "number") {
return status;
}
-43
View File
@@ -1,43 +0,0 @@
import { useMemo } from "react";
import { useConfig } from "#/hooks/query/use-config";
/**
* Hook that provides boolean checks for app mode deployment mode.
*
* App Mode (app_mode):
* - "oss": Open source version running locally/self-hosted
* - "saas": All-Hands managed SaaS version
*
* Deployment Mode (deployment_mode):
* - "cloud": Enterprise customers running on All-Hands managed infrastructure (*.all-hands.dev, *.openhands.ai)
* - "self_hosted": Enterprise customers running on their own infrastructure
*
* Note: SaaS mode can have either cloud or self_hosted deployment mode.
*/
export function useAppMode() {
const { data: config } = useConfig();
return useMemo(() => {
const appMode = config?.app_mode;
const deploymentMode = config?.feature_flags?.deployment_mode;
return {
// App Mode checks
isOss: appMode === "oss",
isSaas: appMode === "saas",
// Deployment Mode checks
isCloud: deploymentMode === "cloud",
isSelfHosted: deploymentMode === "self_hosted",
/** Enterprise checks */
isEnterpriseSelfHosted:
appMode === "saas" && deploymentMode === "self_hosted",
isEnterpriseCloud: appMode === "saas" && deploymentMode === "cloud",
// Raw values (for cases where the actual value is needed)
appMode,
deploymentMode,
};
}, [config?.app_mode, config?.feature_flags?.deployment_mode]);
}
-20
View File
@@ -1,20 +0,0 @@
import { generateAuthUrl } from "#/utils/generate-auth-url";
import { WebClientConfig } from "#/api/option-service/option.types";
interface UseAuthUrlConfig {
appMode: WebClientConfig["app_mode"] | null;
identityProvider: string;
authUrl?: WebClientConfig["auth_url"];
}
export const useAuthUrl = (config: UseAuthUrlConfig) => {
if (config.appMode === "saas") {
return generateAuthUrl(
config.identityProvider,
new URL(window.location.href),
config.authUrl,
);
}
return null;
};
@@ -1,15 +1,10 @@
import { useTranslation } from "react-i18next";
import React from "react";
import { useNavigation } from "#/context/navigation-context";
import useMetricsStore from "#/stores/metrics-store";
import { useDeleteConversation } from "./mutation/use-delete-conversation";
import { useUnifiedPauseConversationSandbox } from "./mutation/use-unified-stop-conversation";
import { useUpdateConversationPublicFlag } from "./mutation/use-update-conversation-public-flag";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { useEventStore } from "#/stores/use-event-store";
import { useActiveConversation } from "./query/use-active-conversation";
import { useDownloadConversation } from "./use-download-conversation";
import {
adaptSystemMessage,
@@ -30,13 +25,10 @@ export function useConversationNameContextMenu({
showOptions = false,
onContextMenuToggle,
}: UseConversationNameContextMenuProps) {
const { t } = useTranslation("openhands");
const { conversationId: currentConversationId, navigate } = useNavigation();
const events = useEventStore((state) => state.events);
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
const { mutate: updatePublicFlag } = useUpdateConversationPublicFlag();
const { data: conversation } = useActiveConversation();
const metrics = useMetricsStore();
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
@@ -131,41 +123,6 @@ export function useConversationNameContextMenu({
onContextMenuToggle?.(false);
};
const handleTogglePublic = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId && conversation) {
// Toggle the current public state
const newPublicState = !conversation.public;
updatePublicFlag({
conversationId,
isPublic: newPublicState,
});
}
// Don't close menu - let user see the toggle state change
};
const shareUrl = React.useMemo(() => {
if (conversationId) {
return `${window.location.origin}/shared/conversations/${conversationId}`;
}
return "";
}, [conversationId]);
const handleCopyShareLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!shareUrl) {
onContextMenuToggle?.(false);
return;
}
navigator.clipboard.writeText(shareUrl);
displaySuccessToast(t(I18nKey.CONVERSATION$LINK_COPIED));
};
return {
// Handlers
handleDelete,
@@ -176,9 +133,6 @@ export function useConversationNameContextMenu({
handleShowAgentTools,
handleShowSkills,
handleShowHooks,
handleTogglePublic,
handleCopyShareLink,
shareUrl,
handleConfirmDelete,
handleConfirmStop,
-14
View File
@@ -1,14 +0,0 @@
import { useAuthUrl } from "./use-auth-url";
import { WebClientConfig } from "#/api/option-service/option.types";
interface UseGitHubAuthUrlConfig {
appMode: WebClientConfig["app_mode"] | null;
authUrl?: WebClientConfig["auth_url"];
}
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
useAuthUrl({
appMode: config.appMode,
identityProvider: "github",
authUrl: config.authUrl,
});
+1 -1
View File
@@ -7,7 +7,7 @@ export const useIsCreatingConversation = () => {
mutationKey: ["create-conversation"],
});
const isNavigating = navigation.isNavigating;
const { isNavigating } = navigation;
const hasPendingMutations = numberOfPendingMutations > 0;
return hasPendingMutations || isNavigating;
+1 -89
View File
@@ -1,21 +1,17 @@
import { usePostHog } from "posthog-js/react";
import { useConfig } from "./query/use-config";
import { useSettings } from "./query/use-settings";
import { Provider } from "#/types/settings";
/**
* Hook that provides tracking functions with automatic data collection
* from available hooks (config, settings, etc.)
* from available hooks (settings, etc.)
*/
export const useTracking = () => {
const posthog = usePostHog();
const { data: config } = useConfig();
const { data: settings } = useSettings();
// Common properties included in all tracking events
const commonProperties = {
app_surface: config?.app_mode || "unknown",
plan_tier: null,
current_url: window.location.href,
user_email: settings?.email || settings?.git_user_email || null,
};
@@ -74,84 +70,6 @@ export const useTracking = () => {
});
};
const trackCreditsPurchased = ({
amountUsd,
stripeSessionId,
}: {
amountUsd: number;
stripeSessionId: string;
}) => {
posthog.capture("credits_purchased", {
amount_usd: amountUsd,
stripe_session_id: stripeSessionId,
...commonProperties,
});
};
const trackCreditLimitReached = ({
conversationId,
}: {
conversationId: string;
}) => {
posthog.capture("credit_limit_reached", {
conversation_id: conversationId,
...commonProperties,
});
};
const trackAddTeamMembersButtonClick = () => {
posthog.capture("exp_add_team_members", {
...commonProperties,
});
};
const trackOnboardingCompleted = ({
role,
orgSize,
useCase,
}: {
role?: string;
orgSize?: string;
useCase?: string[];
}) => {
posthog.capture("onboarding_completed", {
role,
org_size: orgSize,
use_case: useCase,
...commonProperties,
});
};
const trackSaasSelfhostedInquiry = ({ location }: { location: string }) => {
posthog.capture("saas_selfhosted_inquiry", {
location,
...commonProperties,
});
};
const trackEnterpriseLeadFormSubmitted = ({
requestType,
name,
company,
email,
message,
}: {
requestType: "saas" | "self-hosted";
name: string;
company: string;
email: string;
message: string;
}) => {
posthog.capture("enterprise_lead_form_submitted", {
request_type: requestType,
name,
company,
email,
message,
...commonProperties,
});
};
return {
trackLoginButtonClick,
trackConversationCreated,
@@ -160,11 +78,5 @@ export const useTracking = () => {
trackCreatePrButtonClick,
trackGitProviderConnected,
trackUserSignupCompleted,
trackCreditsPurchased,
trackCreditLimitReached,
trackAddTeamMembersButtonClick,
trackOnboardingCompleted,
trackSaasSelfhostedInquiry,
trackEnterpriseLeadFormSubmitted,
};
};
File diff suppressed because it is too large Load Diff
-24
View File
@@ -2,32 +2,8 @@
/// <reference types="vite-plugin-svgr/client" />
interface Window {
__APP_MODE__?: "saas" | "oss";
__GITHUB_CLIENT_ID__?: string | null;
posthog?: {
capture: (event: string, properties?: Record<string, unknown>) => void;
};
Reo?: {
init: (config: { clientID: string }) => void;
identify: (identity: {
username: string;
type: "github" | "email";
other_identities?: Array<{
username: string;
type: "github" | "email";
}>;
firstname?: string;
lastname?: string;
company?: string;
}) => void;
};
grecaptcha?: {
enterprise: {
ready: (callback: () => void) => void;
execute: (
siteKey: string,
options: { action: string },
) => Promise<string>;
};
};
}
-22
View File
@@ -1,22 +0,0 @@
import { delay, http, HttpResponse } from "msw";
export const BILLING_HANDLERS = [
http.get("/api/billing/credits", async () => {
await delay();
return HttpResponse.json({ credits: "100" });
}),
http.post("/api/billing/create-checkout-session", async () => {
await delay();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-checkout",
});
}),
http.post("/api/billing/create-customer-setup-session", async () => {
await delay();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-customer-setup",
});
}),
];
+18 -15
View File
@@ -110,23 +110,26 @@ export const CONVERSATION_HANDLERS = [
});
}),
http.patch("/api/conversations/:conversationId", async ({ params, request }) => {
const conversationId = params.conversationId as string;
const conversation = CONVERSATIONS.get(conversationId);
http.patch(
"/api/conversations/:conversationId",
async ({ params, request }) => {
const conversationId = params.conversationId as string;
const conversation = CONVERSATIONS.get(conversationId);
if (conversation) {
const body = (await request.json()) as { title?: string } | null;
if (body?.title) {
CONVERSATIONS.set(conversationId, {
...conversation,
title: body.title,
updated_at: new Date().toISOString(),
});
return HttpResponse.json(null, { status: 200 });
if (conversation) {
const body = (await request.json()) as { title?: string } | null;
if (body?.title) {
CONVERSATIONS.set(conversationId, {
...conversation,
title: body.title,
updated_at: new Date().toISOString(),
});
return HttpResponse.json(null, { status: 200 });
}
}
}
return HttpResponse.json(null, { status: 404 });
}),
return HttpResponse.json(null, { status: 404 });
},
),
http.delete("/api/conversations/:conversationId", async ({ params }) => {
const conversationId = params.conversationId as string;
+2 -3
View File
@@ -232,8 +232,7 @@ export const GIT_REPOSITORY_HANDLERS = [
http.get("/api/git/changes", async () => HttpResponse.json([])),
http.get(
"/api/git/diff",
async () => HttpResponse.json({ original: "", modified: "" }),
http.get("/api/git/diff", async () =>
HttpResponse.json({ original: "", modified: "" }),
),
];
-2
View File
@@ -1,5 +1,4 @@
import { API_KEYS_HANDLERS } from "./api-keys-handlers";
import { BILLING_HANDLERS } from "./billing-handlers";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
import { SECRETS_HANDLERS } from "./secrets-handlers";
@@ -16,7 +15,6 @@ import { ANALYTICS_HANDLERS } from "./analytics-handlers";
export const handlers = [
...API_KEYS_HANDLERS,
...BILLING_HANDLERS,
...FILE_SERVICE_HANDLERS,
...TASK_SUGGESTIONS_HANDLERS,
...SECRETS_HANDLERS,
-13
View File
@@ -41,27 +41,22 @@ const DEFAULT_MODEL =
export const createMockWebClientConfig = (
overrides: Partial<WebClientConfig> = {},
): WebClientConfig => ({
app_mode: "oss",
posthog_client_key: "test-posthog-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
...overrides.feature_flags,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: null,
...overrides,
});
@@ -447,29 +442,22 @@ export const SETTINGS_HANDLERS = [
),
http.get("/api/v1/web-client/config", () => {
const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
const config: WebClientConfig = {
app_mode: mockSaas ? "saas" : "oss",
posthog_client_key: "fake-posthog-client-key",
feature_flags: {
enable_billing: mockSaas,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: mockSaas ? "openhands" : null,
};
return HttpResponse.json(config);
@@ -566,5 +554,4 @@ export const SETTINGS_HANDLERS = [
return HttpResponse.json(null, { status: 400 });
}),
];
-61
View File
@@ -18,7 +18,6 @@ import {
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
import { useConfig } from "#/hooks/query/use-config";
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
import {
SandboxGroupingStrategy,
@@ -32,7 +31,6 @@ export function AppSettingsScreen() {
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: settings, isLoading } = useSettings();
const { data: config } = useConfig();
const [languageInputHasChanged, setLanguageInputHasChanged] =
React.useState(false);
@@ -42,14 +40,6 @@ export function AppSettingsScreen() {
soundNotificationsSwitchHasChanged,
setSoundNotificationsSwitchHasChanged,
] = React.useState(false);
const [
proactiveConversationsSwitchHasChanged,
setProactiveConversationsSwitchHasChanged,
] = React.useState(false);
const [
solvabilityAnalysisSwitchHasChanged,
setSolvabilityAnalysisSwitchHasChanged,
] = React.useState(false);
const [
sandboxGroupingStrategyHasChanged,
setSandboxGroupingStrategyHasChanged,
@@ -75,13 +65,6 @@ export function AppSettingsScreen() {
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const enableProactiveConversations =
formData.get("enable-proactive-conversations-switch")?.toString() ===
"on";
const enableSolvabilityAnalysis =
formData.get("enable-solvability-analysis-switch")?.toString() === "on";
const sandboxGroupingStrategy =
selectedSandboxGroupingStrategy ||
settings?.sandbox_grouping_strategy ||
@@ -104,8 +87,6 @@ export function AppSettingsScreen() {
language,
user_consents_to_analytics: enableAnalytics,
enable_sound_notifications: enableSoundNotifications,
enable_proactive_conversation_starters: enableProactiveConversations,
enable_solvability_analysis: enableSolvabilityAnalysis,
sandbox_grouping_strategy: sandboxGroupingStrategy,
max_budget_per_task: maxBudgetPerTask,
git_user_name: gitUserName,
@@ -124,7 +105,6 @@ export function AppSettingsScreen() {
setLanguageInputHasChanged(false);
setAnalyticsSwitchHasChanged(false);
setSoundNotificationsSwitchHasChanged(false);
setProactiveConversationsSwitchHasChanged(false);
setSandboxGroupingStrategyHasChanged(false);
setSelectedSandboxGroupingStrategy(null);
setMaxBudgetPerTaskHasChanged(false);
@@ -159,21 +139,6 @@ export function AppSettingsScreen() {
);
};
const checkIfProactiveConversationsSwitchHasChanged = (checked: boolean) => {
const currentProactiveConversations =
!!settings?.enable_proactive_conversation_starters;
setProactiveConversationsSwitchHasChanged(
checked !== currentProactiveConversations,
);
};
const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => {
const currentSolvabilityAnalysis = !!settings?.enable_solvability_analysis;
setSolvabilityAnalysisSwitchHasChanged(
checked !== currentSolvabilityAnalysis,
);
};
const handleSandboxGroupingStrategyChange = (key: React.Key | null) => {
const newStrategy = key?.toString() as SandboxGroupingStrategy | undefined;
setSelectedSandboxGroupingStrategy(newStrategy || null);
@@ -203,8 +168,6 @@ export function AppSettingsScreen() {
!languageInputHasChanged &&
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged &&
!proactiveConversationsSwitchHasChanged &&
!solvabilityAnalysisSwitchHasChanged &&
!sandboxGroupingStrategyHasChanged &&
!maxBudgetPerTaskHasChanged &&
!gitUserNameHasChanged &&
@@ -245,30 +208,6 @@ export function AppSettingsScreen() {
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
{config?.app_mode === "saas" && (
<SettingsSwitch
testId="enable-proactive-conversations-switch"
name="enable-proactive-conversations-switch"
defaultIsToggled={
!!settings.enable_proactive_conversation_starters
}
onToggle={checkIfProactiveConversationsSwitchHasChanged}
>
{t(I18nKey.SETTINGS$PROACTIVE_CONVERSATION_STARTERS)}
</SettingsSwitch>
)}
{config?.app_mode === "saas" && (
<SettingsSwitch
testId="enable-solvability-analysis-switch"
name="enable-solvability-analysis-switch"
defaultIsToggled={!!settings.enable_solvability_analysis}
onToggle={checkIfSolvabilityAnalysisSwitchHasChanged}
>
{t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)}
</SettingsSwitch>
)}
{ENABLE_SANDBOX_GROUPING() && (
<SettingsDropdownInput
testId="sandbox-grouping-strategy-input"
-14
View File
@@ -6,22 +6,14 @@ import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestio
import { GitRepository } from "#/types/git";
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations";
import { HomepageCTA } from "#/components/features/home/homepage-cta";
import { isCTADismissed } from "#/utils/local-storage";
import { useAppMode } from "#/hooks/use-app-mode";
<PrefetchPageLinks page="/conversations/:conversationId" />;
function HomeScreen() {
const { isEnterpriseCloud } = useAppMode();
const [selectedRepo, setSelectedRepo] = React.useState<GitRepository | null>(
null,
);
const [shouldShowCTA, setShouldShowCTA] = React.useState(
() => !isCTADismissed("homepage"),
);
return (
<div
data-testid="home-screen"
@@ -48,12 +40,6 @@ function HomeScreen() {
<TaskSuggestions filterFor={selectedRepo} />
</div>
</div>
{isEnterpriseCloud && shouldShowCTA && (
<div className="fixed bottom-4 right-8 z-50 md:bottom-6 md:right-12">
<HomepageCTA setShouldShowCTA={setShouldShowCTA} />
</div>
)}
</div>
);
}
+40 -131
View File
@@ -1,22 +1,16 @@
import React from "react";
import { useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema";
import { useSettings } from "#/hooks/query/use-settings";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { HelpLink } from "#/ui/help-link";
import { useConfig } from "#/hooks/query/use-config";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import {
SdkSectionHeaderProps,
SdkSectionPage,
} from "#/components/features/settings/sdk-settings/sdk-section-page";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { Settings, SettingsSchema, SettingsScope } from "#/types/settings";
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
import {
@@ -86,26 +80,13 @@ function OpenHandsApiKeyHelp({ testId }: OpenHandsApiKeyHelpProps) {
const { t } = useTranslation("openhands");
return (
<>
<HelpLink
testId={testId}
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
href="https://app.all-hands.dev/settings/api-keys"
suffix={` ${t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}`}
/>
<p className="text-xs">
{t(I18nKey.SETTINGS$LLM_BILLING_INFO)}{" "}
<a
href="https://docs.openhands.dev/usage/llms/openhands-llms"
rel="noreferrer noopener"
target="_blank"
className="underline underline-offset-2"
>
{t(I18nKey.SETTINGS$SEE_PRICING_DETAILS)}
</a>
</p>
</>
<HelpLink
testId={testId}
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
href="https://app.all-hands.dev/settings/api-keys"
suffix={` ${t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}`}
/>
);
}
@@ -115,60 +96,22 @@ export function LlmSettingsScreen({
scope?: SettingsScope;
}) {
const { t } = useTranslation("openhands");
const [searchParams, setSearchParams] = useSearchParams();
const { data: settings } = useSettings(scope);
const { data: schema } = useAgentSettingsSchema(
settings?.agent_settings_schema,
);
const { data: config } = useConfig();
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
null,
);
const hasHydratedInitialPersonalSaasViewRef = React.useRef(false);
const defaultModel = String(
(DEFAULT_SETTINGS.agent_settings?.llm as Record<string, unknown>)?.model ??
"",
);
const isSaasMode = config?.app_mode === "saas";
React.useEffect(() => {
if (settings?.llm_model) {
const { provider } = extractModelAndProvider(settings.llm_model);
setSelectedProvider(provider || null);
}
}, [settings?.llm_model]);
React.useEffect(() => {
if (settings && isSaasMode) {
hasHydratedInitialPersonalSaasViewRef.current = true;
}
}, [isSaasMode, settings]);
React.useEffect(() => {
const checkout = searchParams.get("checkout");
if (checkout === "success") {
displaySuccessToast(t(I18nKey.SUBSCRIPTION$SUCCESS));
setSearchParams({});
} else if (checkout === "cancel") {
displayErrorToast(t(I18nKey.SUBSCRIPTION$FAILURE));
setSearchParams({});
}
}, [searchParams, setSearchParams, t]);
const getInitialView = React.useCallback(
(
currentSettings: Settings,
filteredSchema: SettingsSchema,
): SettingsView => {
if (isSaasMode && !hasHydratedInitialPersonalSaasViewRef.current) {
return "basic";
}
const schemaView = inferInitialView(currentSettings, filteredSchema);
if (schemaView !== "basic") {
return schemaView;
@@ -182,7 +125,7 @@ export function LlmSettingsScreen({
return hasCustomBaseUrl ? "all" : "basic";
},
[isSaasMode],
[],
);
const buildHeader = React.useCallback(
@@ -193,53 +136,38 @@ export function LlmSettingsScreen({
typeof values["llm.base_url"] === "string"
? values["llm.base_url"]
: "";
const derivedProvider = modelValue
? extractModelAndProvider(modelValue).provider || null
: null;
const activeProvider =
view === "basic"
? (selectedProvider ?? derivedProvider)
: derivedProvider;
const shouldUseOpenHandsKey =
isSaasMode && activeProvider === "openhands";
const showOpenHandsApiKeyHelp = modelValue.startsWith("openhands/");
const renderApiKeyInput = (testId: string, helpTestId: string) => {
if (shouldUseOpenHandsKey) {
return null;
}
const renderApiKeyInput = (testId: string, helpTestId: string) => (
<>
<SettingsInput
testId={testId}
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-full"
value={
typeof values["llm.api_key"] === "string"
? values["llm.api_key"]
: ""
}
placeholder={settings?.llm_api_key_set ? "<hidden>" : ""}
onChange={(value) => onChange("llm.api_key", value)}
isDisabled={isDisabled}
startContent={
settings?.llm_api_key_set ? (
<KeyStatusIcon isSet={settings.llm_api_key_set} />
) : undefined
}
/>
return (
<>
<SettingsInput
testId={testId}
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-full"
value={
typeof values["llm.api_key"] === "string"
? values["llm.api_key"]
: ""
}
placeholder={settings?.llm_api_key_set ? "<hidden>" : ""}
onChange={(value) => onChange("llm.api_key", value)}
isDisabled={isDisabled}
startContent={
settings?.llm_api_key_set ? (
<KeyStatusIcon isSet={settings.llm_api_key_set} />
) : undefined
}
/>
<HelpLink
testId={helpTestId}
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.openhands.dev/usage/local-setup#getting-an-api-key"
/>
</>
);
};
<HelpLink
testId={helpTestId}
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.openhands.dev/usage/local-setup#getting-an-api-key"
/>
</>
);
return (
<div className="flex flex-col gap-6">
@@ -251,7 +179,6 @@ export function LlmSettingsScreen({
<ModelSelector
currentModel={modelValue || undefined}
onChange={(provider, model) => {
setSelectedProvider(provider);
const nextModel = buildModelId(provider, model);
if (nextModel) {
onChange("llm.model", nextModel);
@@ -310,7 +237,7 @@ export function LlmSettingsScreen({
</div>
);
},
[isSaasMode, defaultModel, selectedProvider, settings?.llm_api_key_set, t],
[defaultModel, settings?.llm_api_key_set, t],
);
const buildPayload = React.useCallback(
@@ -324,25 +251,7 @@ export function LlmSettingsScreen({
// basePayload is a nested dict (e.g. {llm: {model: "gpt-4"}})
const agentSettings = structuredClone(basePayload);
const modelValue =
typeof context.values["llm.model"] === "string"
? context.values["llm.model"]
: "";
const derivedProvider = modelValue
? extractModelAndProvider(modelValue).provider || null
: null;
const activeProvider =
context.view === "basic"
? (selectedProvider ?? derivedProvider)
: derivedProvider;
const shouldUseOpenHandsKey =
isSaasMode && activeProvider === "openhands";
const llm = (agentSettings.llm ?? {}) as Record<string, unknown>;
if (shouldUseOpenHandsKey && llm.model !== undefined) {
llm.api_key = "";
agentSettings.llm = llm;
}
if (context.view === "basic") {
llm.base_url = getSchemaFieldDefaultValue(schema, "llm.base_url");
@@ -351,7 +260,7 @@ export function LlmSettingsScreen({
return { agent_settings_diff: agentSettings };
},
[isSaasMode, schema, selectedProvider],
[schema],
);
return (
@@ -363,7 +272,7 @@ export function LlmSettingsScreen({
buildPayload={buildPayload}
getInitialView={getInitialView}
forceShowAdvancedView
allowAllView={!isSaasMode}
allowAllView
testId="llm-settings-screen"
/>
);
+51 -59
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react";
import { AxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { useSettings } from "#/hooks/query/use-settings";
import { useConfig } from "#/hooks/query/use-config";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useDeleteMcpServer } from "#/hooks/mutation/use-delete-mcp-server";
import { useAddMcpServer } from "#/hooks/mutation/use-add-mcp-server";
@@ -44,7 +43,6 @@ interface MCPServerConfig {
export function MCPSettingsScreen() {
const { t } = useTranslation("openhands");
const { data: settings, isLoading } = useSettings();
const { data: config } = useConfig();
const { mutate: saveSettings, isPending: isSavingSearchApiKey } =
useSaveSettings();
const { mutate: deleteMcpServer } = useDeleteMcpServer();
@@ -61,8 +59,6 @@ export function MCPSettingsScreen() {
useState(false);
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
const isSaasMode = config?.app_mode === "saas";
const mcpConfig: MCPConfig = parseMcpConfig(
settings?.agent_settings?.mcp_config,
);
@@ -219,62 +215,58 @@ export function MCPSettingsScreen() {
onDelete={handleDeleteClick}
/>
{!isSaasMode ? (
<section
data-testid="mcp-search-settings-section"
className="flex flex-col gap-4 rounded-2xl border border-tertiary p-5"
>
<div className="flex flex-col gap-2">
<Typography.H3>
{t(I18nKey.SETTINGS$MCP_SEARCH_TITLE)}
</Typography.H3>
<Typography.Paragraph className="text-sm text-[#A3A3A3]">
{t(I18nKey.SETTINGS$MCP_SEARCH_DESCRIPTION)}
</Typography.Paragraph>
<section
data-testid="mcp-search-settings-section"
className="flex flex-col gap-4 rounded-2xl border border-tertiary p-5"
>
<div className="flex flex-col gap-2">
<Typography.H3>{t(I18nKey.SETTINGS$MCP_SEARCH_TITLE)}</Typography.H3>
<Typography.Paragraph className="text-sm text-[#A3A3A3]">
{t(I18nKey.SETTINGS$MCP_SEARCH_DESCRIPTION)}
</Typography.Paragraph>
</div>
<div className="max-w-xl flex flex-col gap-4">
<SettingsInput
testId="search-api-key-input"
label={t(I18nKey.SETTINGS$SEARCH_API_KEY)}
type="password"
className="w-full"
value={searchApiKey}
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
onChange={(value) => {
setSearchApiKey(value);
setSearchApiKeyDirty(value !== (settings.search_api_key ?? ""));
}}
startContent={
settings.search_api_key_set ? (
<KeyStatusIcon isSet={settings.search_api_key_set} />
) : undefined
}
/>
<HelpLink
testId="search-api-key-help-anchor"
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
href="https://tavily.com/"
/>
<div>
<BrandButton
testId="save-search-api-key-button"
type="button"
variant="primary"
isDisabled={isSavingSearchApiKey || !searchApiKeyDirty}
onClick={handleSaveSearchApiKey}
>
{isSavingSearchApiKey
? t(I18nKey.SETTINGS$SAVING)
: t(I18nKey.SETTINGS$SAVE_CHANGES)}
</BrandButton>
</div>
<div className="max-w-xl flex flex-col gap-4">
<SettingsInput
testId="search-api-key-input"
label={t(I18nKey.SETTINGS$SEARCH_API_KEY)}
type="password"
className="w-full"
value={searchApiKey}
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
onChange={(value) => {
setSearchApiKey(value);
setSearchApiKeyDirty(value !== (settings.search_api_key ?? ""));
}}
startContent={
settings.search_api_key_set ? (
<KeyStatusIcon isSet={settings.search_api_key_set} />
) : undefined
}
/>
<HelpLink
testId="search-api-key-help-anchor"
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
href="https://tavily.com/"
/>
<div>
<BrandButton
testId="save-search-api-key-button"
type="button"
variant="primary"
isDisabled={isSavingSearchApiKey || !searchApiKeyDirty}
onClick={handleSaveSearchApiKey}
>
{isSavingSearchApiKey
? t(I18nKey.SETTINGS$SAVING)
: t(I18nKey.SETTINGS$SAVE_CHANGES)}
</BrandButton>
</div>
</div>
</section>
) : null}
</div>
</section>
{confirmationModalIsVisible && serverToDelete && (
<ConfirmationModal
-1
View File
@@ -12,7 +12,6 @@ export type RuntimeStatus =
| "STATUS$ERROR_LLM_AUTHENTICATION"
| "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE"
| "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR"
| "STATUS$ERROR_LLM_OUT_OF_CREDITS"
| "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION"
| "CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE"
| "STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR"
-1
View File
@@ -5,7 +5,6 @@ export const ProviderOptions = {
bitbucket_data_center: "bitbucket_data_center",
azure_devops: "azure_devops",
forgejo: "forgejo",
enterprise_sso: "enterprise_sso",
} as const;
export const SandboxGroupingStrategyOptions = {
-30
View File
@@ -1,30 +0,0 @@
import { cn } from "#/utils/utils";
interface CreditsChipProps {
testId?: string;
className?: string;
}
/**
* Chip component for displaying credits amount
* Uses yellow background with black text for visibility
*/
export function CreditsChip({
children,
testId,
className,
}: React.PropsWithChildren<CreditsChipProps>) {
return (
<div
data-testid={testId}
data-openhands-chip
style={{ minWidth: "100px" }}
className={cn(
"bg-[#FFE165] px-4 rounded-[100px] text-black text-lg text-center font-semibold",
className,
)}
>
{children}
</div>
);
}
-8
View File
@@ -50,11 +50,3 @@ export function showChatError({
status_update: true,
});
}
/**
* Checks if an error message indicates a budget or credit limit issue
*/
export function isBudgetOrCreditError(errorMessage: string): boolean {
const lowerCaseError = errorMessage.toLowerCase();
return lowerCaseError.includes("budget") || lowerCaseError.includes("credit");
}
-1
View File
@@ -12,7 +12,6 @@ export function loadFeatureFlag(
}
}
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
-44
View File
@@ -1,44 +0,0 @@
/**
* Generates a URL to redirect to for OAuth authentication
* @param identityProvider The identity provider to use (e.g., "github", "gitlab", "bitbucket", "azure_devops")
* @param requestUrl The URL of the request
* @returns The URL to redirect to for OAuth
*/
export const generateAuthUrl = (
identityProvider: string,
requestUrl: URL,
authUrl?: string | null,
) => {
// Use HTTPS protocol unless the host is localhost
const protocol =
requestUrl.hostname === "localhost" ? requestUrl.protocol : "https:";
const redirectUri = `${protocol}//${requestUrl.host}/oauth/keycloak/callback`;
let finalAuthUrl: string;
if (authUrl) {
// Ensure https:// is prepended and remove any accidental duplicate slashes
finalAuthUrl = `https://${authUrl.replace(/^https?:\/\//, "")}`;
} else {
finalAuthUrl = requestUrl.hostname
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
// If no replacements matched, prepend "auth." (excluding localhost)
if (
finalAuthUrl === requestUrl.hostname &&
requestUrl.hostname !== "localhost"
) {
finalAuthUrl = `auth.${requestUrl.hostname}`;
}
finalAuthUrl = `https://${finalAuthUrl}`;
}
const scope = "openid email profile"; // OAuth scope - not user-facing
const separator = requestUrl.search ? "&" : "?";
const cleanHref = requestUrl.href.replace(/\/$/, "");
const state = `${cleanHref}${separator}login_method=${identityProvider}`;
return `${finalAuthUrl}/realms/allhands/protocol/openid-connect/auth?client_id=allhands&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}`;
};
+3 -1
View File
@@ -7,7 +7,9 @@ type ClientGitChangeStatus = "added" | "modified" | "deleted" | "renamed";
type SupportedGitStatus = V1GitChangeStatus | ClientGitChangeStatus;
export function mapAnyGitStatusToV0Status(status: SupportedGitStatus): GitChangeStatus {
export function mapAnyGitStatusToV0Status(
status: SupportedGitStatus,
): GitChangeStatus {
switch (status) {
case "ADDED":
case "added":
-78
View File
@@ -1,8 +1,6 @@
// Local storage keys
export const LOCAL_STORAGE_KEYS = {
LOGIN_METHOD: "openhands_login_method",
ENTERPRISE_FORM_SAAS: "openhands_enterprise_form_saas",
ENTERPRISE_FORM_SELF_HOSTED: "openhands_enterprise_form_self_hosted",
};
// Login methods
@@ -12,7 +10,6 @@ export enum LoginMethod {
BITBUCKET = "bitbucket",
BITBUCKET_DATA_CENTER = "bitbucket_data_center",
AZURE_DEVOPS = "azure_devops",
ENTERPRISE_SSO = "enterprise_sso",
}
/**
@@ -38,78 +35,3 @@ export const getLoginMethod = (): LoginMethod | null => {
export const clearLoginData = (): void => {
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD);
};
// CTA locations that can be dismissed
export type CTALocation = "homepage";
// Generate storage key for a CTA location
const getCTAKey = (location: CTALocation): string =>
`${location}-cta-dismissed`;
/**
* Set a CTA as dismissed in local storage (persists across tabs)
* @param location The CTA location to dismiss
*/
export const setCTADismissed = (location: CTALocation): void => {
localStorage.setItem(getCTAKey(location), "true");
};
/**
* Check if a CTA has been dismissed
* @param location The CTA location to check
* @returns true if dismissed, false otherwise
*/
export const isCTADismissed = (location: CTALocation): boolean =>
localStorage.getItem(getCTAKey(location)) === "true";
// Enterprise form data types
export type EnterpriseFormType = "saas" | "self-hosted";
export interface EnterpriseFormData {
name: string;
company: string;
email: string;
message: string;
}
const getEnterpriseFormKey = (formType: EnterpriseFormType): string =>
formType === "saas"
? LOCAL_STORAGE_KEYS.ENTERPRISE_FORM_SAAS
: LOCAL_STORAGE_KEYS.ENTERPRISE_FORM_SELF_HOSTED;
/**
* Save enterprise form data to localStorage
* @param formType The type of form (saas or self-hosted)
* @param data The form data to save
*/
export const saveEnterpriseFormData = (
formType: EnterpriseFormType,
data: EnterpriseFormData,
): void => {
localStorage.setItem(getEnterpriseFormKey(formType), JSON.stringify(data));
};
/**
* Get enterprise form data from localStorage
* @param formType The type of form (saas or self-hosted)
* @returns The saved form data or null if not found
*/
export const getEnterpriseFormData = (
formType: EnterpriseFormType,
): EnterpriseFormData | null => {
const data = localStorage.getItem(getEnterpriseFormKey(formType));
if (!data) return null;
try {
return JSON.parse(data) as EnterpriseFormData;
} catch {
return null;
}
};
/**
* Clear enterprise form data from localStorage
* @param formType The type of form (saas or self-hosted)
*/
export const clearEnterpriseFormData = (formType: EnterpriseFormType): void => {
localStorage.removeItem(getEnterpriseFormKey(formType));
};
+1 -1
View File
@@ -1,6 +1,6 @@
import toast from "react-hot-toast";
import i18n from "#/i18n";
import { UseMutationResult } from "@tanstack/react-query";
import i18n from "#/i18n";
import {
TOAST_OPTIONS,
displaySuccessToast,
-106
View File
@@ -1,106 +0,0 @@
/**
* Reo.dev tracking service for SaaS mode
* Tracks developer activity and engagement in the product
* Using CDN approach for better TypeScript compatibility
*/
import EventLogger from "./event-logger";
export interface ReoIdentity {
username: string;
type: "github" | "email";
other_identities?: Array<{
username: string;
type: "github" | "email";
}>;
firstname?: string;
lastname?: string;
company?: string;
}
const REO_CLIENT_ID = "6bac7145b4ee6ec";
class ReoService {
private initialized = false;
private scriptLoaded = false;
/**
* Load and initialize the Reo.dev tracking script from CDN
*/
async init(): Promise<void> {
if (this.initialized) {
return;
}
try {
// Load the Reo script dynamically from CDN
await this.loadScript();
// Initialize Reo with client ID
if (window.Reo) {
window.Reo.init({ clientID: REO_CLIENT_ID });
this.initialized = true;
}
} catch (error) {
EventLogger.error(`Failed to initialize Reo.dev tracking: ${error}`);
}
}
/**
* Load the Reo.dev script from CDN
*/
private loadScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.scriptLoaded) {
resolve();
return;
}
const script = document.createElement("script");
script.src = `https://static.reo.dev/${REO_CLIENT_ID}/reo.js`;
script.defer = true;
script.onload = () => {
this.scriptLoaded = true;
resolve();
};
script.onerror = () => {
reject(new Error("Failed to load Reo.dev script"));
};
document.head.appendChild(script);
});
}
/**
* Identify a user in Reo.dev tracking
* Should be called after successful login
*/
identify(identity: ReoIdentity): void {
if (!this.initialized) {
EventLogger.warning("Reo.dev not initialized. Call init() first.");
return;
}
try {
if (window.Reo) {
window.Reo.identify(identity);
}
} catch (error) {
EventLogger.error(`Failed to identify user in Reo.dev: ${error}`);
}
}
/**
* Check if Reo.dev is initialized
*/
isInitialized(): boolean {
return this.initialized;
}
}
const reoService = new ReoService();
export default reoService;
+4 -1
View File
@@ -54,7 +54,10 @@ export function isSettingsPageHidden(
featureFlags: WebClientFeatureFlags | undefined,
): boolean {
if (featureFlags?.hide_llm_settings && path === "/settings") return true;
if (featureFlags?.hide_integrations_page && path === "/settings/integrations") {
if (
featureFlags?.hide_integrations_page &&
path === "/settings/integrations"
) {
return true;
}
return false;
+1 -10
View File
@@ -161,22 +161,13 @@ export const getFileExtension = (fileName: string): string => {
return extension || "FILE";
};
export const shouldUseInstallationRepos = (
provider: Provider,
app_mode: "saas" | "oss" | undefined,
) => {
export const shouldUseInstallationRepos = (provider: Provider) => {
if (!provider) return false;
switch (provider) {
case "bitbucket":
case "bitbucket_data_center":
return true;
case "gitlab":
return false;
case "azure_devops":
return false;
case "github":
return app_mode === "saas";
default:
return false;
}