mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
fix(frontend): remove SaaS and enterprise terminology (#88)
* fix: remove SaaS and enterprise terminology * refactor: remove unrelated file
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
-2
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,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,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,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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,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
Vendored
-24
@@ -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>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "" }),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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":
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user