&
+ ExtraProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/features/markdown/paragraph.tsx b/frontend/src/components/features/markdown/paragraph.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..97cd933ce86076a4aa9a1fadca669bd8a8cc0cca
--- /dev/null
+++ b/frontend/src/components/features/markdown/paragraph.tsx
@@ -0,0 +1,11 @@
+import React from "react";
+import { ExtraProps } from "react-markdown";
+
+// Custom component to render in markdown with bottom padding
+export function paragraph({
+ children,
+}: React.ClassAttributes &
+ React.HTMLAttributes &
+ ExtraProps) {
+ return {children}
;
+}
diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc337c05dfa89abc1321d639ea966d02124037cb
--- /dev/null
+++ b/frontend/src/components/features/payment/payment-form.tsx
@@ -0,0 +1,86 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
+import { useBalance } from "#/hooks/query/use-balance";
+import { cn } from "#/utils/utils";
+import MoneyIcon from "#/icons/money.svg?react";
+import { SettingsInput } from "../settings/settings-input";
+import { BrandButton } from "../settings/brand-button";
+import { LoadingSpinner } from "#/components/shared/loading-spinner";
+import { amountIsValid } from "#/utils/amount-is-valid";
+import { I18nKey } from "#/i18n/declaration";
+
+export function PaymentForm() {
+ const { t } = useTranslation();
+ const { data: balance, isLoading } = useBalance();
+ const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
+
+ const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
+
+ const billingFormAction = async (formData: FormData) => {
+ const amount = formData.get("top-up-input")?.toString();
+
+ if (amount?.trim()) {
+ if (!amountIsValid(amount)) return;
+
+ const intValue = parseInt(amount, 10);
+ addBalance({ amount: intValue });
+ }
+
+ setButtonIsDisabled(true);
+ };
+
+ const handleTopUpInputChange = (value: string) => {
+ setButtonIsDisabled(!amountIsValid(value));
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/payment/setup-payment-modal.tsx b/frontend/src/components/features/payment/setup-payment-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dfce7d9cd30ff3e01003d1dcaac2be6ab89c5564
--- /dev/null
+++ b/frontend/src/components/features/payment/setup-payment-modal.tsx
@@ -0,0 +1,51 @@
+import { useMutation } from "@tanstack/react-query";
+import { Trans, useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
+import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { ModalBody } from "#/components/shared/modals/modal-body";
+import OpenHands from "#/api/open-hands";
+import { BrandButton } from "../settings/brand-button";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+
+export function SetupPaymentModal() {
+ const { t } = useTranslation();
+ const { mutate, isPending } = useMutation({
+ mutationFn: OpenHands.createBillingSessionResponse,
+ onSuccess: (data) => {
+ window.location.href = data;
+ },
+ onError: () => {
+ displayErrorToast(t(I18nKey.BILLING$ERROR_WHILE_CREATING_SESSION));
+ },
+ });
+
+ return (
+
+
+
+
+
+ {t(I18nKey.BILLING$YOUVE_GOT_50)}
+
+
+ }}
+ />
+
+
+
+ {t(I18nKey.BILLING$PROCEED_TO_STRIPE)}
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/served-host/path-form.tsx b/frontend/src/components/features/served-host/path-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..daec8b7c7ef619d7ad8ff0ac458682519229e260
--- /dev/null
+++ b/frontend/src/components/features/served-host/path-form.tsx
@@ -0,0 +1,19 @@
+interface PathFormProps {
+ ref: React.RefObject;
+ onBlur: () => void;
+ defaultValue: string;
+}
+
+export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/api-key-modal-base.tsx b/frontend/src/components/features/settings/api-key-modal-base.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..43d8ba89f02813f333c30fc9b0f9bc4898c9f478
--- /dev/null
+++ b/frontend/src/components/features/settings/api-key-modal-base.tsx
@@ -0,0 +1,33 @@
+import React, { ReactNode } from "react";
+import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+
+interface ApiKeyModalBaseProps {
+ isOpen: boolean;
+ title: string;
+ width?: string;
+ children: ReactNode;
+ footer: ReactNode;
+}
+
+export function ApiKeyModalBase({
+ isOpen,
+ title,
+ width = "500px",
+ children,
+ footer,
+}: ApiKeyModalBaseProps) {
+ if (!isOpen) return null;
+
+ return (
+
+
+
{title}
+ {children}
+
{footer}
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/api-keys-manager.tsx b/frontend/src/components/features/settings/api-keys-manager.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..99097b141e2ef195b3cc2141ea5ce726485d6361
--- /dev/null
+++ b/frontend/src/components/features/settings/api-keys-manager.tsx
@@ -0,0 +1,160 @@
+import React, { useState } from "react";
+import { useTranslation, Trans } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { BrandButton } from "#/components/features/settings/brand-button";
+import { LoadingSpinner } from "#/components/shared/loading-spinner";
+import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+import { CreateApiKeyModal } from "./create-api-key-modal";
+import { DeleteApiKeyModal } from "./delete-api-key-modal";
+import { NewApiKeyModal } from "./new-api-key-modal";
+import { useApiKeys } from "#/hooks/query/use-api-keys";
+
+export function ApiKeysManager() {
+ const { t } = useTranslation();
+ const { data: apiKeys = [], isLoading, error } = useApiKeys();
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [keyToDelete, setKeyToDelete] = useState(null);
+ const [newlyCreatedKey, setNewlyCreatedKey] =
+ useState(null);
+ const [showNewKeyModal, setShowNewKeyModal] = useState(false);
+
+ // Display error toast if the query fails
+ if (error) {
+ displayErrorToast(t(I18nKey.ERROR$GENERIC));
+ }
+
+ const handleKeyCreated = (newKey: CreateApiKeyResponse) => {
+ setNewlyCreatedKey(newKey);
+ setCreateModalOpen(false);
+ setShowNewKeyModal(true);
+ };
+
+ const handleCloseCreateModal = () => {
+ setCreateModalOpen(false);
+ };
+
+ const handleCloseDeleteModal = () => {
+ setDeleteModalOpen(false);
+ setKeyToDelete(null);
+ };
+
+ const handleCloseNewKeyModal = () => {
+ setShowNewKeyModal(false);
+ setNewlyCreatedKey(null);
+ };
+
+ const formatDate = (dateString: string | null) => {
+ if (!dateString) return "Never";
+ return new Date(dateString).toLocaleString();
+ };
+
+ return (
+ <>
+
+
+ setCreateModalOpen(true)}
+ >
+ {t(I18nKey.SETTINGS$CREATE_API_KEY)}
+
+
+
+
+
+ API documentation
+
+ ),
+ }}
+ />
+
+
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && (
+
+
+
+
+
+ {t(I18nKey.SETTINGS$NAME)}
+ |
+
+ {t(I18nKey.SETTINGS$CREATED_AT)}
+ |
+
+ {t(I18nKey.SETTINGS$LAST_USED)}
+ |
+
+ {t(I18nKey.SETTINGS$ACTIONS)}
+ |
+
+
+
+ {apiKeys.map((key) => (
+
+ {key.name} |
+
+ {formatDate(key.created_at)}
+ |
+
+ {formatDate(key.last_used_at)}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Create API Key Modal */}
+
+
+ {/* Delete API Key Modal */}
+
+
+ {/* Show New API Key Modal */}
+
+ >
+ );
+}
diff --git a/frontend/src/components/features/settings/app-settings/app-settings-inputs-skeleton.tsx b/frontend/src/components/features/settings/app-settings/app-settings-inputs-skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..324d1187549b1a5e67080cd836386badbc30bc58
--- /dev/null
+++ b/frontend/src/components/features/settings/app-settings/app-settings-inputs-skeleton.tsx
@@ -0,0 +1,15 @@
+import { InputSkeleton } from "../input-skeleton";
+import { SwitchSkeleton } from "../switch-skeleton";
+
+export function AppSettingsInputsSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/app-settings/language-input.tsx b/frontend/src/components/features/settings/app-settings/language-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4e41c71f6221f6a13399a1718bad1cf7feb6021b
--- /dev/null
+++ b/frontend/src/components/features/settings/app-settings/language-input.tsx
@@ -0,0 +1,34 @@
+import { useTranslation } from "react-i18next";
+import { AvailableLanguages } from "#/i18n";
+import { I18nKey } from "#/i18n/declaration";
+import { SettingsDropdownInput } from "../settings-dropdown-input";
+
+interface LanguageInputProps {
+ name: string;
+ onChange: (value: string) => void;
+ defaultKey: string;
+}
+
+export function LanguageInput({
+ defaultKey,
+ onChange,
+ name,
+}: LanguageInputProps) {
+ const { t } = useTranslation();
+
+ return (
+ ({
+ key: l.value,
+ label: l.label,
+ }))}
+ defaultSelectedKey={defaultKey}
+ isClearable={false}
+ wrapperClassName="w-full max-w-[680px]"
+ />
+ );
+}
diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ecf9352c4873d9c5d6fb6a7c8773a5555cb29229
--- /dev/null
+++ b/frontend/src/components/features/settings/brand-button.tsx
@@ -0,0 +1,47 @@
+import { cn } from "#/utils/utils";
+
+interface BrandButtonProps {
+ testId?: string;
+ name?: string;
+ variant: "primary" | "secondary" | "danger";
+ type: React.ButtonHTMLAttributes["type"];
+ isDisabled?: boolean;
+ className?: string;
+ onClick?: () => void;
+ startContent?: React.ReactNode;
+}
+
+export function BrandButton({
+ testId,
+ name,
+ children,
+ variant,
+ type,
+ isDisabled,
+ className,
+ onClick,
+ startContent,
+}: React.PropsWithChildren) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/create-api-key-modal.tsx b/frontend/src/components/features/settings/create-api-key-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b97d29f349b64fb65d2684b7130b3a5272d39304
--- /dev/null
+++ b/frontend/src/components/features/settings/create-api-key-modal.tsx
@@ -0,0 +1,101 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { BrandButton } from "#/components/features/settings/brand-button";
+import { SettingsInput } from "#/components/features/settings/settings-input";
+import { LoadingSpinner } from "#/components/shared/loading-spinner";
+import { CreateApiKeyResponse } from "#/api/api-keys";
+import {
+ displayErrorToast,
+ displaySuccessToast,
+} from "#/utils/custom-toast-handlers";
+import { ApiKeyModalBase } from "./api-key-modal-base";
+import { useCreateApiKey } from "#/hooks/mutation/use-create-api-key";
+
+interface CreateApiKeyModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onKeyCreated: (newKey: CreateApiKeyResponse) => void;
+}
+
+export function CreateApiKeyModal({
+ isOpen,
+ onClose,
+ onKeyCreated,
+}: CreateApiKeyModalProps) {
+ const { t } = useTranslation();
+ const [newKeyName, setNewKeyName] = useState("");
+
+ const createApiKeyMutation = useCreateApiKey();
+
+ const handleCreateKey = async () => {
+ if (!newKeyName.trim()) {
+ displayErrorToast(t(I18nKey.ERROR$REQUIRED_FIELD));
+ return;
+ }
+
+ try {
+ const newKey = await createApiKeyMutation.mutateAsync(newKeyName);
+ onKeyCreated(newKey);
+ displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_CREATED));
+ setNewKeyName("");
+ } catch (error) {
+ displayErrorToast(t(I18nKey.ERROR$GENERIC));
+ }
+ };
+
+ const handleCancel = () => {
+ setNewKeyName("");
+ onClose();
+ };
+
+ const modalFooter = (
+ <>
+
+ {createApiKeyMutation.isPending ? (
+
+ ) : (
+ t(I18nKey.BUTTON$CREATE)
+ )}
+
+
+ {t(I18nKey.BUTTON$CANCEL)}
+
+ >
+ );
+
+ return (
+
+
+
+ {t(I18nKey.SETTINGS$CREATE_API_KEY_DESCRIPTION)}
+
+
setNewKeyName(value)}
+ className="w-full mt-4"
+ type="text"
+ />
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/delete-api-key-modal.tsx b/frontend/src/components/features/settings/delete-api-key-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..187507745839b76068f83dfff3f357d5de845e3f
--- /dev/null
+++ b/frontend/src/components/features/settings/delete-api-key-modal.tsx
@@ -0,0 +1,84 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { BrandButton } from "#/components/features/settings/brand-button";
+import { LoadingSpinner } from "#/components/shared/loading-spinner";
+import { ApiKey } from "#/api/api-keys";
+import {
+ displayErrorToast,
+ displaySuccessToast,
+} from "#/utils/custom-toast-handlers";
+import { ApiKeyModalBase } from "./api-key-modal-base";
+import { useDeleteApiKey } from "#/hooks/mutation/use-delete-api-key";
+
+interface DeleteApiKeyModalProps {
+ isOpen: boolean;
+ keyToDelete: ApiKey | null;
+ onClose: () => void;
+}
+
+export function DeleteApiKeyModal({
+ isOpen,
+ keyToDelete,
+ onClose,
+}: DeleteApiKeyModalProps) {
+ const { t } = useTranslation();
+ const deleteApiKeyMutation = useDeleteApiKey();
+
+ const handleDeleteKey = async () => {
+ if (!keyToDelete) return;
+
+ try {
+ await deleteApiKeyMutation.mutateAsync(keyToDelete.id);
+ displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_DELETED));
+ onClose();
+ } catch (error) {
+ displayErrorToast(t(I18nKey.ERROR$GENERIC));
+ }
+ };
+
+ if (!keyToDelete) return null;
+
+ const modalFooter = (
+ <>
+
+ {deleteApiKeyMutation.isPending ? (
+
+ ) : (
+ t(I18nKey.BUTTON$DELETE)
+ )}
+
+
+ {t(I18nKey.BUTTON$CANCEL)}
+
+ >
+ );
+
+ return (
+
+
+
+ {t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, {
+ name: keyToDelete.name,
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/git-settings/configure-github-repositories-anchor.tsx b/frontend/src/components/features/settings/git-settings/configure-github-repositories-anchor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4b9294878ce4249ea5ef29fd0c90ee798831b2b8
--- /dev/null
+++ b/frontend/src/components/features/settings/git-settings/configure-github-repositories-anchor.tsx
@@ -0,0 +1,27 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { BrandButton } from "../brand-button";
+
+interface ConfigureGitHubRepositoriesAnchorProps {
+ slug: string;
+}
+
+export function ConfigureGitHubRepositoriesAnchor({
+ slug,
+}: ConfigureGitHubRepositoriesAnchorProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t(I18nKey.GITHUB$CONFIGURE_REPOS)}
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/git-settings/github-settings-inputs-skeleton.tsx b/frontend/src/components/features/settings/git-settings/github-settings-inputs-skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9885b98cec86250592b5f1b240087a9a3d24acf9
--- /dev/null
+++ b/frontend/src/components/features/settings/git-settings/github-settings-inputs-skeleton.tsx
@@ -0,0 +1,18 @@
+import { InputSkeleton } from "../input-skeleton";
+import { SubtextSkeleton } from "../subtext-skeleton";
+
+export function GitSettingInputsSkeleton() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/git-settings/github-token-help-anchor.tsx b/frontend/src/components/features/settings/git-settings/github-token-help-anchor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b953a5c446678aaad0efa6b79cc7e0fc896ffe07
--- /dev/null
+++ b/frontend/src/components/features/settings/git-settings/github-token-help-anchor.tsx
@@ -0,0 +1,30 @@
+import { Trans } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+export function GitHubTokenHelpAnchor() {
+ return (
+
+ ,
+ ,
+ ]}
+ />
+
+ );
+}
diff --git a/frontend/src/components/features/settings/git-settings/github-token-input.tsx b/frontend/src/components/features/settings/git-settings/github-token-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..15b51b5ef59e032cc7c1308ca0b1f651f8b06317
--- /dev/null
+++ b/frontend/src/components/features/settings/git-settings/github-token-input.tsx
@@ -0,0 +1,64 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { SettingsInput } from "../settings-input";
+import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
+import { KeyStatusIcon } from "../key-status-icon";
+
+interface GitHubTokenInputProps {
+ onChange: (value: string) => void;
+ onGitHubHostChange: (value: string) => void;
+ isGitHubTokenSet: boolean;
+ name: string;
+ githubHostSet: string | null | undefined;
+}
+
+export function GitHubTokenInput({
+ onChange,
+ onGitHubHostChange,
+ isGitHubTokenSet,
+ name,
+ githubHostSet,
+}: GitHubTokenInputProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ " : ""}
+ startContent={
+ isGitHubTokenSet && (
+
+ )
+ }
+ />
+
+ {})}
+ name="github-host-input"
+ testId="github-host-input"
+ label={t(I18nKey.GITHUB$HOST_LABEL)}
+ type="text"
+ className="w-full max-w-[680px]"
+ placeholder="github.com"
+ defaultValue={githubHostSet || undefined}
+ startContent={
+ githubHostSet &&
+ githubHostSet.trim() !== "" && (
+
+ )
+ }
+ />
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/git-settings/gitlab-token-help-anchor.tsx b/frontend/src/components/features/settings/git-settings/gitlab-token-help-anchor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e538cbd4422e1c4e6477ae48191549cd3b3507a5
--- /dev/null
+++ b/frontend/src/components/features/settings/git-settings/gitlab-token-help-anchor.tsx
@@ -0,0 +1,30 @@
+import { Trans } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+export function GitLabTokenHelpAnchor() {
+ return (
+
+ ,
+ ,
+ ]}
+ />
+
+ );
+}
diff --git a/frontend/src/components/features/settings/git-settings/gitlab-token-input.tsx b/frontend/src/components/features/settings/git-settings/gitlab-token-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fa0e1e77e3d2cf2086ab41c51cac01e461130e30
--- /dev/null
+++ b/frontend/src/components/features/settings/git-settings/gitlab-token-input.tsx
@@ -0,0 +1,64 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { SettingsInput } from "../settings-input";
+import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
+import { KeyStatusIcon } from "../key-status-icon";
+
+interface GitLabTokenInputProps {
+ onChange: (value: string) => void;
+ onGitLabHostChange: (value: string) => void;
+ isGitLabTokenSet: boolean;
+ name: string;
+ gitlabHostSet: string | null | undefined;
+}
+
+export function GitLabTokenInput({
+ onChange,
+ onGitLabHostChange,
+ isGitLabTokenSet,
+ name,
+ gitlabHostSet,
+}: GitLabTokenInputProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ " : ""}
+ startContent={
+ isGitLabTokenSet && (
+
+ )
+ }
+ />
+
+ {})}
+ name="gitlab-host-input"
+ testId="gitlab-host-input"
+ label={t(I18nKey.GITLAB$HOST_LABEL)}
+ type="text"
+ className="w-full max-w-[680px]"
+ placeholder="gitlab.com"
+ defaultValue={gitlabHostSet || undefined}
+ startContent={
+ gitlabHostSet &&
+ gitlabHostSet.trim() !== "" && (
+
+ )
+ }
+ />
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/help-link.tsx b/frontend/src/components/features/settings/help-link.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..984f279de23068d63a50f90b0e103c670a49f2e6
--- /dev/null
+++ b/frontend/src/components/features/settings/help-link.tsx
@@ -0,0 +1,22 @@
+interface HelpLinkProps {
+ testId: string;
+ text: string;
+ linkText: string;
+ href: string;
+}
+
+export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
+ return (
+
+ {text}{" "}
+
+ {linkText}
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/input-skeleton.tsx b/frontend/src/components/features/settings/input-skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dfd5d909cf442057d662728185947efbd7f450dd
--- /dev/null
+++ b/frontend/src/components/features/settings/input-skeleton.tsx
@@ -0,0 +1,8 @@
+export function InputSkeleton() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/key-status-icon.tsx b/frontend/src/components/features/settings/key-status-icon.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..903d51091f65299d852146a8d5e767ba1f4b7992
--- /dev/null
+++ b/frontend/src/components/features/settings/key-status-icon.tsx
@@ -0,0 +1,15 @@
+import SuccessIcon from "#/icons/success.svg?react";
+import { cn } from "#/utils/utils";
+
+interface KeyStatusIconProps {
+ testId?: string;
+ isSet: boolean;
+}
+
+export function KeyStatusIcon({ testId, isSet }: KeyStatusIconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/llm-settings/llm-settings-inputs-skeleton.tsx b/frontend/src/components/features/settings/llm-settings/llm-settings-inputs-skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3094495bf2854b6c6e5fc95de4b0463902daf4b7
--- /dev/null
+++ b/frontend/src/components/features/settings/llm-settings/llm-settings-inputs-skeleton.tsx
@@ -0,0 +1,21 @@
+import { InputSkeleton } from "../input-skeleton";
+import { SubtextSkeleton } from "../subtext-skeleton";
+import { SwitchSkeleton } from "../switch-skeleton";
+
+export function LlmSettingsInputsSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/llm-settings/reset-settings-modal.tsx b/frontend/src/components/features/settings/llm-settings/reset-settings-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..85f4f8035097dcbb3ceb830e06702abd97bc8f24
--- /dev/null
+++ b/frontend/src/components/features/settings/llm-settings/reset-settings-modal.tsx
@@ -0,0 +1,41 @@
+import { useTranslation } from "react-i18next";
+import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { I18nKey } from "#/i18n/declaration";
+import { BrandButton } from "../brand-button";
+
+interface ResetSettingsModalProps {
+ onReset: () => void;
+}
+
+export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}
+
+
+ Reset
+
+
+
+ Cancel
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2bf040ef86780187416e1d1e6731b07aed5a2e4b
--- /dev/null
+++ b/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx
@@ -0,0 +1,84 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { MCPConfig } from "#/types/settings";
+import { I18nKey } from "#/i18n/declaration";
+import { MCPSSEServers } from "./mcp-sse-servers";
+import { MCPStdioServers } from "./mcp-stdio-servers";
+import { MCPJsonEditor } from "./mcp-json-editor";
+import { BrandButton } from "../brand-button";
+
+interface MCPConfigEditorProps {
+ mcpConfig?: MCPConfig;
+ onChange: (config: MCPConfig) => void;
+}
+
+export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
+ const { t } = useTranslation();
+ const [isEditing, setIsEditing] = useState(false);
+ const handleConfigChange = (newConfig: MCPConfig) => {
+ onChange(newConfig);
+ setIsEditing(false);
+ };
+
+ const config = mcpConfig || { sse_servers: [], stdio_servers: [] };
+
+ return (
+
+
+
+ {t(I18nKey.SETTINGS$MCP_TITLE)}
+
+
+ {t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
+
+
+
+
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
+
+ {config.sse_servers.length === 0 &&
+ config.stdio_servers.length === 0 && (
+
+ {t(I18nKey.SETTINGS$MCP_NO_SERVERS_CONFIGURED)}
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-config-viewer.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-config-viewer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7f849692099b20c457681314b9eaf3a77666d83a
--- /dev/null
+++ b/frontend/src/components/features/settings/mcp-settings/mcp-config-viewer.tsx
@@ -0,0 +1,141 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { MCPConfig, MCPSSEServer, MCPStdioServer } from "#/types/settings";
+import { I18nKey } from "#/i18n/declaration";
+
+interface MCPConfigViewerProps {
+ mcpConfig?: MCPConfig;
+}
+
+interface SSEServerDisplayProps {
+ server: string | MCPSSEServer;
+}
+
+function SSEServerDisplay({ server }: SSEServerDisplayProps) {
+ const { t } = useTranslation();
+
+ if (typeof server === "string") {
+ return (
+
+
+ {t(I18nKey.SETTINGS$MCP_URL)}:{" "}
+ {server}
+
+
+ );
+ }
+
+ return (
+
+
+ {t(I18nKey.SETTINGS$MCP_URL)}:{" "}
+ {server.url}
+
+ {server.api_key && (
+
+
+ {t(I18nKey.SETTINGS$MCP_API_KEY)}:
+ {" "}
+ {server.api_key ? "Set" : t(I18nKey.SETTINGS$MCP_API_KEY_NOT_SET)}
+
+ )}
+
+ );
+}
+
+interface StdioServerDisplayProps {
+ server: MCPStdioServer;
+}
+
+function StdioServerDisplay({ server }: StdioServerDisplayProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t(I18nKey.SETTINGS$MCP_NAME)}:{" "}
+ {server.name}
+
+
+ {t(I18nKey.SETTINGS$MCP_COMMAND)}:{" "}
+ {server.command}
+
+ {server.args && server.args.length > 0 && (
+
+ {t(I18nKey.SETTINGS$MCP_ARGS)}:{" "}
+ {server.args.join(" ")}
+
+ )}
+ {server.env && Object.keys(server.env).length > 0 && (
+
+ {t(I18nKey.SETTINGS$MCP_ENV)}:{" "}
+ {Object.entries(server.env)
+ .map(([key, value]) => `${key}=${value}`)
+ .join(", ")}
+
+ )}
+
+ );
+}
+
+export function MCPConfigViewer({ mcpConfig }: MCPConfigViewerProps) {
+ const { t } = useTranslation();
+
+ if (
+ !mcpConfig ||
+ (mcpConfig.sse_servers.length === 0 && mcpConfig.stdio_servers.length === 0)
+ ) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {mcpConfig.sse_servers.length > 0 && (
+
+
+ {t(I18nKey.SETTINGS$MCP_SSE_SERVERS)}{" "}
+
+ ({mcpConfig.sse_servers.length})
+
+
+ {mcpConfig.sse_servers.map((server, index) => (
+
+ ))}
+
+ )}
+
+ {mcpConfig.stdio_servers.length > 0 && (
+
+
+ {t(I18nKey.SETTINGS$MCP_STDIO_SERVERS)}{" "}
+
+ ({mcpConfig.stdio_servers.length})
+
+
+ {mcpConfig.stdio_servers.map((server, index) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-json-editor.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-json-editor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cfc84e545f8e41e3e7500b7652f74688fda56450
--- /dev/null
+++ b/frontend/src/components/features/settings/mcp-settings/mcp-json-editor.tsx
@@ -0,0 +1,97 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { MCPConfig } from "#/types/settings";
+import { I18nKey } from "#/i18n/declaration";
+import { BrandButton } from "../brand-button";
+
+interface MCPJsonEditorProps {
+ mcpConfig?: MCPConfig;
+ onChange: (config: MCPConfig) => void;
+}
+
+export function MCPJsonEditor({ mcpConfig, onChange }: MCPJsonEditorProps) {
+ const { t } = useTranslation();
+ const [configText, setConfigText] = useState(() =>
+ mcpConfig
+ ? JSON.stringify(mcpConfig, null, 2)
+ : t(I18nKey.SETTINGS$MCP_DEFAULT_CONFIG),
+ );
+ const [error, setError] = useState(null);
+
+ const handleTextChange = (e: React.ChangeEvent) => {
+ setConfigText(e.target.value);
+ };
+
+ const handleSave = () => {
+ try {
+ const newConfig = JSON.parse(configText);
+
+ // Validate the structure
+ if (!newConfig.sse_servers || !Array.isArray(newConfig.sse_servers)) {
+ throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_SSE_ARRAY));
+ }
+
+ if (!newConfig.stdio_servers || !Array.isArray(newConfig.stdio_servers)) {
+ throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_STDIO_ARRAY));
+ }
+
+ // Validate SSE servers
+ for (const server of newConfig.sse_servers) {
+ if (
+ typeof server !== "string" &&
+ (!server.url || typeof server.url !== "string")
+ ) {
+ throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_SSE_URL));
+ }
+ }
+
+ // Validate stdio servers
+ for (const server of newConfig.stdio_servers) {
+ if (!server.name || !server.command) {
+ throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_STDIO_PROPS));
+ }
+ }
+
+ onChange(newConfig);
+ setError(null);
+ } catch (e) {
+ setError(
+ e instanceof Error
+ ? e.message
+ : t(I18nKey.SETTINGS$MCP_ERROR_INVALID_JSON),
+ );
+ }
+ };
+
+ return (
+
+
+ {t(I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION)}
+
+
+ {error && (
+
+ {t(I18nKey.SETTINGS$MCP_CONFIG_ERROR)} {error}
+
+ )}
+
+ {t(I18nKey.SETTINGS$MCP_CONFIG_EXAMPLE)}{" "}
+
+ {
+ '{ "sse_servers": ["https://example-mcp-server.com/sse"], "stdio_servers": [{ "name": "fetch", "command": "uvx", "args": ["mcp-server-fetch"] }] }'
+ }
+
+
+
+
+ {t(I18nKey.SETTINGS$MCP_APPLY_CHANGES)}
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-sse-servers.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-sse-servers.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..354013f71ccd00724b35b3accef3901dc74fbe19
--- /dev/null
+++ b/frontend/src/components/features/settings/mcp-settings/mcp-sse-servers.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { MCPSSEServer } from "#/types/settings";
+import { I18nKey } from "#/i18n/declaration";
+
+interface MCPSSEServersProps {
+ servers: (string | MCPSSEServer)[];
+}
+
+export function MCPSSEServers({ servers }: MCPSSEServersProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t(I18nKey.SETTINGS$MCP_SSE_SERVERS)}{" "}
+ ({servers.length})
+
+ {servers.map((server, index) => (
+
+
+ {t(I18nKey.SETTINGS$MCP_URL)}:{" "}
+ {typeof server === "string" ? server : server.url}
+
+ {typeof server !== "string" && server.api_key && (
+
+
+ {t(I18nKey.SETTINGS$MCP_API_KEY)}:
+ {" "}
+ {server.api_key
+ ? "Configured"
+ : t(I18nKey.SETTINGS$MCP_API_KEY_NOT_SET)}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-stdio-servers.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-stdio-servers.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..72e0bdec84710a32c20ad90d59505ab403e907a2
--- /dev/null
+++ b/frontend/src/components/features/settings/mcp-settings/mcp-stdio-servers.tsx
@@ -0,0 +1,58 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { MCPStdioServer } from "#/types/settings";
+import { I18nKey } from "#/i18n/declaration";
+
+interface MCPStdioServersProps {
+ servers: MCPStdioServer[];
+}
+
+export function MCPStdioServers({ servers }: MCPStdioServersProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t(I18nKey.SETTINGS$MCP_STDIO_SERVERS)}{" "}
+ ({servers.length})
+
+ {servers.map((server, index) => (
+
+
+ {t(I18nKey.SETTINGS$MCP_NAME)}:{" "}
+ {server.name}
+
+
+
+ {t(I18nKey.SETTINGS$MCP_COMMAND)}:
+ {" "}
+ {server.command}
+
+ {server.args && server.args.length > 0 && (
+
+
+ {t(I18nKey.SETTINGS$MCP_ARGS)}:
+ {" "}
+ {server.args.join(" ")}
+
+ )}
+ {server.env && Object.keys(server.env).length > 0 && (
+
+
+ {t(I18nKey.SETTINGS$MCP_ENV)}:
+ {" "}
+
+ {Object.entries(server.env)
+ .map(([key, value]) => `${key}=${value}`)
+ .join(", ")}
+
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/features/settings/new-api-key-modal.tsx b/frontend/src/components/features/settings/new-api-key-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2457f6a46ebcf18d65aa3d52d7dbdb8e60189843
--- /dev/null
+++ b/frontend/src/components/features/settings/new-api-key-modal.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { BrandButton } from "#/components/features/settings/brand-button";
+import { CreateApiKeyResponse } from "#/api/api-keys";
+import { displaySuccessToast } from "#/utils/custom-toast-handlers";
+import { ApiKeyModalBase } from "./api-key-modal-base";
+
+interface NewApiKeyModalProps {
+ isOpen: boolean;
+ newlyCreatedKey: CreateApiKeyResponse | null;
+ onClose: () => void;
+}
+
+export function NewApiKeyModal({
+ isOpen,
+ newlyCreatedKey,
+ onClose,
+}: NewApiKeyModalProps) {
+ const { t } = useTranslation();
+
+ const handleCopyToClipboard = () => {
+ if (newlyCreatedKey) {
+ navigator.clipboard.writeText(newlyCreatedKey.key);
+ displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED));
+ }
+ };
+
+ if (!newlyCreatedKey) return null;
+
+ const modalFooter = (
+ <>
+
+ {t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)}
+
+
+ {t(I18nKey.BUTTON$CLOSE)}
+
+ >
+ );
+
+ return (
+
+
+
{t(I18nKey.SETTINGS$API_KEY_WARNING)}
+
+ {newlyCreatedKey.key}
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/settings/optional-tag.tsx b/frontend/src/components/features/settings/optional-tag.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..64c9cc913a99ac7394b0d10e69b9bcac86a92e75
--- /dev/null
+++ b/frontend/src/components/features/settings/optional-tag.tsx
@@ -0,0 +1,3 @@
+export function OptionalTag() {
+ return (Optional);
+}
diff --git a/frontend/src/components/features/settings/secrets-settings/secret-form.tsx b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..984fb09410265df93a5c05f3ab70bd362c559298
--- /dev/null
+++ b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx
@@ -0,0 +1,202 @@
+import { useQueryClient } from "@tanstack/react-query";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
+import { useUpdateSecret } from "#/hooks/mutation/use-update-secret";
+import { SettingsInput } from "../settings-input";
+import { cn } from "#/utils/utils";
+import { BrandButton } from "../brand-button";
+import { useGetSecrets } from "#/hooks/query/use-get-secrets";
+import { GetSecretsResponse } from "#/api/secrets-service.types";
+import { OptionalTag } from "../optional-tag";
+
+interface SecretFormProps {
+ mode: "add" | "edit";
+ selectedSecret: string | null;
+ onCancel: () => void;
+}
+
+export function SecretForm({
+ mode,
+ selectedSecret,
+ onCancel,
+}: SecretFormProps) {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ const { data: secrets } = useGetSecrets();
+ const { mutate: createSecret } = useCreateSecret();
+ const { mutate: updateSecret } = useUpdateSecret();
+
+ const [error, setError] = React.useState(null);
+
+ const secretDescription =
+ (mode === "edit" &&
+ selectedSecret &&
+ secrets
+ ?.find((secret) => secret.name === selectedSecret)
+ ?.description?.trim()) ||
+ "";
+
+ const handleCreateSecret = (
+ name: string,
+ value: string,
+ description?: string,
+ ) => {
+ createSecret(
+ { name, value, description },
+ {
+ onSettled: onCancel,
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["secrets"] });
+ },
+ },
+ );
+ };
+
+ const updateSecretOptimistically = (
+ oldName: string,
+ name: string,
+ description?: string,
+ ) => {
+ queryClient.setQueryData(
+ ["secrets"],
+ (oldSecrets) => {
+ if (!oldSecrets) return [];
+ return oldSecrets.map((secret) => {
+ if (secret.name === oldName) {
+ return {
+ ...secret,
+ name,
+ description,
+ };
+ }
+ return secret;
+ });
+ },
+ );
+ };
+
+ const revertOptimisticUpdate = () => {
+ queryClient.invalidateQueries({ queryKey: ["secrets"] });
+ };
+
+ const handleEditSecret = (
+ secretToEdit: string,
+ name: string,
+ description?: string,
+ ) => {
+ updateSecretOptimistically(secretToEdit, name, description);
+ updateSecret(
+ { secretToEdit, name, description },
+ {
+ onSettled: onCancel,
+ onError: revertOptimisticUpdate,
+ },
+ );
+ };
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+
+ const formData = new FormData(event.currentTarget);
+ const name = formData.get("secret-name")?.toString();
+ const value = formData.get("secret-value")?.toString().trim();
+ const description = formData.get("secret-description")?.toString();
+
+ if (name) {
+ setError(null);
+
+ const isNameAlreadyUsed = secrets?.some(
+ (secret) => secret.name === name && secret.name !== selectedSecret,
+ );
+ if (isNameAlreadyUsed) {
+ setError("Secret already exists");
+ return;
+ }
+
+ if (mode === "add") {
+ if (!value) {
+ setError(t("SECRETS$SECRET_VALUE_REQUIRED"));
+ return;
+ }
+
+ handleCreateSecret(name, value, description || undefined);
+ } else if (mode === "edit" && selectedSecret) {
+ handleEditSecret(selectedSecret, name, description || undefined);
+ }
+ }
+ };
+
+ const formTestId = mode === "add" ? "add-secret-form" : "edit-secret-form";
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/secrets-settings/secret-list-item.tsx b/frontend/src/components/features/settings/secrets-settings/secret-list-item.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7b2a61ac91948b79a2f66402b91c552762acb93c
--- /dev/null
+++ b/frontend/src/components/features/settings/secrets-settings/secret-list-item.tsx
@@ -0,0 +1,63 @@
+import { FaPencil, FaTrash } from "react-icons/fa6";
+
+export function SecretListItemSkeleton() {
+ return (
+
+ );
+}
+
+interface SecretListItemProps {
+ title: string;
+ description?: string;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+export function SecretListItem({
+ title,
+ description,
+ onEdit,
+ onDelete,
+}: SecretListItemProps) {
+ return (
+
+ {title} |
+
+
+ {description || "-"}
+ |
+
+
+
+
+ |
+
+ );
+}
diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..14bee3eba57d9bcb5c863aa4cf3ba2b8833e39e5
--- /dev/null
+++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx
@@ -0,0 +1,77 @@
+import { Autocomplete, AutocompleteItem } from "@heroui/react";
+import { ReactNode } from "react";
+import { OptionalTag } from "./optional-tag";
+import { cn } from "#/utils/utils";
+
+interface SettingsDropdownInputProps {
+ testId: string;
+ name: string;
+ items: { key: React.Key; label: string }[];
+ label?: ReactNode;
+ wrapperClassName?: string;
+ placeholder?: string;
+ showOptionalTag?: boolean;
+ isDisabled?: boolean;
+ defaultSelectedKey?: string;
+ selectedKey?: string;
+ isClearable?: boolean;
+ onSelectionChange?: (key: React.Key | null) => void;
+ onInputChange?: (value: string) => void;
+ defaultFilter?: (textValue: string, inputValue: string) => boolean;
+}
+
+export function SettingsDropdownInput({
+ testId,
+ label,
+ wrapperClassName,
+ name,
+ items,
+ placeholder,
+ showOptionalTag,
+ isDisabled,
+ defaultSelectedKey,
+ selectedKey,
+ isClearable,
+ onSelectionChange,
+ onInputChange,
+ defaultFilter,
+}: SettingsDropdownInputProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b34a593a6506425580255f5bb7b6a291193d1d62
--- /dev/null
+++ b/frontend/src/components/features/settings/settings-input.tsx
@@ -0,0 +1,71 @@
+import { cn } from "#/utils/utils";
+import { OptionalTag } from "./optional-tag";
+
+interface SettingsInputProps {
+ testId?: string;
+ name?: string;
+ label: string;
+ type: React.HTMLInputTypeAttribute;
+ defaultValue?: string;
+ value?: string;
+ placeholder?: string;
+ showOptionalTag?: boolean;
+ isDisabled?: boolean;
+ startContent?: React.ReactNode;
+ className?: string;
+ onChange?: (value: string) => void;
+ required?: boolean;
+ min?: number;
+ max?: number;
+ step?: number;
+ pattern?: string;
+}
+
+export function SettingsInput({
+ testId,
+ name,
+ label,
+ type,
+ defaultValue,
+ value,
+ placeholder,
+ showOptionalTag,
+ isDisabled,
+ startContent,
+ className,
+ onChange,
+ required,
+ min,
+ max,
+ step,
+ pattern,
+}: SettingsInputProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/settings-switch.tsx b/frontend/src/components/features/settings/settings-switch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0696d7049855a36312bba15c7ef1acf6bfd120dd
--- /dev/null
+++ b/frontend/src/components/features/settings/settings-switch.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { StyledSwitchComponent } from "./styled-switch-component";
+
+interface SettingsSwitchProps {
+ testId?: string;
+ name?: string;
+ onToggle?: (value: boolean) => void;
+ defaultIsToggled?: boolean;
+ isToggled?: boolean;
+ isBeta?: boolean;
+}
+
+export function SettingsSwitch({
+ children,
+ testId,
+ name,
+ onToggle,
+ defaultIsToggled,
+ isToggled: controlledIsToggled,
+ isBeta,
+}: React.PropsWithChildren) {
+ const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
+
+ const handleToggle = (value: boolean) => {
+ setIsToggled(value);
+ onToggle?.(value);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/styled-switch-component.tsx b/frontend/src/components/features/settings/styled-switch-component.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9299b08b30fb4c75c689f3b0e4724bed7107da13
--- /dev/null
+++ b/frontend/src/components/features/settings/styled-switch-component.tsx
@@ -0,0 +1,27 @@
+import { cn } from "#/utils/utils";
+
+interface StyledSwitchComponentProps {
+ isToggled: boolean;
+}
+
+export function StyledSwitchComponent({
+ isToggled,
+}: StyledSwitchComponentProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/settings/subtext-skeleton.tsx b/frontend/src/components/features/settings/subtext-skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..46d87297be852c05971141f7e85b8baf989c062a
--- /dev/null
+++ b/frontend/src/components/features/settings/subtext-skeleton.tsx
@@ -0,0 +1,3 @@
+export function SubtextSkeleton() {
+ return ;
+}
diff --git a/frontend/src/components/features/settings/switch-skeleton.tsx b/frontend/src/components/features/settings/switch-skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..11af03339a3772d3bb69e1c05a58e70542f2211e
--- /dev/null
+++ b/frontend/src/components/features/settings/switch-skeleton.tsx
@@ -0,0 +1,8 @@
+export function SwitchSkeleton() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/sidebar/avatar.tsx b/frontend/src/components/features/sidebar/avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bd601fcaa3db7d4b93f6720d41b89143e50e606d
--- /dev/null
+++ b/frontend/src/components/features/sidebar/avatar.tsx
@@ -0,0 +1,17 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface AvatarProps {
+ src: string;
+}
+
+export function Avatar({ src }: AvatarProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a78facb3cf209512ef19576ff72093abbc8fb47e
--- /dev/null
+++ b/frontend/src/components/features/sidebar/sidebar.tsx
@@ -0,0 +1,114 @@
+import React from "react";
+import { useLocation } from "react-router";
+import { useGitUser } from "#/hooks/query/use-git-user";
+import { UserActions } from "./user-actions";
+import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
+import { DocsButton } from "#/components/shared/buttons/docs-button";
+import { NewProjectButton } from "#/components/shared/buttons/new-project-button";
+import { SettingsButton } from "#/components/shared/buttons/settings-button";
+import { ConversationPanelButton } from "#/components/shared/buttons/conversation-panel-button";
+import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
+import { useSettings } from "#/hooks/query/use-settings";
+import { ConversationPanel } from "../conversation-panel/conversation-panel";
+import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
+import { useLogout } from "#/hooks/mutation/use-logout";
+import { useConfig } from "#/hooks/query/use-config";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+
+export function Sidebar() {
+ const location = useLocation();
+ const user = useGitUser();
+ const { data: config } = useConfig();
+ const {
+ data: settings,
+ error: settingsError,
+ isError: settingsIsError,
+ isFetching: isFetchingSettings,
+ } = useSettings();
+ const { mutate: logout } = useLogout();
+
+ const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
+
+ const [conversationPanelIsOpen, setConversationPanelIsOpen] =
+ React.useState(false);
+
+ // TODO: Remove HIDE_LLM_SETTINGS check once released
+ const shouldHideLlmSettings =
+ config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
+
+ React.useEffect(() => {
+ if (shouldHideLlmSettings) return;
+
+ if (location.pathname === "/settings") {
+ setSettingsModalIsOpen(false);
+ } else if (
+ !isFetchingSettings &&
+ settingsIsError &&
+ settingsError?.status !== 404
+ ) {
+ // We don't show toast errors for settings in the global error handler
+ // because we have a special case for 404 errors
+ displayErrorToast(
+ "Something went wrong while fetching settings. Please reload the page.",
+ );
+ } else if (config?.APP_MODE === "oss" && settingsError?.status === 404) {
+ setSettingsModalIsOpen(true);
+ }
+ }, [
+ settingsError?.status,
+ settingsError,
+ isFetchingSettings,
+ location.pathname,
+ ]);
+
+ return (
+ <>
+
+
+ {settingsModalIsOpen && (
+ setSettingsModalIsOpen(false)}
+ />
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/features/sidebar/user-actions.tsx b/frontend/src/components/features/sidebar/user-actions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ed35e5f21eacb61de21bb63f8dac8942fe534240
--- /dev/null
+++ b/frontend/src/components/features/sidebar/user-actions.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import { UserAvatar } from "./user-avatar";
+import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
+
+interface UserActionsProps {
+ onLogout: () => void;
+ user?: { avatar_url: string };
+ isLoading?: boolean;
+}
+
+export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
+ const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
+ React.useState(false);
+
+ const toggleAccountMenu = () => {
+ setAccountContextMenuIsVisible((prev) => !prev);
+ };
+
+ const closeAccountMenu = () => {
+ setAccountContextMenuIsVisible(false);
+ };
+
+ const handleLogout = () => {
+ onLogout();
+ closeAccountMenu();
+ };
+
+ return (
+
+
+
+ {accountContextMenuIsVisible && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/features/sidebar/user-avatar.tsx b/frontend/src/components/features/sidebar/user-avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3e5d0fda57fbf9002273c6f8a1bdccd5f3eb21d4
--- /dev/null
+++ b/frontend/src/components/features/sidebar/user-avatar.tsx
@@ -0,0 +1,40 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { LoadingSpinner } from "#/components/shared/loading-spinner";
+import ProfileIcon from "#/icons/profile.svg?react";
+import { cn } from "#/utils/utils";
+import { Avatar } from "./avatar";
+import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
+
+interface UserAvatarProps {
+ onClick: () => void;
+ avatarUrl?: string;
+ isLoading?: boolean;
+}
+
+export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
+ const { t } = useTranslation();
+ return (
+
+ {!isLoading && avatarUrl && }
+ {!isLoading && !avatarUrl && (
+
+ )}
+ {isLoading && }
+
+ );
+}
diff --git a/frontend/src/components/features/suggestions/replay-suggestion-box.tsx b/frontend/src/components/features/suggestions/replay-suggestion-box.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b9c177fcecbe9a19574594eee9a1a548ea4518f7
--- /dev/null
+++ b/frontend/src/components/features/suggestions/replay-suggestion-box.tsx
@@ -0,0 +1,34 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { SuggestionBox } from "./suggestion-box";
+
+interface ReplaySuggestionBoxProps {
+ onChange: (event: React.ChangeEvent) => void;
+}
+
+export function ReplaySuggestionBox({ onChange }: ReplaySuggestionBoxProps) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t(I18nKey.LANDING$UPLOAD_TRAJECTORY)}
+
+
+
+ }
+ />
+ );
+}
diff --git a/frontend/src/components/features/suggestions/suggestion-box.tsx b/frontend/src/components/features/suggestions/suggestion-box.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..327e49fe8e250b9c9078941bafb7a830b4855aca
--- /dev/null
+++ b/frontend/src/components/features/suggestions/suggestion-box.tsx
@@ -0,0 +1,21 @@
+import React from "react";
+
+interface SuggestionBoxProps {
+ title: string;
+ content: React.ReactNode;
+}
+
+export function SuggestionBox({ title, content }: SuggestionBoxProps) {
+ return (
+
+
+ {title}
+
+ {typeof content === "string" ? (
+
{content}
+ ) : (
+
{content}
+ )}
+
+ );
+}
diff --git a/frontend/src/components/features/suggestions/suggestion-bubble.tsx b/frontend/src/components/features/suggestions/suggestion-bubble.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3802642b7114479fae34e501b68ee516896af89e
--- /dev/null
+++ b/frontend/src/components/features/suggestions/suggestion-bubble.tsx
@@ -0,0 +1,35 @@
+import { useTranslation } from "react-i18next";
+import { RefreshButton } from "#/components/shared/buttons/refresh-button";
+import Lightbulb from "#/icons/lightbulb.svg?react";
+import { I18nKey } from "#/i18n/declaration";
+
+interface SuggestionBubbleProps {
+ suggestion: { key: string; value: string };
+ onClick: () => void;
+ onRefresh: () => void;
+}
+
+export function SuggestionBubble({
+ suggestion,
+ onClick,
+ onRefresh,
+}: SuggestionBubbleProps) {
+ const { t } = useTranslation();
+ const handleRefresh = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onRefresh();
+ };
+
+ return (
+
+
+
+ {t(suggestion.key as I18nKey)}
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/suggestions/suggestion-item.tsx b/frontend/src/components/features/suggestions/suggestion-item.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f29766199036e55510ff130e9edc1affd1094593
--- /dev/null
+++ b/frontend/src/components/features/suggestions/suggestion-item.tsx
@@ -0,0 +1,25 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+export type Suggestion = { label: I18nKey | string; value: string };
+
+interface SuggestionItemProps {
+ suggestion: Suggestion;
+ onClick: (value: string) => void;
+}
+
+export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/suggestions/suggestions.tsx b/frontend/src/components/features/suggestions/suggestions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..204daddbdac5b48cf2583303c7fc2232515fc96f
--- /dev/null
+++ b/frontend/src/components/features/suggestions/suggestions.tsx
@@ -0,0 +1,23 @@
+import { SuggestionItem, type Suggestion } from "./suggestion-item";
+
+interface SuggestionsProps {
+ suggestions: Suggestion[];
+ onSuggestionClick: (value: string) => void;
+}
+
+export function Suggestions({
+ suggestions,
+ onSuggestionClick,
+}: SuggestionsProps) {
+ return (
+
+ {suggestions.map((suggestion, index) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/features/terminal/terminal.tsx b/frontend/src/components/features/terminal/terminal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..692d82ccb24f57346f240ace94039e06ce51a207
--- /dev/null
+++ b/frontend/src/components/features/terminal/terminal.tsx
@@ -0,0 +1,30 @@
+import { useSelector } from "react-redux";
+import { useTranslation } from "react-i18next";
+import { RootState } from "#/store";
+import { useTerminal } from "#/hooks/use-terminal";
+import "@xterm/xterm/css/xterm.css";
+import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
+
+function Terminal() {
+ const { commands } = useSelector((state: RootState) => state.cmd);
+ const { curAgentState } = useSelector((state: RootState) => state.agent);
+ const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
+ const { t } = useTranslation();
+
+ const ref = useTerminal({
+ commands,
+ });
+
+ return (
+
+ {isRuntimeInactive && (
+
+ {t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
+
+ )}
+
+
+ );
+}
+
+export default Terminal;
diff --git a/frontend/src/components/features/tips/random-tip.tsx b/frontend/src/components/features/tips/random-tip.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6843b581603ea3eaa4af45807bf68fdab45f04b2
--- /dev/null
+++ b/frontend/src/components/features/tips/random-tip.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { getRandomTip } from "#/utils/tips";
+
+export function RandomTip() {
+ const { t } = useTranslation();
+ const [randomTip, setRandomTip] = React.useState(getRandomTip());
+
+ // Update the random tip when the component mounts
+ React.useEffect(() => {
+ setRandomTip(getRandomTip());
+ }, []);
+
+ return (
+
+
{t(I18nKey.TIPS$PROTIP)}:
+ {t(randomTip.key)}
+ {randomTip.link && (
+ <>
+ {" "}
+
+ {t(I18nKey.TIPS$LEARN_MORE)}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/features/trajectory/trajectory-actions.tsx b/frontend/src/components/features/trajectory/trajectory-actions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..adb14fee66ba57de5c88d6cd64b3501708e3d3e4
--- /dev/null
+++ b/frontend/src/components/features/trajectory/trajectory-actions.tsx
@@ -0,0 +1,43 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
+import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
+import ExportIcon from "#/icons/export.svg?react";
+import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
+
+interface TrajectoryActionsProps {
+ onPositiveFeedback: () => void;
+ onNegativeFeedback: () => void;
+ onExportTrajectory: () => void;
+}
+
+export function TrajectoryActions({
+ onPositiveFeedback,
+ onNegativeFeedback,
+ onExportTrajectory,
+}: TrajectoryActionsProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ }
+ tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
+ />
+ }
+ tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
+ />
+ }
+ tooltip={t(I18nKey.BUTTON$EXPORT_CONVERSATION)}
+ />
+
+ );
+}
diff --git a/frontend/src/components/features/waitlist/auth-modal.tsx b/frontend/src/components/features/waitlist/auth-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3fbafea011c2c74678e659e1c4c1210ac05e1e1e
--- /dev/null
+++ b/frontend/src/components/features/waitlist/auth-modal.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
+import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { ModalBody } from "#/components/shared/modals/modal-body";
+import { BrandButton } from "../settings/brand-button";
+import GitHubLogo from "#/assets/branding/github-logo.svg?react";
+import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
+import { useAuthUrl } from "#/hooks/use-auth-url";
+import { GetConfigResponse } from "#/api/open-hands.types";
+
+interface AuthModalProps {
+ githubAuthUrl: string | null;
+ appMode?: GetConfigResponse["APP_MODE"] | null;
+}
+
+export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
+ const { t } = useTranslation();
+
+ const gitlabAuthUrl = useAuthUrl({
+ appMode: appMode || null,
+ identityProvider: "gitlab",
+ });
+
+ const handleGitHubAuth = () => {
+ if (githubAuthUrl) {
+ // Always start the OIDC flow, let the backend handle TOS check
+ window.location.href = githubAuthUrl;
+ }
+ };
+
+ const handleGitLabAuth = () => {
+ if (gitlabAuthUrl) {
+ // Always start the OIDC flow, let the backend handle TOS check
+ window.location.href = gitlabAuthUrl;
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t(I18nKey.AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER)}
+
+
+
+
+ }
+ >
+ {t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
+
+
+ }
+ >
+ {t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/waitlist/reauth-modal.tsx b/frontend/src/components/features/waitlist/reauth-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..33e2ee219b0ee78e49bd134bbb0d0504071dbed8
--- /dev/null
+++ b/frontend/src/components/features/waitlist/reauth-modal.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { ModalBody } from "#/components/shared/modals/modal-body";
+import { I18nKey } from "#/i18n/declaration";
+import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
+
+export function ReauthModal() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t(I18nKey.AUTH$LOGGING_BACK_IN)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/waitlist/tos-checkbox.tsx b/frontend/src/components/features/waitlist/tos-checkbox.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b2fdafcbc725dff874f95901f00ed289d780a7a9
--- /dev/null
+++ b/frontend/src/components/features/waitlist/tos-checkbox.tsx
@@ -0,0 +1,26 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface TOSCheckboxProps {
+ onChange: () => void;
+}
+
+export function TOSCheckbox({ onChange }: TOSCheckboxProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/layout/beta-badge.tsx b/frontend/src/components/layout/beta-badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0ef82837eed353a893852bd620e906b1184345ef
--- /dev/null
+++ b/frontend/src/components/layout/beta-badge.tsx
@@ -0,0 +1,11 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+export function BetaBadge() {
+ const { t } = useTranslation();
+ return (
+
+ {t(I18nKey.BADGE$BETA)}
+
+ );
+}
diff --git a/frontend/src/components/layout/container.tsx b/frontend/src/components/layout/container.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4f39595c736d8dfc2776f600cfbfe792a5cf00fe
--- /dev/null
+++ b/frontend/src/components/layout/container.tsx
@@ -0,0 +1,57 @@
+import clsx from "clsx";
+import React from "react";
+import { NavTab } from "./nav-tab";
+
+interface ContainerProps {
+ label?: React.ReactNode;
+ labels?: {
+ label: string | React.ReactNode;
+ to: string;
+ icon?: React.ReactNode;
+ isBeta?: boolean;
+ isLoading?: boolean;
+ rightContent?: React.ReactNode;
+ }[];
+ children: React.ReactNode;
+ className?: React.HTMLAttributes["className"];
+}
+
+export function Container({
+ label,
+ labels,
+ children,
+ className,
+}: ContainerProps) {
+ return (
+
+ {labels && (
+
+ {labels.map(
+ ({ label: l, to, icon, isBeta, isLoading, rightContent }) => (
+
+ ),
+ )}
+
+ )}
+ {!labels && label && (
+
+ {label}
+
+ )}
+
{children}
+
+ );
+}
diff --git a/frontend/src/components/layout/count-badge.tsx b/frontend/src/components/layout/count-badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..96f0cf9639a93fffb028fef94d8a392b9949b672
--- /dev/null
+++ b/frontend/src/components/layout/count-badge.tsx
@@ -0,0 +1,7 @@
+export function CountBadge({ count }: { count: number }) {
+ return (
+
+ {count}
+
+ );
+}
diff --git a/frontend/src/components/layout/nav-tab.tsx b/frontend/src/components/layout/nav-tab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..91d3d6c590a22f56ccfd697b2f1bd9f8d220f84d
--- /dev/null
+++ b/frontend/src/components/layout/nav-tab.tsx
@@ -0,0 +1,49 @@
+import { NavLink } from "react-router";
+import { cn } from "#/utils/utils";
+import { BetaBadge } from "./beta-badge";
+import { LoadingSpinner } from "../shared/loading-spinner";
+
+interface NavTabProps {
+ to: string;
+ label: string | React.ReactNode;
+ icon: React.ReactNode;
+ isBeta?: boolean;
+ isLoading?: boolean;
+ rightContent?: React.ReactNode;
+}
+
+export function NavTab({
+ to,
+ label,
+ icon,
+ isBeta,
+ isLoading,
+ rightContent,
+}: NavTabProps) {
+ return (
+
+ {({ isActive }) => (
+
+
+
{icon}
+ {label}
+ {isBeta &&
}
+
+
+ {rightContent}
+ {isLoading && }
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/layout/resizable-panel.tsx b/frontend/src/components/layout/resizable-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0f7ecc4e518383670805298923986c85a357a7b6
--- /dev/null
+++ b/frontend/src/components/layout/resizable-panel.tsx
@@ -0,0 +1,191 @@
+import React, { CSSProperties, JSX, useEffect, useRef, useState } from "react";
+import {
+ VscChevronDown,
+ VscChevronLeft,
+ VscChevronRight,
+ VscChevronUp,
+} from "react-icons/vsc";
+import { twMerge } from "tailwind-merge";
+import { IconButton } from "../shared/buttons/icon-button";
+
+export enum Orientation {
+ HORIZONTAL = "horizontal",
+ VERTICAL = "vertical",
+}
+
+enum Collapse {
+ COLLAPSED = "collapsed",
+ SPLIT = "split",
+ FILLED = "filled",
+}
+
+type ResizablePanelProps = {
+ firstChild: React.ReactNode;
+ firstClassName: string | undefined;
+ secondChild: React.ReactNode;
+ secondClassName: string | undefined;
+ className: string | undefined;
+ orientation: Orientation;
+ initialSize: number;
+};
+
+export function ResizablePanel({
+ firstChild,
+ firstClassName,
+ secondChild,
+ secondClassName,
+ className,
+ orientation,
+ initialSize,
+}: ResizablePanelProps): JSX.Element {
+ const [firstSize, setFirstSize] = useState(initialSize);
+ const [dividerPosition, setDividerPosition] = useState(null);
+ const firstRef = useRef(null);
+ const secondRef = useRef(null);
+ const [collapse, setCollapse] = useState(Collapse.SPLIT);
+ const isHorizontal = orientation === Orientation.HORIZONTAL;
+
+ useEffect(() => {
+ if (dividerPosition == null || !firstRef.current) {
+ return undefined;
+ }
+ const getFirstSizeFromEvent = (e: MouseEvent) => {
+ const position = isHorizontal ? e.clientX : e.clientY;
+ return firstSize + position - dividerPosition;
+ };
+ const onMouseMove = (e: MouseEvent) => {
+ e.preventDefault();
+ const newFirstSize = `${getFirstSizeFromEvent(e)}px`;
+ const { current } = firstRef;
+ if (current) {
+ if (isHorizontal) {
+ current.style.width = newFirstSize;
+ current.style.minWidth = newFirstSize;
+ } else {
+ current.style.height = newFirstSize;
+ current.style.minHeight = newFirstSize;
+ }
+ }
+ };
+ const onMouseUp = (e: MouseEvent) => {
+ e.preventDefault();
+ if (firstRef.current) {
+ firstRef.current.style.transition = "";
+ }
+ if (secondRef.current) {
+ secondRef.current.style.transition = "";
+ }
+ setFirstSize(getFirstSizeFromEvent(e));
+ setDividerPosition(null);
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ };
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [dividerPosition, firstSize, orientation]);
+
+ const onMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (firstRef.current) {
+ firstRef.current.style.transition = "none";
+ }
+ if (secondRef.current) {
+ secondRef.current.style.transition = "none";
+ }
+ const position = isHorizontal ? e.clientX : e.clientY;
+ setDividerPosition(position);
+ };
+
+ const getStyleForFirst = () => {
+ const style: CSSProperties = { overflow: "hidden" };
+ if (collapse === Collapse.COLLAPSED) {
+ style.opacity = 0;
+ style.width = 0;
+ style.minWidth = 0;
+ style.height = 0;
+ style.minHeight = 0;
+ } else if (collapse === Collapse.SPLIT) {
+ const firstSizePx = `${firstSize}px`;
+ if (isHorizontal) {
+ style.width = firstSizePx;
+ style.minWidth = firstSizePx;
+ } else {
+ style.height = firstSizePx;
+ style.minHeight = firstSizePx;
+ }
+ } else {
+ style.flexGrow = 1;
+ }
+ return style;
+ };
+
+ const getStyleForSecond = () => {
+ const style: CSSProperties = { overflow: "hidden" };
+ if (collapse === Collapse.FILLED) {
+ style.opacity = 0;
+ style.width = 0;
+ style.minWidth = 0;
+ style.height = 0;
+ style.minHeight = 0;
+ } else if (collapse === Collapse.SPLIT) {
+ style.flexGrow = 1;
+ } else {
+ style.flexGrow = 1;
+ }
+ return style;
+ };
+
+ const onCollapse = () => {
+ if (collapse === Collapse.SPLIT) {
+ setCollapse(Collapse.COLLAPSED);
+ } else {
+ setCollapse(Collapse.SPLIT);
+ }
+ };
+
+ const onExpand = () => {
+ if (collapse === Collapse.SPLIT) {
+ setCollapse(Collapse.FILLED);
+ } else {
+ setCollapse(Collapse.SPLIT);
+ }
+ };
+
+ return (
+
+
+ {firstChild}
+
+
+ : }
+ ariaLabel="Collapse"
+ onClick={onCollapse}
+ />
+ : }
+ ariaLabel="Expand"
+ onClick={onExpand}
+ />
+
+
+ {secondChild}
+
+
+ );
+}
diff --git a/frontend/src/components/layout/served-app-label.tsx b/frontend/src/components/layout/served-app-label.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1334c4be056101282b3fd85f607daf420aa689c0
--- /dev/null
+++ b/frontend/src/components/layout/served-app-label.tsx
@@ -0,0 +1,18 @@
+import { useTranslation } from "react-i18next";
+import { useActiveHost } from "#/hooks/query/use-active-host";
+import { I18nKey } from "#/i18n/declaration";
+
+export function ServedAppLabel() {
+ const { t } = useTranslation();
+ const { activeHost } = useActiveHost();
+
+ return (
+
+
+
{t(I18nKey.APP$TITLE)}
+
BETA
+
+ {activeHost &&
}
+
+ );
+}
diff --git a/frontend/src/components/layout/tab-content.tsx b/frontend/src/components/layout/tab-content.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e28de68258b2f34c55132a0db658c99155f567a8
--- /dev/null
+++ b/frontend/src/components/layout/tab-content.tsx
@@ -0,0 +1,72 @@
+import React, { lazy, Suspense } from "react";
+import { useLocation } from "react-router";
+import { LoadingSpinner } from "../shared/loading-spinner";
+
+// Lazy load all tab components
+const EditorTab = lazy(() => import("#/routes/changes-tab"));
+const BrowserTab = lazy(() => import("#/routes/browser-tab"));
+const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
+const ServedTab = lazy(() => import("#/routes/served-tab"));
+const TerminalTab = lazy(() => import("#/routes/terminal-tab"));
+const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
+
+interface TabContentProps {
+ conversationPath: string;
+}
+
+export function TabContent({ conversationPath }: TabContentProps) {
+ const location = useLocation();
+ const currentPath = location.pathname;
+
+ // Determine which tab is active based on the current path
+ const isEditorActive = currentPath === conversationPath;
+ const isBrowserActive = currentPath === `${conversationPath}/browser`;
+ const isJupyterActive = currentPath === `${conversationPath}/jupyter`;
+ const isServedActive = currentPath === `${conversationPath}/served`;
+ const isTerminalActive = currentPath === `${conversationPath}/terminal`;
+ const isVSCodeActive = currentPath === `${conversationPath}/vscode`;
+
+ return (
+
+ {/* Each tab content is always loaded but only visible when active */}
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/action-tooltip.tsx b/frontend/src/components/shared/action-tooltip.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a9c366e1596c7000d0866ad1e7280a0ecea4411c
--- /dev/null
+++ b/frontend/src/components/shared/action-tooltip.tsx
@@ -0,0 +1,37 @@
+import { Tooltip } from "@heroui/react";
+import { useTranslation } from "react-i18next";
+import ConfirmIcon from "#/assets/confirm";
+import RejectIcon from "#/assets/reject";
+import { I18nKey } from "#/i18n/declaration";
+
+interface ActionTooltipProps {
+ type: "confirm" | "reject";
+ onClick: () => void;
+}
+
+export function ActionTooltip({ type, onClick }: ActionTooltipProps) {
+ const { t } = useTranslation();
+
+ const content =
+ type === "confirm"
+ ? t(I18nKey.CHAT_INTERFACE$USER_CONFIRMED)
+ : t(I18nKey.CHAT_INTERFACE$USER_REJECTED);
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/action-button.tsx b/frontend/src/components/shared/buttons/action-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c9ca6a70a4f593ce9d7fee70a7ee300000e8b3bb
--- /dev/null
+++ b/frontend/src/components/shared/buttons/action-button.tsx
@@ -0,0 +1,33 @@
+import { Tooltip } from "@heroui/react";
+import { AgentState } from "#/types/agent-state";
+
+interface ActionButtonProps {
+ isDisabled?: boolean;
+ content: string;
+ action: AgentState;
+ handleAction: (action: AgentState) => void;
+}
+
+export function ActionButton({
+ isDisabled = false,
+ content,
+ action,
+ handleAction,
+ children,
+}: React.PropsWithChildren) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/all-hands-logo-button.tsx b/frontend/src/components/shared/buttons/all-hands-logo-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f364d28262e1711d059c5000b206f9ef159e60d2
--- /dev/null
+++ b/frontend/src/components/shared/buttons/all-hands-logo-button.tsx
@@ -0,0 +1,18 @@
+import { useTranslation } from "react-i18next";
+import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
+import { I18nKey } from "#/i18n/declaration";
+import { TooltipButton } from "./tooltip-button";
+
+export function AllHandsLogoButton() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/confirmation-buttons.tsx b/frontend/src/components/shared/buttons/confirmation-buttons.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0ffe67bb43583526cf293ed6eaae9ffb3f40b520
--- /dev/null
+++ b/frontend/src/components/shared/buttons/confirmation-buttons.tsx
@@ -0,0 +1,32 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { AgentState } from "#/types/agent-state";
+import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
+import { useWsClient } from "#/context/ws-client-provider";
+import { ActionTooltip } from "../action-tooltip";
+
+export function ConfirmationButtons() {
+ const { t } = useTranslation();
+ const { send } = useWsClient();
+
+ const handleStateChange = (state: AgentState) => {
+ const event = generateAgentStateChangeEvent(state);
+ send(event);
+ };
+
+ return (
+
+
{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}
+
+
handleStateChange(AgentState.USER_CONFIRMED)}
+ />
+ handleStateChange(AgentState.USER_REJECTED)}
+ />
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/conversation-panel-button.tsx b/frontend/src/components/shared/buttons/conversation-panel-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..36b66f6a838b360fd6475083dee24b1fa8eb41d8
--- /dev/null
+++ b/frontend/src/components/shared/buttons/conversation-panel-button.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { FaListUl } from "react-icons/fa";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { TooltipButton } from "./tooltip-button";
+import { cn } from "#/utils/utils";
+
+interface ConversationPanelButtonProps {
+ isOpen: boolean;
+ onClick: () => void;
+ disabled?: boolean;
+}
+
+export function ConversationPanelButton({
+ isOpen,
+ onClick,
+ disabled = false,
+}: ConversationPanelButtonProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/copy-to-clipboard-button.tsx b/frontend/src/components/shared/buttons/copy-to-clipboard-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6fe7dc2f5761387d45f28645c86d1bca81ee465f
--- /dev/null
+++ b/frontend/src/components/shared/buttons/copy-to-clipboard-button.tsx
@@ -0,0 +1,36 @@
+import { useTranslation } from "react-i18next";
+import CheckmarkIcon from "#/icons/checkmark.svg?react";
+import CopyIcon from "#/icons/copy.svg?react";
+import { I18nKey } from "#/i18n/declaration";
+
+interface CopyToClipboardButtonProps {
+ isHidden: boolean;
+ isDisabled: boolean;
+ onClick: () => void;
+ mode: "copy" | "copied";
+}
+
+export function CopyToClipboardButton({
+ isHidden,
+ isDisabled,
+ onClick,
+ mode,
+}: CopyToClipboardButtonProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/docs-button.tsx b/frontend/src/components/shared/buttons/docs-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d7ea367ee9d4a0041cbde7da47576b3401310d4d
--- /dev/null
+++ b/frontend/src/components/shared/buttons/docs-button.tsx
@@ -0,0 +1,26 @@
+import { useTranslation } from "react-i18next";
+import DocsIcon from "#/icons/academy.svg?react";
+import { I18nKey } from "#/i18n/declaration";
+import { TooltipButton } from "./tooltip-button";
+
+interface DocsButtonProps {
+ disabled?: boolean;
+}
+
+export function DocsButton({ disabled = false }: DocsButtonProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/editor-action-button.tsx b/frontend/src/components/shared/buttons/editor-action-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ce568e4a6b157cfaafa9d4670995138685ecdecd
--- /dev/null
+++ b/frontend/src/components/shared/buttons/editor-action-button.tsx
@@ -0,0 +1,29 @@
+import { cn } from "#/utils/utils";
+
+interface EditorActionButtonProps {
+ onClick: () => void;
+ disabled: boolean;
+ className: React.HTMLAttributes["className"];
+}
+
+export function EditorActionButton({
+ onClick,
+ disabled,
+ className,
+ children,
+}: React.PropsWithChildren) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/icon-button.tsx b/frontend/src/components/shared/buttons/icon-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f6007622e7bcfcec78a06c2d426de4632441466f
--- /dev/null
+++ b/frontend/src/components/shared/buttons/icon-button.tsx
@@ -0,0 +1,29 @@
+import { Button } from "@heroui/react";
+import React, { ReactElement } from "react";
+
+export interface IconButtonProps {
+ icon: ReactElement;
+ onClick: () => void;
+ ariaLabel: string;
+ testId?: string;
+}
+
+export function IconButton({
+ icon,
+ onClick,
+ ariaLabel,
+ testId = "",
+}: IconButtonProps): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/modal-button.tsx b/frontend/src/components/shared/buttons/modal-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..706e7a481b7f422cf9374fb0da41c7edbb7021c4
--- /dev/null
+++ b/frontend/src/components/shared/buttons/modal-button.tsx
@@ -0,0 +1,47 @@
+import clsx from "clsx";
+import React from "react";
+
+interface ModalButtonProps {
+ testId?: string;
+ variant?: "default" | "text-like";
+ onClick?: () => void;
+ text: string;
+ className: React.HTMLProps["className"];
+ icon?: React.ReactNode;
+ type?: "button" | "submit";
+ disabled?: boolean;
+ intent?: string;
+}
+
+export function ModalButton({
+ testId,
+ variant = "default",
+ onClick,
+ text,
+ className,
+ icon,
+ type = "button",
+ disabled,
+ intent,
+}: ModalButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/new-project-button.tsx b/frontend/src/components/shared/buttons/new-project-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..67cc7aab601800ec636bd3303913c63615129d1a
--- /dev/null
+++ b/frontend/src/components/shared/buttons/new-project-button.tsx
@@ -0,0 +1,24 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import PlusIcon from "#/icons/plus.svg?react";
+import { TooltipButton } from "./tooltip-button";
+
+interface NewProjectButtonProps {
+ disabled?: boolean;
+}
+
+export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
+ const { t } = useTranslation();
+ const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/refresh-button.tsx b/frontend/src/components/shared/buttons/refresh-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4fe67a0489ffe34ea6f1980269351f7d8dfd17f8
--- /dev/null
+++ b/frontend/src/components/shared/buttons/refresh-button.tsx
@@ -0,0 +1,13 @@
+import Refresh from "#/icons/refresh.svg?react";
+
+interface RefreshButtonProps {
+ onClick: (event: React.MouseEvent) => void;
+}
+
+export function RefreshButton({ onClick }: RefreshButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/refresh-icon-button.tsx b/frontend/src/components/shared/buttons/refresh-icon-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..585186d2045f8dbbe6e426c780b8424f7277c9be
--- /dev/null
+++ b/frontend/src/components/shared/buttons/refresh-icon-button.tsx
@@ -0,0 +1,26 @@
+import { IoIosRefresh } from "react-icons/io";
+import { useTranslation } from "react-i18next";
+import { IconButton } from "./icon-button";
+import { I18nKey } from "#/i18n/declaration";
+
+interface RefreshIconButtonProps {
+ onClick: () => void;
+}
+
+export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ }
+ testId="refresh"
+ ariaLabel={t("BUTTON$REFRESH" as I18nKey)}
+ onClick={onClick}
+ />
+ );
+}
diff --git a/frontend/src/components/shared/buttons/remove-button.tsx b/frontend/src/components/shared/buttons/remove-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..afcf2ffc8c32542ea51b67cb28849ce7523b435a
--- /dev/null
+++ b/frontend/src/components/shared/buttons/remove-button.tsx
@@ -0,0 +1,21 @@
+import { cn } from "#/utils/utils";
+import CloseIcon from "#/icons/close.svg?react";
+
+interface RemoveButtonProps {
+ onClick: () => void;
+}
+
+export function RemoveButton({ onClick }: RemoveButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/scroll-to-bottom-button.tsx b/frontend/src/components/shared/buttons/scroll-to-bottom-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..25db4240166d57cd31d04ced993ed4b0fbd5bb0f
--- /dev/null
+++ b/frontend/src/components/shared/buttons/scroll-to-bottom-button.tsx
@@ -0,0 +1,18 @@
+import ArrowSendIcon from "#/icons/arrow-send.svg?react";
+
+interface ScrollToBottomButtonProps {
+ onClick: () => void;
+}
+
+export function ScrollToBottomButton({ onClick }: ScrollToBottomButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/settings-button.tsx b/frontend/src/components/shared/buttons/settings-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..68a8d38fa6bb42e0dcb91ea3893f4dec30fd53f3
--- /dev/null
+++ b/frontend/src/components/shared/buttons/settings-button.tsx
@@ -0,0 +1,29 @@
+import { useTranslation } from "react-i18next";
+import SettingsIcon from "#/icons/settings.svg?react";
+import { TooltipButton } from "./tooltip-button";
+import { I18nKey } from "#/i18n/declaration";
+
+interface SettingsButtonProps {
+ onClick?: () => void;
+ disabled?: boolean;
+}
+
+export function SettingsButton({
+ onClick,
+ disabled = false,
+}: SettingsButtonProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/stop-button.tsx b/frontend/src/components/shared/buttons/stop-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bf0ce7db79c025ad8270ff65cb595371b86a56d1
--- /dev/null
+++ b/frontend/src/components/shared/buttons/stop-button.tsx
@@ -0,0 +1,23 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface StopButtonProps {
+ isDisabled?: boolean;
+ onClick?: () => void;
+}
+
+export function StopButton({ isDisabled, onClick }: StopButtonProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/submit-button.tsx b/frontend/src/components/shared/buttons/submit-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..48f04b7ad551e40601ee9a5f3835cd36ee017ee1
--- /dev/null
+++ b/frontend/src/components/shared/buttons/submit-button.tsx
@@ -0,0 +1,23 @@
+import { useTranslation } from "react-i18next";
+import ArrowSendIcon from "#/icons/arrow-send.svg?react";
+import { I18nKey } from "#/i18n/declaration";
+
+interface SubmitButtonProps {
+ isDisabled?: boolean;
+ onClick: () => void;
+}
+
+export function SubmitButton({ isDisabled, onClick }: SubmitButtonProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/tooltip-button.tsx b/frontend/src/components/shared/buttons/tooltip-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3c04318e9f93572b07e43e163c98d0607f642782
--- /dev/null
+++ b/frontend/src/components/shared/buttons/tooltip-button.tsx
@@ -0,0 +1,125 @@
+import { Tooltip } from "@heroui/react";
+import React, { ReactNode } from "react";
+import { NavLink } from "react-router";
+import { cn } from "#/utils/utils";
+
+export interface TooltipButtonProps {
+ children: ReactNode;
+ tooltip: string;
+ onClick?: () => void;
+ href?: string;
+ navLinkTo?: string;
+ ariaLabel: string;
+ testId?: string;
+ className?: React.HTMLAttributes["className"];
+ disabled?: boolean;
+}
+
+export function TooltipButton({
+ children,
+ tooltip,
+ onClick,
+ href,
+ navLinkTo,
+ ariaLabel,
+ testId,
+ className,
+ disabled = false,
+}: TooltipButtonProps) {
+ const handleClick = (e: React.MouseEvent) => {
+ if (onClick && !disabled) {
+ onClick();
+ e.preventDefault();
+ }
+ };
+
+ const buttonContent = (
+
+ );
+
+ let content;
+
+ if (navLinkTo && !disabled) {
+ content = (
+
+ cn(
+ "hover:opacity-80",
+ isActive ? "text-white" : "text-[#9099AC]",
+ className,
+ )
+ }
+ aria-label={ariaLabel}
+ data-testid={testId}
+ >
+ {children}
+
+ );
+ } else if (navLinkTo && disabled) {
+ // If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
+ content = (
+
+ );
+ } else if (href && !disabled) {
+ content = (
+
+ {children}
+
+ );
+ } else if (href && disabled) {
+ // If disabled and has href, render a button that looks like a link but doesn't navigate
+ content = (
+
+ );
+ } else {
+ content = buttonContent;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/frontend/src/components/shared/buttons/trajectory-action-button.tsx b/frontend/src/components/shared/buttons/trajectory-action-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..293ceeb1d90034aa89cc6ff8be7da93337d13801
--- /dev/null
+++ b/frontend/src/components/shared/buttons/trajectory-action-button.tsx
@@ -0,0 +1,36 @@
+import { Tooltip } from "@heroui/react";
+
+interface TrajectoryActionButtonProps {
+ testId?: string;
+ onClick: () => void;
+ icon: React.ReactNode;
+ tooltip?: string;
+}
+
+export function TrajectoryActionButton({
+ testId,
+ onClick,
+ icon,
+ tooltip,
+}: TrajectoryActionButtonProps) {
+ const button = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+ {button}
+
+ );
+ }
+
+ return button;
+}
diff --git a/frontend/src/components/shared/custom-input.tsx b/frontend/src/components/shared/custom-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5fff6b7a55fa46a4ac7a8fcb53d78fd26795aa9d
--- /dev/null
+++ b/frontend/src/components/shared/custom-input.tsx
@@ -0,0 +1,43 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface CustomInputProps {
+ name: string;
+ label: string;
+ required?: boolean;
+ defaultValue?: string;
+ type?: "text" | "password";
+}
+
+export function CustomInput({
+ name,
+ label,
+ required,
+ defaultValue,
+ type = "text",
+}: CustomInputProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/error-toast.tsx b/frontend/src/components/shared/error-toast.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..36144eff96510e12a8e47f9cd97be6d64e602f68
--- /dev/null
+++ b/frontend/src/components/shared/error-toast.tsx
@@ -0,0 +1,25 @@
+import toast, { Toast } from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface ErrorToastProps {
+ id: Toast["id"];
+ error: string;
+}
+
+export function ErrorToast({ id, error }: ErrorToastProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {error}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/hero-heading.tsx b/frontend/src/components/shared/hero-heading.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ea8a15aa093dc69391b4a79b377b062060de71ab
--- /dev/null
+++ b/frontend/src/components/shared/hero-heading.tsx
@@ -0,0 +1,29 @@
+import { useTranslation } from "react-i18next";
+import BuildIt from "#/icons/build-it.svg?react";
+import { I18nKey } from "#/i18n/declaration";
+
+export function HeroHeading() {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/inputs/advanced-option-switch.tsx b/frontend/src/components/shared/inputs/advanced-option-switch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c0425d1ef6ba14d6d6dc4da05e81a2a5ded63ed9
--- /dev/null
+++ b/frontend/src/components/shared/inputs/advanced-option-switch.tsx
@@ -0,0 +1,41 @@
+import { Switch } from "@heroui/react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { cn } from "#/utils/utils";
+
+interface AdvancedOptionSwitchProps {
+ isDisabled: boolean;
+ showAdvancedOptions: boolean;
+ setShowAdvancedOptions: (value: boolean) => void;
+}
+
+export function AdvancedOptionSwitch({
+ isDisabled,
+ showAdvancedOptions,
+ setShowAdvancedOptions,
+}: AdvancedOptionSwitchProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {t(I18nKey.SETTINGS_FORM$ADVANCED_OPTIONS_LABEL)}
+
+ );
+}
diff --git a/frontend/src/components/shared/inputs/api-key-input.tsx b/frontend/src/components/shared/inputs/api-key-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7287bb0d625c68e5d7359e67d87de5d20c0cfa3a
--- /dev/null
+++ b/frontend/src/components/shared/inputs/api-key-input.tsx
@@ -0,0 +1,52 @@
+import { Input, Tooltip } from "@heroui/react";
+import { useTranslation } from "react-i18next";
+import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
+import { I18nKey } from "#/i18n/declaration";
+
+interface APIKeyInputProps {
+ isDisabled: boolean;
+ isSet: boolean;
+}
+
+export function APIKeyInput({ isDisabled, isSet }: APIKeyInputProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/inputs/base-url-input.tsx b/frontend/src/components/shared/inputs/base-url-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8868f5be17e81b3fa6af5d44626d276275a8265d
--- /dev/null
+++ b/frontend/src/components/shared/inputs/base-url-input.tsx
@@ -0,0 +1,30 @@
+import { Input } from "@heroui/react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface BaseUrlInputProps {
+ isDisabled: boolean;
+ defaultValue: string;
+}
+
+export function BaseUrlInput({ isDisabled, defaultValue }: BaseUrlInputProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/inputs/confirmation-mode-switch.tsx b/frontend/src/components/shared/inputs/confirmation-mode-switch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..55f71e00be02d689a02c16f3f6b59bfe5e565f31
--- /dev/null
+++ b/frontend/src/components/shared/inputs/confirmation-mode-switch.tsx
@@ -0,0 +1,37 @@
+import { Switch } from "@heroui/react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { cn } from "#/utils/utils";
+
+interface ConfirmationModeSwitchProps {
+ isDisabled: boolean;
+ defaultSelected: boolean;
+}
+
+export function ConfirmationModeSwitch({
+ isDisabled,
+ defaultSelected,
+}: ConfirmationModeSwitchProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
+
+ );
+}
diff --git a/frontend/src/components/shared/inputs/custom-model-input.tsx b/frontend/src/components/shared/inputs/custom-model-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b578325be9e6188272216fcf4bd52d60a513f3dd
--- /dev/null
+++ b/frontend/src/components/shared/inputs/custom-model-input.tsx
@@ -0,0 +1,38 @@
+import { Input } from "@heroui/react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface CustomModelInputProps {
+ isDisabled: boolean;
+ defaultValue: string;
+}
+
+export function CustomModelInput({
+ isDisabled,
+ defaultValue,
+}: CustomModelInputProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/loading-spinner.tsx b/frontend/src/components/shared/loading-spinner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f19d0fe4de65407016481488e81b6a979b2e55a
--- /dev/null
+++ b/frontend/src/components/shared/loading-spinner.tsx
@@ -0,0 +1,23 @@
+import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react";
+import { cn } from "#/utils/utils";
+
+interface LoadingSpinnerProps {
+ size: "small" | "large";
+}
+
+export function LoadingSpinner({ size }: LoadingSpinnerProps) {
+ const sizeStyle =
+ size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]";
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/base-modal/base-modal.tsx b/frontend/src/components/shared/modals/base-modal/base-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cf8eb22c8c0e5b08d212e80341d27d27107ef003
--- /dev/null
+++ b/frontend/src/components/shared/modals/base-modal/base-modal.tsx
@@ -0,0 +1,69 @@
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from "@heroui/react";
+import React from "react";
+import { Action, FooterContent } from "./footer-content";
+import { HeaderContent } from "./header-content";
+
+interface BaseModalProps {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ title: string;
+ contentClassName?: string;
+ bodyClassName?: string;
+ isDismissable?: boolean;
+ subtitle?: string;
+ actions?: Action[];
+ children?: React.ReactNode;
+ testID?: string;
+}
+
+export function BaseModal({
+ isOpen,
+ onOpenChange,
+ title,
+ contentClassName = "max-w-[30rem] p-[40px]",
+ bodyClassName = "px-0 py-[20px]",
+ isDismissable = true,
+ subtitle = undefined,
+ actions = [],
+ children = null,
+ testID,
+}: BaseModalProps) {
+ return (
+
+
+ {(closeModal) => (
+ <>
+ {title && (
+
+
+
+ )}
+
+ {children}
+
+ {actions && actions.length > 0 && (
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/base-modal/footer-content.tsx b/frontend/src/components/shared/modals/base-modal/footer-content.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..577a7b890dd3fc67ed2afcd4404714f889a9987e
--- /dev/null
+++ b/frontend/src/components/shared/modals/base-modal/footer-content.tsx
@@ -0,0 +1,38 @@
+import { Button } from "@heroui/react";
+import React from "react";
+
+export interface Action {
+ action: () => void;
+ isDisabled?: boolean;
+ label: string;
+ className?: string;
+ closeAfterAction?: boolean;
+}
+
+interface FooterContentProps {
+ actions: Action[];
+ closeModal: () => void;
+}
+
+export function FooterContent({ actions, closeModal }: FooterContentProps) {
+ return (
+ <>
+ {actions.map(
+ ({ action, isDisabled, label, className, closeAfterAction }) => (
+
+ ),
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/shared/modals/base-modal/header-content.tsx b/frontend/src/components/shared/modals/base-modal/header-content.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..894af6a4ffa1bc749d7fcd791474fb00d011361a
--- /dev/null
+++ b/frontend/src/components/shared/modals/base-modal/header-content.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+interface HeaderContentProps {
+ maintitle: string;
+ subtitle?: string;
+}
+
+export function HeaderContent({
+ maintitle,
+ subtitle = undefined,
+}: HeaderContentProps) {
+ return (
+ <>
+ {maintitle}
+ {subtitle && (
+ {subtitle}
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/shared/modals/confirmation-modal.tsx b/frontend/src/components/shared/modals/confirmation-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..426edccaea71dac2c8b4bcb11f64ed2ae795f85b
--- /dev/null
+++ b/frontend/src/components/shared/modals/confirmation-modal.tsx
@@ -0,0 +1,45 @@
+import { BrandButton } from "#/components/features/settings/brand-button";
+import { ModalBackdrop } from "./modal-backdrop";
+
+interface ConfirmationModalProps {
+ text: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export function ConfirmationModal({
+ text,
+ onConfirm,
+ onCancel,
+}: ConfirmationModalProps) {
+ return (
+
+
+
{text}
+
+
+ Cancel
+
+
+ Confirm
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx b/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c5b4e3f255f8ba15e5f8e40603f0933f18cb69e8
--- /dev/null
+++ b/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { ModalBody } from "../modal-body";
+import { ModalButton } from "../../buttons/modal-button";
+
+interface ButtonConfig {
+ text: string;
+ onClick: () => void;
+ className: React.HTMLProps["className"];
+}
+
+interface BaseModalTitleProps {
+ title: React.ReactNode;
+}
+
+export function BaseModalTitle({ title }: BaseModalTitleProps) {
+ return (
+
+ {title}
+
+ );
+}
+
+interface BaseModalDescriptionProps {
+ description?: React.ReactNode;
+ children?: React.ReactNode;
+}
+
+export function BaseModalDescription({
+ description,
+ children,
+}: BaseModalDescriptionProps) {
+ return (
+ {children || description}
+ );
+}
+
+interface BaseModalProps {
+ testId?: string;
+ title: string;
+ description: string;
+ buttons: ButtonConfig[];
+}
+
+export function BaseModal({
+ testId,
+ title,
+ description,
+ buttons,
+}: BaseModalProps) {
+ return (
+
+
+
+
+
+
+
+ {buttons.map((button, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/confirmation-modals/danger-modal.tsx b/frontend/src/components/shared/modals/confirmation-modals/danger-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..98f4a7155f4475386867122ce82c0f1679fc281d
--- /dev/null
+++ b/frontend/src/components/shared/modals/confirmation-modals/danger-modal.tsx
@@ -0,0 +1,40 @@
+import { BaseModal } from "./base-modal";
+
+interface DangerModalProps {
+ testId?: string;
+
+ title: string;
+ description: string;
+
+ buttons: {
+ danger: { text: string; onClick: () => void };
+ cancel: { text: string; onClick: () => void };
+ };
+}
+
+export function DangerModal({
+ testId,
+ title,
+ description,
+ buttons,
+}: DangerModalProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/modal-backdrop.tsx b/frontend/src/components/shared/modals/modal-backdrop.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5d3fcea432171b7fd2fcc03e73ab09c3cbabfa74
--- /dev/null
+++ b/frontend/src/components/shared/modals/modal-backdrop.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+
+interface ModalBackdropProps {
+ children: React.ReactNode;
+ onClose?: () => void;
+}
+
+export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
+ React.useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose?.();
+ };
+
+ window.addEventListener("keydown", handleEscape);
+ return () => window.removeEventListener("keydown", handleEscape);
+ }, []);
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) onClose?.(); // only close if the click was on the backdrop
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/modal-body.tsx b/frontend/src/components/shared/modals/modal-body.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3f503527afb871cd1d3c356dd402fb94a9df1521
--- /dev/null
+++ b/frontend/src/components/shared/modals/modal-body.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+import { cn } from "#/utils/utils";
+
+type ModalWidth = "small" | "medium";
+
+interface ModalBodyProps {
+ testID?: string;
+ children: React.ReactNode;
+ className?: React.HTMLProps["className"];
+ width?: ModalWidth;
+}
+
+export function ModalBody({
+ testID,
+ children,
+ className,
+ width = "small",
+}: ModalBodyProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/security/invariant/assets/logo.tsx b/frontend/src/components/shared/modals/security/invariant/assets/logo.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..39b1230aa2eea4f15909a1ccfb6d26f044662836
--- /dev/null
+++ b/frontend/src/components/shared/modals/security/invariant/assets/logo.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+
+interface InvariantLogoIconProps {
+ className?: string;
+}
+
+function InvariantLogoIcon({ className }: InvariantLogoIconProps) {
+ return (
+
+ );
+}
+
+export default InvariantLogoIcon;
diff --git a/frontend/src/components/shared/modals/security/invariant/invariant.tsx b/frontend/src/components/shared/modals/security/invariant/invariant.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..471264f8512f022456b4953fba7305914adc1db5
--- /dev/null
+++ b/frontend/src/components/shared/modals/security/invariant/invariant.tsx
@@ -0,0 +1,298 @@
+import React from "react";
+import { useSelector } from "react-redux";
+import { IoAlertCircle } from "react-icons/io5";
+import { useTranslation } from "react-i18next";
+import { Editor, Monaco } from "@monaco-editor/react";
+import { editor } from "monaco-editor";
+import { Button, Select, SelectItem } from "@heroui/react";
+import { useMutation } from "@tanstack/react-query";
+import { RootState } from "#/store";
+import {
+ ActionSecurityRisk,
+ SecurityAnalyzerLog,
+} from "#/state/security-analyzer-slice";
+import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
+import { I18nKey } from "#/i18n/declaration";
+import toast from "#/utils/toast";
+import InvariantLogoIcon from "./assets/logo";
+import { getFormattedDateTime } from "#/utils/gget-formatted-datetime";
+import { downloadJSON } from "#/utils/download-json";
+import InvariantService from "#/api/invariant-service";
+import { useGetPolicy } from "#/hooks/query/use-get-policy";
+import { useGetRiskSeverity } from "#/hooks/query/use-get-risk-severity";
+import { useGetTraces } from "#/hooks/query/use-get-traces";
+
+type SectionType = "logs" | "policy" | "settings";
+
+function SecurityInvariant() {
+ const { t } = useTranslation();
+ const { logs } = useSelector((state: RootState) => state.securityAnalyzer);
+
+ const [activeSection, setActiveSection] = React.useState("logs");
+ const [policy, setPolicy] = React.useState("");
+ const [selectedRisk, setSelectedRisk] = React.useState(
+ ActionSecurityRisk.MEDIUM,
+ );
+
+ const logsRef = React.useRef(null);
+
+ useGetPolicy({ onSuccess: setPolicy });
+
+ useGetRiskSeverity({
+ onSuccess: (riskSeverity) => {
+ setSelectedRisk(
+ riskSeverity === 0
+ ? ActionSecurityRisk.LOW
+ : riskSeverity || ActionSecurityRisk.MEDIUM,
+ );
+ },
+ });
+
+ const { refetch: exportTraces } = useGetTraces({
+ onSuccess: (traces) => {
+ toast.info(t(I18nKey.INVARIANT$TRACE_EXPORTED_MESSAGE));
+
+ const filename = `openhands-trace-${getFormattedDateTime()}.json`;
+ downloadJSON(traces, filename);
+ },
+ });
+
+ const { mutate: updatePolicy } = useMutation({
+ mutationFn: (variables: { policy: string }) =>
+ InvariantService.updatePolicy(variables.policy),
+ onSuccess: () => {
+ toast.info(t(I18nKey.INVARIANT$POLICY_UPDATED_MESSAGE));
+ },
+ });
+
+ const { mutate: updateRiskSeverity } = useMutation({
+ mutationFn: (variables: { riskSeverity: number }) =>
+ InvariantService.updateRiskSeverity(variables.riskSeverity),
+ onSuccess: () => {
+ toast.info(t(I18nKey.INVARIANT$SETTINGS_UPDATED_MESSAGE));
+ },
+ });
+
+ useScrollToBottom(logsRef);
+
+ const getRiskColor = React.useCallback((risk: ActionSecurityRisk) => {
+ switch (risk) {
+ case ActionSecurityRisk.LOW:
+ return "text-green-500";
+ case ActionSecurityRisk.MEDIUM:
+ return "text-yellow-500";
+ case ActionSecurityRisk.HIGH:
+ return "text-red-500";
+ case ActionSecurityRisk.UNKNOWN:
+ default:
+ return "text-gray-500";
+ }
+ }, []);
+
+ const getRiskText = React.useCallback(
+ (risk: ActionSecurityRisk) => {
+ switch (risk) {
+ case ActionSecurityRisk.LOW:
+ return t(I18nKey.SECURITY_ANALYZER$LOW_RISK);
+ case ActionSecurityRisk.MEDIUM:
+ return t(I18nKey.SECURITY_ANALYZER$MEDIUM_RISK);
+ case ActionSecurityRisk.HIGH:
+ return t(I18nKey.SECURITY_ANALYZER$HIGH_RISK);
+ case ActionSecurityRisk.UNKNOWN:
+ default:
+ return t(I18nKey.SECURITY_ANALYZER$UNKNOWN_RISK);
+ }
+ },
+ [t],
+ );
+
+ const handleEditorDidMount = React.useCallback(
+ (_: editor.IStandaloneCodeEditor, monaco: Monaco): void => {
+ monaco.editor.defineTheme("my-theme", {
+ base: "vs-dark",
+ inherit: true,
+ rules: [],
+ colors: {
+ "editor.background": "#171717",
+ },
+ });
+
+ monaco.editor.setTheme("my-theme");
+ },
+ [],
+ );
+
+ const sections: Record = {
+ logs: (
+ <>
+
+
{t(I18nKey.INVARIANT$LOG_LABEL)}
+
+
+
+ {logs.map((log: SecurityAnalyzerLog, index: number) => (
+
+
+ {log.content}
+ {(log.confirmation_state === "awaiting_confirmation" ||
+ log.confirmed_changed) && (
+
+ )}
+
+
+ {getRiskText(log.security_risk)}
+
+
+ ))}
+
+ >
+ ),
+ policy: (
+ <>
+
+
{t(I18nKey.INVARIANT$POLICY_LABEL)}
+
+
+
+ setPolicy(value || "")}
+ />
+
+ >
+ ),
+ settings: (
+ <>
+
+
{t(I18nKey.INVARIANT$SETTINGS_LABEL)}
+
+
+
+
+
+ {t(I18nKey.INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL)}
+
+
+
+
+ >
+ ),
+ };
+
+ return (
+
+
+
+
+ {t(I18nKey.INVARIANT$INVARIANT_ANALYZER_LABEL)}
+
+
+ {t(I18nKey.INVARIANT$INVARIANT_ANALYZER_MESSAGE)}{" "}
+
+ {t(I18nKey.INVARIANT$CLICK_TO_LEARN_MORE_LABEL)}
+
+
+
+
+ setActiveSection("logs")}
+ >
+ {t(I18nKey.INVARIANT$LOG_LABEL)}
+
+ setActiveSection("policy")}
+ >
+ {t(I18nKey.INVARIANT$POLICY_LABEL)}
+
+ setActiveSection("settings")}
+ >
+ {t(I18nKey.INVARIANT$SETTINGS_LABEL)}
+
+
+
+
+ {sections[activeSection as SectionType]}
+
+
+ );
+}
+
+export default SecurityInvariant;
diff --git a/frontend/src/components/shared/modals/security/security.tsx b/frontend/src/components/shared/modals/security/security.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ba30bc08c851ac55503e02b024d8e3a3f2f13201
--- /dev/null
+++ b/frontend/src/components/shared/modals/security/security.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import SecurityInvariant from "./invariant/invariant";
+import { I18nKey } from "#/i18n/declaration";
+import { BaseModal } from "../base-modal/base-modal";
+
+interface SecurityProps {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ securityAnalyzer: string;
+}
+
+enum SecurityAnalyzerOption {
+ INVARIANT = "invariant",
+}
+
+const SecurityAnalyzers: Record = {
+ [SecurityAnalyzerOption.INVARIANT]: SecurityInvariant,
+};
+
+function Security({ isOpen, onOpenChange, securityAnalyzer }: SecurityProps) {
+ const { t } = useTranslation();
+
+ const AnalyzerComponent =
+ securityAnalyzer &&
+ SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
+ ? SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
+ : () => {t(I18nKey.SECURITY$UNKNOWN_ANALYZER_LABEL)}
;
+
+ return (
+
+
+
+ );
+}
+
+export default Security;
diff --git a/frontend/src/components/shared/modals/settings/model-selector.tsx b/frontend/src/components/shared/modals/settings/model-selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..42b07da26a4c4ab21fefae64666f24cce600381a
--- /dev/null
+++ b/frontend/src/components/shared/modals/settings/model-selector.tsx
@@ -0,0 +1,172 @@
+import {
+ Autocomplete,
+ AutocompleteItem,
+ AutocompleteSection,
+} from "@heroui/react";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { mapProvider } from "#/utils/map-provider";
+import { VERIFIED_MODELS, VERIFIED_PROVIDERS } from "#/utils/verified-models";
+import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
+
+interface ModelSelectorProps {
+ isDisabled?: boolean;
+ models: Record;
+ currentModel?: string;
+ onChange?: (model: string | null) => void;
+}
+
+export function ModelSelector({
+ isDisabled,
+ models,
+ currentModel,
+ onChange,
+}: ModelSelectorProps) {
+ const [, setLitellmId] = React.useState(null);
+ const [selectedProvider, setSelectedProvider] = React.useState(
+ null,
+ );
+ const [selectedModel, setSelectedModel] = React.useState(null);
+
+ React.useEffect(() => {
+ if (currentModel) {
+ // runs when resetting to defaults
+ const { provider, model } = extractModelAndProvider(currentModel);
+
+ setLitellmId(currentModel);
+ setSelectedProvider(provider);
+ setSelectedModel(model);
+ }
+ }, [currentModel]);
+
+ const handleChangeProvider = (provider: string) => {
+ setSelectedProvider(provider);
+ setSelectedModel(null);
+
+ const separator = models[provider]?.separator || "";
+ setLitellmId(provider + separator);
+ };
+
+ const handleChangeModel = (model: string) => {
+ const separator = models[selectedProvider || ""]?.separator || "";
+ let fullModel = selectedProvider + separator + model;
+ if (selectedProvider === "openai") {
+ // LiteLLM lists OpenAI models without the openai/ prefix
+ fullModel = model;
+ }
+ setLitellmId(fullModel);
+ setSelectedModel(model);
+ onChange?.(fullModel);
+ };
+
+ const clear = () => {
+ setSelectedProvider(null);
+ setLitellmId(null);
+ };
+
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d8d49e2da571cbb137994bd9545485fdc11949ba
--- /dev/null
+++ b/frontend/src/components/shared/modals/settings/settings-form.tsx
@@ -0,0 +1,135 @@
+import { useLocation } from "react-router";
+import { useTranslation } from "react-i18next";
+import React from "react";
+import posthog from "posthog-js";
+import { I18nKey } from "#/i18n/declaration";
+import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
+import { DangerModal } from "../confirmation-modals/danger-modal";
+import { extractSettings } from "#/utils/settings-utils";
+import { ModalBackdrop } from "../modal-backdrop";
+import { ModelSelector } from "./model-selector";
+import { Settings } from "#/types/settings";
+import { BrandButton } from "#/components/features/settings/brand-button";
+import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
+import { SettingsInput } from "#/components/features/settings/settings-input";
+import { HelpLink } from "#/components/features/settings/help-link";
+import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
+
+interface SettingsFormProps {
+ settings: Settings;
+ models: string[];
+ onClose: () => void;
+}
+
+export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
+ const { mutate: saveUserSettings } = useSaveSettings();
+
+ const location = useLocation();
+ const { t } = useTranslation();
+
+ const formRef = React.useRef(null);
+
+ const [confirmEndSessionModalOpen, setConfirmEndSessionModalOpen] =
+ React.useState(false);
+
+ const handleFormSubmission = async (formData: FormData) => {
+ const newSettings = extractSettings(formData);
+
+ await saveUserSettings(newSettings, {
+ onSuccess: () => {
+ onClose();
+
+ posthog.capture("settings_saved", {
+ LLM_MODEL: newSettings.LLM_MODEL,
+ LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET",
+ SEARCH_API_KEY_SET: newSettings.SEARCH_API_KEY ? "SET" : "UNSET",
+ REMOTE_RUNTIME_RESOURCE_FACTOR:
+ newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
+ });
+ },
+ });
+ };
+
+ const handleConfirmEndSession = () => {
+ const formData = new FormData(formRef.current ?? undefined);
+ handleFormSubmission(formData);
+ };
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ const formData = new FormData(event.currentTarget);
+
+ if (location.pathname.startsWith("/conversations/")) {
+ setConfirmEndSessionModalOpen(true);
+ } else {
+ handleFormSubmission(formData);
+ }
+ };
+
+ const isLLMKeySet = settings.LLM_API_KEY_SET;
+
+ return (
+
+
+
+ {confirmEndSessionModalOpen && (
+
+ setConfirmEndSessionModalOpen(false),
+ },
+ }}
+ />
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/settings/settings-modal.tsx b/frontend/src/components/shared/modals/settings/settings-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0735e2ae1fdceffae650526f37e139a0921bcf0a
--- /dev/null
+++ b/frontend/src/components/shared/modals/settings/settings-modal.tsx
@@ -0,0 +1,59 @@
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
+import { I18nKey } from "#/i18n/declaration";
+import { LoadingSpinner } from "../../loading-spinner";
+import { ModalBackdrop } from "../modal-backdrop";
+import { SettingsForm } from "./settings-form";
+import { Settings } from "#/types/settings";
+import { DEFAULT_SETTINGS } from "#/services/settings";
+
+interface SettingsModalProps {
+ settings?: Settings;
+ onClose: () => void;
+}
+
+export function SettingsModal({ onClose, settings }: SettingsModalProps) {
+ const aiConfigOptions = useAIConfigOptions();
+ const { t } = useTranslation();
+
+ return (
+
+
+ {aiConfigOptions.error && (
+
{aiConfigOptions.error.message}
+ )}
+
+ {t(I18nKey.AI_SETTINGS$TITLE)}
+
+
+ {t(I18nKey.SETTINGS$DESCRIPTION)}{" "}
+ {t(I18nKey.SETTINGS$FOR_OTHER_OPTIONS)}
+
+ {t(I18nKey.SETTINGS$SEE_ADVANCED_SETTINGS)}
+
+
+
+ {aiConfigOptions.isLoading && (
+
+
+
+ )}
+ {aiConfigOptions.data && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/task-form.tsx b/frontend/src/components/shared/task-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f6d35c84232e65838ae4d79f787c648e75dbe7cc
--- /dev/null
+++ b/frontend/src/components/shared/task-form.tsx
@@ -0,0 +1,126 @@
+import React from "react";
+import { useNavigation } from "react-router";
+import { useDispatch, useSelector } from "react-redux";
+import { RootState } from "#/store";
+import { addFile, removeFile } from "#/state/initial-query-slice";
+import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
+import { SUGGESTIONS } from "#/utils/suggestions";
+import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
+import { ChatInput } from "#/components/features/chat/chat-input";
+import { getRandomKey } from "#/utils/get-random-key";
+import { cn } from "#/utils/utils";
+import { AttachImageLabel } from "../features/images/attach-image-label";
+import { ImageCarousel } from "../features/images/image-carousel";
+import { UploadImageInput } from "../features/images/upload-image-input";
+import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
+import { LoadingSpinner } from "./loading-spinner";
+
+interface TaskFormProps {
+ ref: React.RefObject;
+}
+
+export function TaskForm({ ref }: TaskFormProps) {
+ const dispatch = useDispatch();
+ const navigation = useNavigation();
+
+ const { files } = useSelector((state: RootState) => state.initialQuery);
+
+ const [text, setText] = React.useState("");
+ const [suggestion, setSuggestion] = React.useState(() => {
+ const key = getRandomKey(SUGGESTIONS["non-repo"]);
+ return { key, value: SUGGESTIONS["non-repo"][key] };
+ });
+ const [inputIsFocused, setInputIsFocused] = React.useState(false);
+ const { mutate: createConversation, isPending } = useCreateConversation();
+
+ const onRefreshSuggestion = () => {
+ const suggestions = SUGGESTIONS["non-repo"];
+ // remove current suggestion to avoid refreshing to the same suggestion
+ const suggestionCopy = { ...suggestions };
+ delete suggestionCopy[suggestion.key];
+
+ const key = getRandomKey(suggestionCopy);
+ setSuggestion({ key, value: suggestions[key] });
+ };
+
+ const onClickSuggestion = () => {
+ setText(suggestion.value);
+ };
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ const formData = new FormData(event.currentTarget);
+
+ const q = formData.get("q")?.toString();
+ createConversation({ q });
+ };
+
+ return (
+
+
+
{
+ const promises = uploadedFiles.map(convertImageToBase64);
+ const base64Images = await Promise.all(promises);
+ base64Images.forEach((base64) => {
+ dispatch(addFile(base64));
+ });
+ }}
+ label={}
+ />
+ {files.length > 0 && (
+ dispatch(removeFile(index))}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6edd3cded4cb17fb8e2cc1bc6dc51607a618b097
--- /dev/null
+++ b/frontend/src/context/ws-client-provider.tsx
@@ -0,0 +1,381 @@
+import React from "react";
+import { io, Socket } from "socket.io-client";
+import { useQueryClient } from "@tanstack/react-query";
+import EventLogger from "#/utils/event-logger";
+import { handleAssistantMessage } from "#/services/actions";
+import { showChatError, trackError } from "#/utils/error-handler";
+import { useRate } from "#/hooks/use-rate";
+import { OpenHandsParsedEvent } from "#/types/core";
+import {
+ AssistantMessageAction,
+ CommandAction,
+ FileEditAction,
+ FileWriteAction,
+ OpenHandsAction,
+ UserMessageAction,
+} from "#/types/core/actions";
+import { Conversation } from "#/api/open-hands.types";
+import { useUserProviders } from "#/hooks/use-user-providers";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
+import { OpenHandsObservation } from "#/types/core/observations";
+import {
+ isAgentStateChangeObservation,
+ isErrorObservation,
+ isOpenHandsAction,
+ isOpenHandsObservation,
+ isStatusUpdate,
+ isUserMessage,
+} from "#/types/core/guards";
+import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
+import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
+
+const hasValidMessageProperty = (obj: unknown): obj is { message: string } =>
+ typeof obj === "object" &&
+ obj !== null &&
+ "message" in obj &&
+ typeof obj.message === "string";
+
+const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
+ typeof event === "object" &&
+ event !== null &&
+ "id" in event &&
+ "source" in event &&
+ "message" in event &&
+ "timestamp" in event;
+
+const isFileWriteAction = (
+ event: OpenHandsParsedEvent,
+): event is FileWriteAction => "action" in event && event.action === "write";
+
+const isFileEditAction = (
+ event: OpenHandsParsedEvent,
+): event is FileEditAction => "action" in event && event.action === "edit";
+
+const isCommandAction = (event: OpenHandsParsedEvent): event is CommandAction =>
+ "action" in event && event.action === "run";
+
+const isAssistantMessage = (
+ event: OpenHandsParsedEvent,
+): event is AssistantMessageAction =>
+ "source" in event &&
+ "type" in event &&
+ event.source === "agent" &&
+ event.type === "message";
+
+const isMessageAction = (
+ event: OpenHandsParsedEvent,
+): event is UserMessageAction | AssistantMessageAction =>
+ isUserMessage(event) || isAssistantMessage(event);
+
+export enum WsClientProviderStatus {
+ CONNECTED,
+ DISCONNECTED,
+ CONNECTING,
+}
+
+interface UseWsClient {
+ status: WsClientProviderStatus;
+ isLoadingMessages: boolean;
+ events: Record[];
+ parsedEvents: (OpenHandsAction | OpenHandsObservation)[];
+ send: (event: Record) => void;
+}
+
+const WsClientContext = React.createContext({
+ status: WsClientProviderStatus.DISCONNECTED,
+ isLoadingMessages: true,
+ events: [],
+ parsedEvents: [],
+ send: () => {
+ throw new Error("not connected");
+ },
+});
+
+interface WsClientProviderProps {
+ conversationId: string;
+}
+
+interface ErrorArg {
+ message?: string;
+ data?: ErrorArgData | unknown;
+}
+
+interface ErrorArgData {
+ msg_id: string;
+}
+
+export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
+ const isObject = (val: unknown): val is object =>
+ !!val && typeof val === "object";
+ const isString = (val: unknown): val is string => typeof val === "string";
+ if (isObject(data) && "message" in data && isString(data.message)) {
+ if (data.message === "websocket error" || data.message === "timeout") {
+ return;
+ }
+ let msgId: string | undefined;
+ let metadata: Record = {};
+
+ if ("data" in data && isObject(data.data)) {
+ if ("msg_id" in data.data && isString(data.data.msg_id)) {
+ msgId = data.data.msg_id;
+ }
+ metadata = data.data as Record;
+ }
+
+ showChatError({
+ message: data.message,
+ source: "websocket",
+ metadata,
+ msgId,
+ });
+ }
+}
+
+export function WsClientProvider({
+ conversationId,
+ children,
+}: React.PropsWithChildren) {
+ const { removeOptimisticUserMessage } = useOptimisticUserMessage();
+ const { setErrorMessage, removeErrorMessage } = useWSErrorMessage();
+ const queryClient = useQueryClient();
+ const sioRef = React.useRef(null);
+ const [status, setStatus] = React.useState(
+ WsClientProviderStatus.DISCONNECTED,
+ );
+ const [events, setEvents] = React.useState[]>([]);
+ const [parsedEvents, setParsedEvents] = React.useState<
+ (OpenHandsAction | OpenHandsObservation)[]
+ >([]);
+ const lastEventRef = React.useRef | null>(null);
+ const { providers } = useUserProviders();
+
+ const messageRateHandler = useRate({ threshold: 250 });
+ const { data: conversation, refetch: refetchConversation } =
+ useActiveConversation();
+
+ function send(event: Record) {
+ if (!sioRef.current) {
+ EventLogger.error("WebSocket is not connected.");
+ return;
+ }
+ sioRef.current.emit("oh_user_action", event);
+ }
+
+ function handleConnect() {
+ setStatus(WsClientProviderStatus.CONNECTED);
+ removeErrorMessage();
+ }
+
+ function handleMessage(event: Record) {
+ handleAssistantMessage(event);
+
+ if (isOpenHandsEvent(event)) {
+ const isStatusUpdateError =
+ isStatusUpdate(event) && event.type === "error";
+
+ const isAgentStateChangeError =
+ isAgentStateChangeObservation(event) &&
+ event.extras.agent_state === "error";
+
+ if (isStatusUpdateError || isAgentStateChangeError) {
+ const errorMessage = isStatusUpdate(event)
+ ? event.message
+ : event.extras.reason || "Unknown error";
+
+ trackError({
+ message: errorMessage,
+ source: "chat",
+ metadata: { msgId: event.id },
+ });
+ setErrorMessage(errorMessage);
+
+ return;
+ }
+
+ if (isOpenHandsAction(event) || isOpenHandsObservation(event)) {
+ setParsedEvents((prevEvents) => [...prevEvents, event]);
+ }
+
+ if (isErrorObservation(event)) {
+ trackError({
+ message: event.message,
+ source: "chat",
+ metadata: { msgId: event.id },
+ });
+ } else {
+ removeErrorMessage();
+ }
+
+ if (isUserMessage(event)) {
+ removeOptimisticUserMessage();
+ }
+
+ if (isMessageAction(event)) {
+ messageRateHandler.record(new Date().getTime());
+ }
+
+ // Invalidate diffs cache when a file is edited or written
+ if (
+ isFileEditAction(event) ||
+ isFileWriteAction(event) ||
+ isCommandAction(event)
+ ) {
+ queryClient.invalidateQueries(
+ {
+ queryKey: ["file_changes", conversationId],
+ },
+ // Do not refetch if we are still receiving messages at a high rate (e.g., loading an existing conversation)
+ // This prevents unnecessary refetches when the user is still receiving messages
+ { cancelRefetch: false },
+ );
+
+ // Invalidate file diff cache when a file is edited or written
+ if (!isCommandAction(event)) {
+ const cachedConversaton = queryClient.getQueryData([
+ "user",
+ "conversation",
+ conversationId,
+ ]);
+ const clonedRepositoryDirectory =
+ cachedConversaton?.selected_repository?.split("/").pop();
+
+ let fileToInvalidate = event.args.path.replace("/workspace/", "");
+ if (clonedRepositoryDirectory) {
+ fileToInvalidate = fileToInvalidate.replace(
+ `${clonedRepositoryDirectory}/`,
+ "",
+ );
+ }
+
+ queryClient.invalidateQueries({
+ queryKey: ["file_diff", conversationId, fileToInvalidate],
+ });
+ }
+ }
+ }
+
+ setEvents((prevEvents) => [...prevEvents, event]);
+ if (!Number.isNaN(parseInt(event.id as string, 10))) {
+ lastEventRef.current = event;
+ }
+ }
+
+ function handleDisconnect(data: unknown) {
+ setStatus(WsClientProviderStatus.DISCONNECTED);
+ const sio = sioRef.current;
+ if (!sio) {
+ return;
+ }
+ sio.io.opts.query = sio.io.opts.query || {};
+ sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
+ updateStatusWhenErrorMessagePresent(data);
+
+ setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
+ }
+
+ function handleError(data: unknown) {
+ // set status
+ setStatus(WsClientProviderStatus.DISCONNECTED);
+ updateStatusWhenErrorMessagePresent(data);
+
+ setErrorMessage(
+ hasValidMessageProperty(data)
+ ? data.message
+ : "An unknown error occurred on the WebSocket connection.",
+ );
+
+ // check if something went wrong with the conversation.
+ refetchConversation();
+ }
+
+ React.useEffect(() => {
+ lastEventRef.current = null;
+
+ // reset events when conversationId changes
+ setEvents([]);
+ setParsedEvents([]);
+ setStatus(WsClientProviderStatus.DISCONNECTED);
+ }, [conversationId]);
+
+ React.useEffect(() => {
+ if (!conversationId) {
+ throw new Error("No conversation ID provided");
+ }
+ if (
+ !conversation ||
+ ["STOPPED", "STARTING"].includes(conversation.status)
+ ) {
+ return () => undefined; // conversation not yet loaded
+ }
+
+ let sio = sioRef.current;
+
+ if (sio?.connected) {
+ sio.disconnect();
+ }
+
+ const lastEvent = lastEventRef.current;
+ const query = {
+ latest_event_id: lastEvent?.id ?? -1,
+ conversation_id: conversationId,
+ providers_set: providers,
+ session_api_key: conversation.session_api_key, // Have to set here because socketio doesn't support custom headers. :(
+ };
+
+ let baseUrl = null;
+ if (conversation.url && !conversation.url.startsWith("/")) {
+ baseUrl = new URL(conversation.url).host;
+ } else {
+ baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
+ }
+
+ sio = io(baseUrl, {
+ transports: ["websocket"],
+ query,
+ });
+ sio.on("connect", handleConnect);
+ sio.on("oh_event", handleMessage);
+ sio.on("connect_error", handleError);
+ sio.on("connect_failed", handleError);
+ sio.on("disconnect", handleDisconnect);
+
+ sioRef.current = sio;
+
+ return () => {
+ sio.off("connect", handleConnect);
+ sio.off("oh_event", handleMessage);
+ sio.off("connect_error", handleError);
+ sio.off("connect_failed", handleError);
+ sio.off("disconnect", handleDisconnect);
+ };
+ }, [conversationId, conversation?.url, conversation?.status]);
+
+ React.useEffect(
+ () => () => {
+ const sio = sioRef.current;
+ if (sio) {
+ sio.off("disconnect", handleDisconnect);
+ sio.disconnect();
+ }
+ },
+ [],
+ );
+
+ const value = React.useMemo(
+ () => ({
+ status,
+ isLoadingMessages: messageRateHandler.isUnderThreshold,
+ events,
+ parsedEvents,
+ send,
+ }),
+ [status, messageRateHandler.isUnderThreshold, events, parsedEvents],
+ );
+
+ return {children};
+}
+
+export function useWsClient() {
+ const context = React.useContext(WsClientContext);
+ return context;
+}
diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5a2c4fa45ed8c4ce67765dcc67511290c2bffed2
--- /dev/null
+++ b/frontend/src/entry.client.tsx
@@ -0,0 +1,75 @@
+/* eslint-disable react/react-in-jsx-scope */
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/file-conventions/entry.client
+ */
+
+import { HydratedRouter } from "react-router/dom";
+import React, { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+import { Provider } from "react-redux";
+import posthog from "posthog-js";
+import "./i18n";
+import { QueryClientProvider } from "@tanstack/react-query";
+import store from "./store";
+import OpenHands from "./api/open-hands";
+import { displayErrorToast } from "./utils/custom-toast-handlers";
+import { queryClient } from "./query-client-config";
+
+function PosthogInit() {
+ const [posthogClientKey, setPosthogClientKey] = React.useState(
+ null,
+ );
+
+ React.useEffect(() => {
+ (async () => {
+ try {
+ const config = await OpenHands.getConfig();
+ setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
+ } catch (error) {
+ displayErrorToast("Error fetching PostHog client key");
+ }
+ })();
+ }, []);
+
+ React.useEffect(() => {
+ if (posthogClientKey) {
+ posthog.init(posthogClientKey, {
+ api_host: "https://us.i.posthog.com",
+ person_profiles: "identified_only",
+ });
+ }
+ }, [posthogClientKey]);
+
+ return null;
+}
+
+async function prepareApp() {
+ if (
+ process.env.NODE_ENV === "development" &&
+ import.meta.env.VITE_MOCK_API === "true"
+ ) {
+ const { worker } = await import("./mocks/browser");
+
+ await worker.start({
+ onUnhandledRequest: "bypass",
+ });
+ }
+}
+
+prepareApp().then(() =>
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+
+
+
+
+ ,
+ );
+ }),
+);
diff --git a/frontend/src/hooks/mutation/stripe/use-create-stripe-checkout-session.ts b/frontend/src/hooks/mutation/stripe/use-create-stripe-checkout-session.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e6a58c78e304b7dc0788f4333f513258dd3fd24d
--- /dev/null
+++ b/frontend/src/hooks/mutation/stripe/use-create-stripe-checkout-session.ts
@@ -0,0 +1,12 @@
+import { useMutation } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+
+export const useCreateStripeCheckoutSession = () =>
+ useMutation({
+ mutationFn: async (variables: { amount: number }) => {
+ const redirectUrl = await OpenHands.createCheckoutSession(
+ variables.amount,
+ );
+ window.location.href = redirectUrl;
+ },
+ });
diff --git a/frontend/src/hooks/mutation/use-add-git-providers.ts b/frontend/src/hooks/mutation/use-add-git-providers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..323a33b97f61a7236740eb4901eb1ff596b8449f
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-add-git-providers.ts
@@ -0,0 +1,21 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { SecretsService } from "#/api/secrets-service";
+import { Provider, ProviderToken } from "#/types/settings";
+
+export const useAddGitProviders = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ providers,
+ }: {
+ providers: Record;
+ }) => SecretsService.addGitProvider(providers),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["settings"] });
+ },
+ meta: {
+ disableToast: true,
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-create-api-key.ts b/frontend/src/hooks/mutation/use-create-api-key.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fd3c05c975eefc78c9f62b73004b688b73eed67b
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-create-api-key.ts
@@ -0,0 +1,16 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys";
+import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
+
+export function useCreateApiKey() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (name: string): Promise =>
+ ApiKeysClient.createApiKey(name),
+ onSuccess: () => {
+ // Invalidate the API keys query to trigger a refetch
+ queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
+ },
+ });
+}
diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1f515f7fc37a4e41c65da774751aa8cc42e17c3f
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-create-conversation.ts
@@ -0,0 +1,58 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router";
+import posthog from "posthog-js";
+import { useDispatch, useSelector } from "react-redux";
+import OpenHands from "#/api/open-hands";
+import { setInitialPrompt } from "#/state/initial-query-slice";
+import { RootState } from "#/store";
+import { GitRepository } from "#/types/git";
+import { SuggestedTask } from "#/components/features/home/tasks/task.types";
+
+export const useCreateConversation = () => {
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const queryClient = useQueryClient();
+
+ const { selectedRepository, files, replayJson } = useSelector(
+ (state: RootState) => state.initialQuery,
+ );
+
+ return useMutation({
+ mutationKey: ["create-conversation"],
+ mutationFn: async (variables: {
+ q?: string;
+ selectedRepository?: GitRepository | null;
+ selected_branch?: string;
+ suggested_task?: SuggestedTask;
+ }) => {
+ if (variables.q) dispatch(setInitialPrompt(variables.q));
+
+ return OpenHands.createConversation(
+ variables.selectedRepository
+ ? variables.selectedRepository.full_name
+ : undefined,
+ variables.selectedRepository
+ ? variables.selectedRepository.git_provider
+ : undefined,
+ variables.q,
+ files,
+ replayJson || undefined,
+ variables.suggested_task || undefined,
+ variables.selected_branch,
+ );
+ },
+ onSuccess: async ({ conversation_id: conversationId }, { q }) => {
+ posthog.capture("initial_query_submitted", {
+ entry_point: "task_form",
+ query_character_length: q?.length,
+ has_repository: !!selectedRepository,
+ has_files: files.length > 0,
+ has_replay_json: !!replayJson,
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["user", "conversations"],
+ });
+ navigate(`/conversations/${conversationId}`);
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-create-secret.ts b/frontend/src/hooks/mutation/use-create-secret.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d8a7f7177f95ea6afc8412bc4b0264e3a5ac91de
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-create-secret.ts
@@ -0,0 +1,15 @@
+import { useMutation } from "@tanstack/react-query";
+import { SecretsService } from "#/api/secrets-service";
+
+export const useCreateSecret = () =>
+ useMutation({
+ mutationFn: ({
+ name,
+ value,
+ description,
+ }: {
+ name: string;
+ value: string;
+ description?: string;
+ }) => SecretsService.createSecret(name, value, description),
+ });
diff --git a/frontend/src/hooks/mutation/use-delete-api-key.ts b/frontend/src/hooks/mutation/use-delete-api-key.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4f4b566fab8c0c8c504131577c60757e5c5f2a37
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-delete-api-key.ts
@@ -0,0 +1,17 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import ApiKeysClient from "#/api/api-keys";
+import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
+
+export function useDeleteApiKey() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string): Promise => {
+ await ApiKeysClient.deleteApiKey(id);
+ },
+ onSuccess: () => {
+ // Invalidate the API keys query to trigger a refetch
+ queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
+ },
+ });
+}
diff --git a/frontend/src/hooks/mutation/use-delete-conversation.ts b/frontend/src/hooks/mutation/use-delete-conversation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cedc5475caaeb8b36f9624c25d2340a0dc8bfa9a
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-delete-conversation.ts
@@ -0,0 +1,39 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+
+export const useDeleteConversation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (variables: { conversationId: string }) =>
+ OpenHands.deleteUserConversation(variables.conversationId),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
+ const previousConversations = queryClient.getQueryData([
+ "user",
+ "conversations",
+ ]);
+
+ queryClient.setQueryData(
+ ["user", "conversations"],
+ (old: { conversation_id: string }[] | undefined) =>
+ old?.filter(
+ (conv) => conv.conversation_id !== variables.conversationId,
+ ),
+ );
+
+ return { previousConversations };
+ },
+ onError: (err, variables, context) => {
+ if (context?.previousConversations) {
+ queryClient.setQueryData(
+ ["user", "conversations"],
+ context.previousConversations,
+ );
+ }
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-delete-secret.ts b/frontend/src/hooks/mutation/use-delete-secret.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a7c360a5d15728551614b57d42dec31872f84349
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-delete-secret.ts
@@ -0,0 +1,7 @@
+import { useMutation } from "@tanstack/react-query";
+import { SecretsService } from "#/api/secrets-service";
+
+export const useDeleteSecret = () =>
+ useMutation({
+ mutationFn: (id: string) => SecretsService.deleteSecret(id),
+ });
diff --git a/frontend/src/hooks/mutation/use-get-trajectory.ts b/frontend/src/hooks/mutation/use-get-trajectory.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e2ad96e64d24876be747314d7b9e43d36d0816d0
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-get-trajectory.ts
@@ -0,0 +1,7 @@
+import { useMutation } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+
+export const useGetTrajectory = () =>
+ useMutation({
+ mutationFn: (cid: string) => OpenHands.getTrajectory(cid),
+ });
diff --git a/frontend/src/hooks/mutation/use-logout.ts b/frontend/src/hooks/mutation/use-logout.ts
new file mode 100644
index 0000000000000000000000000000000000000000..283250a2ff953e1dcc0fd1b10347542d1502fb9d
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-logout.ts
@@ -0,0 +1,30 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import posthog from "posthog-js";
+import OpenHands from "#/api/open-hands";
+import { useConfig } from "../query/use-config";
+import { clearLoginData } from "#/utils/local-storage";
+
+export const useLogout = () => {
+ const queryClient = useQueryClient();
+ const { data: config } = useConfig();
+
+ return useMutation({
+ mutationFn: () => OpenHands.logout(config?.APP_MODE ?? "oss"),
+ onSuccess: async () => {
+ queryClient.removeQueries({ queryKey: ["tasks"] });
+ queryClient.removeQueries({ queryKey: ["settings"] });
+ queryClient.removeQueries({ queryKey: ["user"] });
+ queryClient.removeQueries({ queryKey: ["secrets"] });
+
+ // Clear login method and last page from local storage
+ if (config?.APP_MODE === "saas") {
+ clearLoginData();
+ }
+
+ posthog.reset();
+
+ // Refresh the page after all logout logic is completed
+ window.location.reload();
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6a86ace9f6e62464f953dae4d0e5a446d060b395
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-save-settings.ts
@@ -0,0 +1,69 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import posthog from "posthog-js";
+import { DEFAULT_SETTINGS } from "#/services/settings";
+import OpenHands from "#/api/open-hands";
+import { PostSettings, PostApiSettings } from "#/types/settings";
+import { useSettings } from "../query/use-settings";
+
+const saveSettingsMutationFn = async (settings: Partial) => {
+ const apiSettings: Partial = {
+ llm_model: settings.LLM_MODEL,
+ llm_base_url: settings.LLM_BASE_URL,
+ agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
+ language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE,
+ confirmation_mode: settings.CONFIRMATION_MODE,
+ security_analyzer: settings.SECURITY_ANALYZER,
+ llm_api_key:
+ settings.llm_api_key === ""
+ ? ""
+ : settings.llm_api_key?.trim() || undefined,
+ remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
+ enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
+ enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
+ user_consents_to_analytics: settings.user_consents_to_analytics,
+ provider_tokens_set: settings.PROVIDER_TOKENS_SET,
+ mcp_config: settings.MCP_CONFIG,
+ enable_proactive_conversation_starters:
+ settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
+ search_api_key: settings.SEARCH_API_KEY?.trim() || "",
+ };
+
+ await OpenHands.saveSettings(apiSettings);
+};
+
+export const useSaveSettings = () => {
+ const queryClient = useQueryClient();
+ const { data: currentSettings } = useSettings();
+
+ return useMutation({
+ mutationFn: async (settings: Partial) => {
+ const newSettings = { ...currentSettings, ...settings };
+
+ // Track MCP configuration changes
+ if (
+ settings.MCP_CONFIG &&
+ currentSettings?.MCP_CONFIG !== settings.MCP_CONFIG
+ ) {
+ const hasMcpConfig = !!settings.MCP_CONFIG;
+ const sseServersCount = settings.MCP_CONFIG?.sse_servers?.length || 0;
+ const stdioServersCount =
+ settings.MCP_CONFIG?.stdio_servers?.length || 0;
+
+ // Track MCP configuration usage
+ posthog.capture("mcp_config_updated", {
+ has_mcp_config: hasMcpConfig,
+ sse_servers_count: sseServersCount,
+ stdio_servers_count: stdioServersCount,
+ });
+ }
+
+ await saveSettingsMutationFn(newSettings);
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["settings"] });
+ },
+ meta: {
+ disableToast: true,
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-submit-feedback.ts b/frontend/src/hooks/mutation/use-submit-feedback.ts
new file mode 100644
index 0000000000000000000000000000000000000000..430b0d3f05ed699ac8d53f576ba8b3be4253e6e8
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-submit-feedback.ts
@@ -0,0 +1,22 @@
+import { useMutation } from "@tanstack/react-query";
+import { Feedback } from "#/api/open-hands.types";
+import OpenHands from "#/api/open-hands";
+import { useConversationId } from "#/hooks/use-conversation-id";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+
+type SubmitFeedbackArgs = {
+ feedback: Feedback;
+};
+
+export const useSubmitFeedback = () => {
+ const { conversationId } = useConversationId();
+ return useMutation({
+ mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
+ OpenHands.submitFeedback(conversationId, feedback),
+ onError: (error) => {
+ displayErrorToast(error.message);
+ },
+ retry: 2,
+ retryDelay: 500,
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-update-secret.ts b/frontend/src/hooks/mutation/use-update-secret.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d63eee4a1b72c75551a44639e35a7b4cf5ad0a85
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-update-secret.ts
@@ -0,0 +1,15 @@
+import { useMutation } from "@tanstack/react-query";
+import { SecretsService } from "#/api/secrets-service";
+
+export const useUpdateSecret = () =>
+ useMutation({
+ mutationFn: ({
+ secretToEdit,
+ name,
+ description,
+ }: {
+ secretToEdit: string;
+ name: string;
+ description?: string;
+ }) => SecretsService.updateSecret(secretToEdit, name, description),
+ });
diff --git a/frontend/src/hooks/query/use-active-conversation.ts b/frontend/src/hooks/query/use-active-conversation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e2cd7662e50c27dfeee566e69a17edae69d49789
--- /dev/null
+++ b/frontend/src/hooks/query/use-active-conversation.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+import { useConversationId } from "#/hooks/use-conversation-id";
+import { useUserConversation } from "./use-user-conversation";
+import OpenHands from "#/api/open-hands";
+
+const FIVE_MINUTES = 1000 * 60 * 5;
+
+export const useActiveConversation = () => {
+ const { conversationId } = useConversationId();
+ const userConversation = useUserConversation(conversationId, (query) => {
+ if (query.state.data?.status === "STARTING") {
+ return 3000; // 3 seconds
+ }
+ return FIVE_MINUTES;
+ });
+
+ useEffect(() => {
+ const conversation = userConversation.data;
+ OpenHands.setCurrentConversation(conversation || null);
+ }, [
+ conversationId,
+ userConversation.isFetched,
+ userConversation?.data?.status,
+ ]);
+ return userConversation;
+};
diff --git a/frontend/src/hooks/query/use-active-host.ts b/frontend/src/hooks/query/use-active-host.ts
new file mode 100644
index 0000000000000000000000000000000000000000..95997abb082895f470c714947b84a7b27f586cb2
--- /dev/null
+++ b/frontend/src/hooks/query/use-active-host.ts
@@ -0,0 +1,52 @@
+import { useQueries, useQuery } from "@tanstack/react-query";
+import axios from "axios";
+import React from "react";
+import OpenHands from "#/api/open-hands";
+import { useConversationId } from "#/hooks/use-conversation-id";
+import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
+
+export const useActiveHost = () => {
+ const [activeHost, setActiveHost] = React.useState(null);
+ const { conversationId } = useConversationId();
+ const runtimeIsReady = useRuntimeIsReady();
+
+ const { data } = useQuery({
+ queryKey: [conversationId, "hosts"],
+ queryFn: async () => {
+ const hosts = await OpenHands.getWebHosts(conversationId);
+ return { hosts };
+ },
+ enabled: runtimeIsReady && !!conversationId,
+ initialData: { hosts: [] },
+ meta: {
+ disableToast: true,
+ },
+ });
+
+ const apps = useQueries({
+ queries: data.hosts.map((host) => ({
+ queryKey: [conversationId, "hosts", host],
+ queryFn: async () => {
+ try {
+ await axios.get(host);
+ return host;
+ } catch (e) {
+ return "";
+ }
+ },
+ // refetchInterval: 3000,
+ meta: {
+ disableToast: true,
+ },
+ })),
+ });
+
+ const appsData = apps.map((app) => app.data);
+
+ React.useEffect(() => {
+ const successfulApp = appsData.find((app) => app);
+ setActiveHost(successfulApp || "");
+ }, [appsData]);
+
+ return { activeHost };
+};
diff --git a/frontend/src/hooks/query/use-ai-config-options.ts b/frontend/src/hooks/query/use-ai-config-options.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f5b9a6f4ca330230bd86a8e6d2c3cdfcd2c0e6ba
--- /dev/null
+++ b/frontend/src/hooks/query/use-ai-config-options.ts
@@ -0,0 +1,16 @@
+import { useQuery } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+
+const fetchAiConfigOptions = async () => ({
+ models: await OpenHands.getModels(),
+ agents: await OpenHands.getAgents(),
+ securityAnalyzers: await OpenHands.getSecurityAnalyzers(),
+});
+
+export const useAIConfigOptions = () =>
+ useQuery({
+ queryKey: ["ai-config-options"],
+ queryFn: fetchAiConfigOptions,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes
+ });
diff --git a/frontend/src/hooks/query/use-api-keys.ts b/frontend/src/hooks/query/use-api-keys.ts
new file mode 100644
index 0000000000000000000000000000000000000000..832467b821f88406b94048be4d09351c25f5f21d
--- /dev/null
+++ b/frontend/src/hooks/query/use-api-keys.ts
@@ -0,0 +1,20 @@
+import { useQuery } from "@tanstack/react-query";
+import ApiKeysClient from "#/api/api-keys";
+import { useConfig } from "./use-config";
+
+export const API_KEYS_QUERY_KEY = "api-keys";
+
+export function useApiKeys() {
+ const { data: config } = useConfig();
+
+ return useQuery({
+ queryKey: [API_KEYS_QUERY_KEY],
+ enabled: config?.APP_MODE === "saas",
+ queryFn: async () => {
+ const keys = await ApiKeysClient.getApiKeys();
+ return Array.isArray(keys) ? keys : [];
+ },
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes
+ });
+}
diff --git a/frontend/src/hooks/query/use-balance.ts b/frontend/src/hooks/query/use-balance.ts
new file mode 100644
index 0000000000000000000000000000000000000000..21a0cef5eb4a2adf346b091507e73bec4e454787
--- /dev/null
+++ b/frontend/src/hooks/query/use-balance.ts
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query";
+import { useConfig } from "./use-config";
+import OpenHands from "#/api/open-hands";
+import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
+
+export const useBalance = () => {
+ const { data: config } = useConfig();
+ const isOnTosPage = useIsOnTosPage();
+
+ return useQuery({
+ queryKey: ["user", "balance"],
+ queryFn: OpenHands.getBalance,
+ enabled:
+ !isOnTosPage &&
+ config?.APP_MODE === "saas" &&
+ config?.FEATURE_FLAGS.ENABLE_BILLING,
+ });
+};
diff --git a/frontend/src/hooks/query/use-config.ts b/frontend/src/hooks/query/use-config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e1b93cfe4c48514c6899c50cddd6963a1baa135f
--- /dev/null
+++ b/frontend/src/hooks/query/use-config.ts
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
+
+export const useConfig = () => {
+ const isOnTosPage = useIsOnTosPage();
+
+ return useQuery({
+ queryKey: ["config"],
+ queryFn: OpenHands.getConfig,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes,
+ enabled: !isOnTosPage,
+ });
+};
diff --git a/frontend/src/hooks/query/use-conversation-config.ts b/frontend/src/hooks/query/use-conversation-config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..57456b3cc8b37944e5b70fb019f03d833582c4da
--- /dev/null
+++ b/frontend/src/hooks/query/use-conversation-config.ts
@@ -0,0 +1,39 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import {
+ useWsClient,
+ WsClientProviderStatus,
+} from "#/context/ws-client-provider";
+import { useConversationId } from "#/hooks/use-conversation-id";
+import OpenHands from "#/api/open-hands";
+
+export const useConversationConfig = () => {
+ const { status } = useWsClient();
+ const { conversationId } = useConversationId();
+
+ const query = useQuery({
+ queryKey: ["conversation_config", conversationId],
+ queryFn: () => {
+ if (!conversationId) throw new Error("No conversation ID");
+ return OpenHands.getRuntimeId(conversationId);
+ },
+ enabled: status !== WsClientProviderStatus.DISCONNECTED && !!conversationId,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes
+ });
+
+ React.useEffect(() => {
+ if (query.data) {
+ const { runtime_id: runtimeId } = query.data;
+
+ // eslint-disable-next-line no-console
+ console.log(
+ "Runtime ID: %c%s",
+ "background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
+ runtimeId,
+ );
+ }
+ }, [query.data]);
+
+ return query;
+};
diff --git a/frontend/src/hooks/query/use-get-diff.ts b/frontend/src/hooks/query/use-get-diff.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8756e9970fec4a7a6a397ede913c9c00bf93bb77
--- /dev/null
+++ b/frontend/src/hooks/query/use-get-diff.ts
@@ -0,0 +1,22 @@
+import { useQuery } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+import { GitChangeStatus } from "#/api/open-hands.types";
+import { useConversationId } from "#/hooks/use-conversation-id";
+
+type UseGetDiffConfig = {
+ filePath: string;
+ type: GitChangeStatus;
+ enabled: boolean;
+};
+
+export const useGitDiff = (config: UseGetDiffConfig) => {
+ const { conversationId } = useConversationId();
+
+ return useQuery({
+ queryKey: ["file_diff", conversationId, config.filePath, config.type],
+ queryFn: () => OpenHands.getGitChangeDiff(conversationId, config.filePath),
+ enabled: config.enabled,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes
+ });
+};
diff --git a/frontend/src/hooks/query/use-get-git-changes.ts b/frontend/src/hooks/query/use-get-git-changes.ts
new file mode 100644
index 0000000000000000000000000000000000000000..55ab59122b9c9300531a4e63f87174749d34b3c8
--- /dev/null
+++ b/frontend/src/hooks/query/use-get-git-changes.ts
@@ -0,0 +1,67 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import OpenHands from "#/api/open-hands";
+import { useConversationId } from "#/hooks/use-conversation-id";
+import { GitChange } from "#/api/open-hands.types";
+import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
+
+export const useGetGitChanges = () => {
+ const { conversationId } = useConversationId();
+ const [orderedChanges, setOrderedChanges] = React.useState([]);
+ const previousDataRef = React.useRef(null);
+ const runtimeIsReady = useRuntimeIsReady();
+
+ const result = useQuery({
+ queryKey: ["file_changes", conversationId],
+ queryFn: () => OpenHands.getGitChanges(conversationId),
+ retry: false,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes
+ enabled: runtimeIsReady && !!conversationId,
+ meta: {
+ disableToast: true,
+ },
+ });
+
+ // Latest changes should be on top
+ React.useEffect(() => {
+ if (!result.isFetching && result.isSuccess && result.data) {
+ const currentData = result.data;
+
+ // If this is new data (not the same reference as before)
+ if (currentData !== previousDataRef.current) {
+ previousDataRef.current = currentData;
+
+ // Figure out new items by comparing with what we already have
+ if (Array.isArray(currentData)) {
+ const currentIds = new Set(currentData.map((item) => item.path));
+ const existingIds = new Set(orderedChanges.map((item) => item.path));
+
+ // Filter out items that already exist in orderedChanges
+ const newItems = currentData.filter(
+ (item) => !existingIds.has(item.path),
+ );
+
+ // Filter out items that no longer exist in the API response
+ const existingItems = orderedChanges.filter((item) =>
+ currentIds.has(item.path),
+ );
+
+ // Add new items to the beginning
+ setOrderedChanges([...newItems, ...existingItems]);
+ } else {
+ // If not an array, just use the data directly
+ setOrderedChanges([currentData]);
+ }
+ }
+ }
+ }, [result.isFetching, result.isSuccess, result.data]);
+
+ return {
+ data: orderedChanges,
+ isLoading: result.isLoading,
+ isSuccess: result.isSuccess,
+ isError: result.isError,
+ error: result.error,
+ };
+};
diff --git a/frontend/src/hooks/query/use-get-policy.ts b/frontend/src/hooks/query/use-get-policy.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b8c450c2a303c6c6c0e025e73d6a10055678a18f
--- /dev/null
+++ b/frontend/src/hooks/query/use-get-policy.ts
@@ -0,0 +1,26 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import InvariantService from "#/api/invariant-service";
+
+type ResponseData = string;
+
+interface UseGetPolicyConfig {
+ onSuccess: (data: ResponseData) => void;
+}
+
+export const useGetPolicy = (config?: UseGetPolicyConfig) => {
+ const data = useQuery({
+ queryKey: ["policy"],
+ queryFn: InvariantService.getPolicy,
+ });
+
+ const { isFetching, isSuccess, data: policy } = data;
+
+ React.useEffect(() => {
+ if (!isFetching && isSuccess && policy) {
+ config?.onSuccess(policy);
+ }
+ }, [isFetching, isSuccess, policy, config?.onSuccess]);
+
+ return data;
+};
diff --git a/frontend/src/hooks/query/use-get-risk-severity.ts b/frontend/src/hooks/query/use-get-risk-severity.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a72e4b30d500777fd197b9bc15067d6aeaf9971d
--- /dev/null
+++ b/frontend/src/hooks/query/use-get-risk-severity.ts
@@ -0,0 +1,26 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import InvariantService from "#/api/invariant-service";
+
+type ResponseData = number;
+
+interface UseGetRiskSeverityConfig {
+ onSuccess: (data: ResponseData) => void;
+}
+
+export const useGetRiskSeverity = (config?: UseGetRiskSeverityConfig) => {
+ const data = useQuery({
+ queryKey: ["risk_severity"],
+ queryFn: InvariantService.getRiskSeverity,
+ });
+
+ const { isFetching, isSuccess, data: riskSeverity } = data;
+
+ React.useEffect(() => {
+ if (!isFetching && isSuccess && riskSeverity) {
+ config?.onSuccess(riskSeverity);
+ }
+ }, [isFetching, isSuccess, riskSeverity, config?.onSuccess]);
+
+ return data;
+};
diff --git a/frontend/src/hooks/query/use-get-secrets.ts b/frontend/src/hooks/query/use-get-secrets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e031222d5892aa4f1296e9e91044f60f22a71c63
--- /dev/null
+++ b/frontend/src/hooks/query/use-get-secrets.ts
@@ -0,0 +1,17 @@
+import { useQuery } from "@tanstack/react-query";
+import { SecretsService } from "#/api/secrets-service";
+import { useUserProviders } from "../use-user-providers";
+import { useConfig } from "./use-config";
+
+export const useGetSecrets = () => {
+ const { data: config } = useConfig();
+ const { providers } = useUserProviders();
+
+ const isOss = config?.APP_MODE === "oss";
+
+ return useQuery({
+ queryKey: ["secrets"],
+ queryFn: SecretsService.getSecrets,
+ enabled: isOss || providers.length > 0,
+ });
+};
diff --git a/frontend/src/hooks/query/use-get-traces.ts b/frontend/src/hooks/query/use-get-traces.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7eaa1fed7917ad938dd8b4ccbc26fc35a0e71b14
--- /dev/null
+++ b/frontend/src/hooks/query/use-get-traces.ts
@@ -0,0 +1,27 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import InvariantService from "#/api/invariant-service";
+
+type ResponseData = object;
+
+interface UseGetTracesConfig {
+ onSuccess: (data: ResponseData) => void;
+}
+
+export const useGetTraces = (config?: UseGetTracesConfig) => {
+ const data = useQuery({
+ queryKey: ["traces"],
+ queryFn: InvariantService.getTraces,
+ enabled: false,
+ });
+
+ const { isFetching, isSuccess, data: traces } = data;
+
+ React.useEffect(() => {
+ if (!isFetching && isSuccess && traces) {
+ config?.onSuccess(traces);
+ }
+ }, [isFetching, isSuccess, traces, config?.onSuccess]);
+
+ return data;
+};
diff --git a/frontend/src/hooks/query/use-git-user.ts b/frontend/src/hooks/query/use-git-user.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b1b60a40f2cff76eb7d42b20c3daf1f10b62574d
--- /dev/null
+++ b/frontend/src/hooks/query/use-git-user.ts
@@ -0,0 +1,34 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import posthog from "posthog-js";
+import { useConfig } from "./use-config";
+import OpenHands from "#/api/open-hands";
+import { useUserProviders } from "../use-user-providers";
+
+export const useGitUser = () => {
+ const { providers } = useUserProviders();
+ const { data: config } = useConfig();
+
+ const user = useQuery({
+ queryKey: ["user"],
+ queryFn: OpenHands.getGitUser,
+ enabled: !!config?.APP_MODE && providers.length > 0,
+ retry: false,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes
+ });
+
+ React.useEffect(() => {
+ if (user.data) {
+ posthog.identify(user.data.login, {
+ company: user.data.company,
+ name: user.data.name,
+ email: user.data.email,
+ user: user.data.login,
+ mode: config?.APP_MODE || "oss",
+ });
+ }
+ }, [user.data]);
+
+ return user;
+};
diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b1ad0c7cd893318a6cc715aa0df50e4b02958532
--- /dev/null
+++ b/frontend/src/hooks/query/use-is-authed.ts
@@ -0,0 +1,40 @@
+import { useQuery } from "@tanstack/react-query";
+import axios, { AxiosError } from "axios";
+import OpenHands from "#/api/open-hands";
+import { useConfig } from "./use-config";
+import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
+
+export const useIsAuthed = () => {
+ const { data: config } = useConfig();
+ const isOnTosPage = useIsOnTosPage();
+
+ const appMode = config?.APP_MODE;
+
+ return useQuery({
+ queryKey: ["user", "authenticated", appMode],
+ queryFn: async () => {
+ try {
+ // If in OSS mode or authentication succeeds, return true
+ await OpenHands.authenticate(appMode!);
+ return true;
+ } catch (error) {
+ // If it's a 401 error, return false (not authenticated)
+ if (axios.isAxiosError(error)) {
+ const axiosError = error as AxiosError;
+ if (axiosError.response?.status === 401) {
+ return false;
+ }
+ }
+ // For any other error, throw it to put the query in error state
+ throw error;
+ }
+ },
+ enabled: !!appMode && !isOnTosPage,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 15, // 15 minutes
+ retry: false,
+ meta: {
+ disableToast: true,
+ },
+ });
+};
diff --git a/frontend/src/hooks/query/use-repository-branches.ts b/frontend/src/hooks/query/use-repository-branches.ts
new file mode 100644
index 0000000000000000000000000000000000000000..64d80d6f62bac514ebdfac5f9f5dc145af657932
--- /dev/null
+++ b/frontend/src/hooks/query/use-repository-branches.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+import { Branch } from "#/types/git";
+
+export const useRepositoryBranches = (repository: string | null) =>
+ useQuery