diff --git a/__tests__/components/onboarding/onboarding-modal.test.tsx b/__tests__/components/onboarding/onboarding-modal.test.tsx index 136d51c3c..04bf53a81 100644 --- a/__tests__/components/onboarding/onboarding-modal.test.tsx +++ b/__tests__/components/onboarding/onboarding-modal.test.tsx @@ -99,8 +99,13 @@ vi.mock("#/hooks/query/use-acp-auth-status", () => ({ }), })); -async function completeAgentStep(user: ReturnType) { - await user.click(screen.getByTestId("onboarding-agent-next")); +async function completeBackendStep(user: ReturnType) { + await waitFor( + () => + expect(screen.getByTestId("onboarding-backend-connected")).toBeVisible(), + { timeout: 3000 }, + ); + await user.click(screen.getByTestId("onboarding-backend-next")); await waitFor( () => expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( @@ -111,13 +116,8 @@ async function completeAgentStep(user: ReturnType) { ); } -async function completeBackendStep(user: ReturnType) { - await waitFor( - () => - expect(screen.getByTestId("onboarding-backend-next")).not.toBeDisabled(), - { timeout: 3000 }, - ); - await user.click(screen.getByTestId("onboarding-backend-next")); +async function completeAgentStep(user: ReturnType) { + await user.click(screen.getByTestId("onboarding-agent-next")); await waitFor( () => expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( @@ -183,7 +183,7 @@ afterEach(() => { }); describe("OnboardingModal", () => { - it("starts on the choose-agent step with each slide offset by its index", () => { + it("starts on the backend step with each slide offset by its index", () => { renderModal(); expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( @@ -191,7 +191,7 @@ describe("OnboardingModal", () => { "0", ); expect( - screen.getByTestId("onboarding-step-choose-agent"), + screen.getByTestId("onboarding-step-check-backend"), ).toBeInTheDocument(); expect(screen.getByTestId("onboarding-slide-0")).toHaveAttribute( @@ -210,10 +210,41 @@ describe("OnboardingModal", () => { ); }); + it("starts first-run no-backend onboarding as Add a backend without an error banner", () => { + window.localStorage.clear(); + vi.stubEnv("VITE_BACKEND_BASE_URL", ""); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + __resetActiveStoreForTests(); + + renderModal(); + + expect(screen.getByText("BACKEND$ADD_TITLE")).toBeInTheDocument(); + expect( + screen.getByText("ONBOARDING$ADD_BACKEND_SUBTITLE"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("onboarding-backend-disconnected"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("onboarding-backend-checking"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("onboarding-backend-cloud-title")).toBeVisible(); + expect(screen.getByTestId("onboarding-backend-login-button")).toBeVisible(); + }); + it("shows a connection error when saving an unreachable backend", async () => { renderModal(); const user = userEvent.setup(); + await waitFor(() => + expect(screen.getByTestId("onboarding-backend-connected")).toBeVisible(), + ); + await user.click( + screen.getByTestId("onboarding-backend-show-configuration"), + ); + await user.clear(screen.getByTestId("onboarding-backend-host")); await user.type( screen.getByTestId("onboarding-backend-host"), @@ -251,37 +282,44 @@ describe("OnboardingModal", () => { renderModal(); const user = userEvent.setup(); - await completeAgentStep(user); await waitFor(() => expect(screen.getByTestId("onboarding-backend-connected")).toBeVisible(), ); expect( - screen.getByTestId("onboarding-backend-configuration-fields"), - ).toHaveClass("hidden"); + within( + screen.getByTestId("onboarding-backend-configuration-fields"), + ).queryByTestId("onboarding-backend-connection-options"), + ).not.toBeInTheDocument(); expect( screen.getByTestId("onboarding-backend-show-configuration"), ).toBeInTheDocument(); - await user.click(screen.getByTestId("onboarding-backend-show-configuration")); + await user.click( + screen.getByTestId("onboarding-backend-show-configuration"), + ); expect( - screen.getByTestId("onboarding-backend-configuration-fields"), - ).not.toHaveClass("hidden"); + within( + screen.getByTestId("onboarding-backend-configuration-fields"), + ).getByTestId("onboarding-backend-connection-options"), + ).toBeInTheDocument(); + expect(screen.getByTestId("onboarding-backend-cloud-title")).toBeVisible(); + expect(screen.getByTestId("onboarding-backend-login-button")).toBeVisible(); }); it("advances each step via the per-step Next button and reframes slide offsets", async () => { renderModal(); const user = userEvent.setup(); - // Step 0 → 1. ChooseAgentStep does an async save before advancing. - await completeAgentStep(user); + // Step 0 → 1. Once the backend health probe resolves, step 0's Next is enabled. + await completeBackendStep(user); expect(screen.getByTestId("onboarding-slide-1")).toHaveAttribute( "data-active", "true", ); - // Step 1 → 2. Once the backend health probe resolves, step 1's Next is enabled. - await completeBackendStep(user); + // Step 1 → 2. ChooseAgentStep does an async save before advancing. + await completeAgentStep(user); expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute( "data-active", "true", @@ -345,8 +383,8 @@ describe("OnboardingModal", () => { // Arrange: render the modal and walk through to the LLM step. renderModal(); const user = userEvent.setup(); - await completeAgentStep(user); await completeBackendStep(user); + await completeAgentStep(user); // Wait for the LLM slide to become the active one before querying // by role — otherwise the heading is `aria-hidden` from inside a // not-yet-active slide and getByRole filters it out. @@ -382,9 +420,9 @@ describe("OnboardingModal", () => { // Pick Gemini CLI: its key/base-URL come from the SDK registry like the // other providers, so the slide shows the GEMINI_API_KEY field. + await completeBackendStep(user); await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli")); await completeAgentStep(user); - await completeBackendStep(user); // Lands on slide 2 (the ACP step) — not jumped past to Say Hello. await waitFor( @@ -422,10 +460,10 @@ describe("OnboardingModal", () => { renderModal(); const user = userEvent.setup(); - // Pick Claude Code → Check Backend. + // Pick Claude Code after configuring the backend. + await completeBackendStep(user); await user.click(screen.getByTestId("onboarding-agent-option-claude-code")); await completeAgentStep(user); - await completeBackendStep(user); // Slide 2 is the ACP credentials step (not skipped), so the flow keeps // all 4 progress segments and slide 2 — not Say Hello — is now active. @@ -483,9 +521,9 @@ describe("OnboardingModal", () => { renderModal(); const user = userEvent.setup(); + await completeBackendStep(user); await user.click(screen.getByTestId("onboarding-agent-option-codex")); await completeAgentStep(user); - await completeBackendStep(user); await waitFor( () => expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( @@ -513,8 +551,8 @@ describe("OnboardingModal", () => { renderModal(); const user = userEvent.setup(); - await completeAgentStep(user); await completeBackendStep(user); + await completeAgentStep(user); await user.click(screen.getByTestId("onboarding-llm-next")); const helloInput = screen.getByTestId( @@ -532,8 +570,8 @@ describe("OnboardingModal", () => { renderModal(onClose); const user = userEvent.setup(); - await completeAgentStep(user); await completeBackendStep(user); + await completeAgentStep(user); await waitFor(() => expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute( "data-active", diff --git a/__tests__/root.test.tsx b/__tests__/root.test.tsx index e5f75f23b..a591a3ed3 100644 --- a/__tests__/root.test.tsx +++ b/__tests__/root.test.tsx @@ -7,6 +7,7 @@ import App, { links } from "#/root"; import { server } from "#/mocks/node"; import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; import { ActiveBackendProvider } from "#/contexts/active-backend-context"; +import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion"; const TRANSLATIONS: Record = { BACKEND$MANAGE_TITLE: "Manage backends", @@ -33,6 +34,14 @@ vi.mock("react-i18next", () => ({ }), })); +vi.mock("#/components/features/onboarding/onboarding-modal", () => ({ + OnboardingModal: () => ( +
+
+
+ ), +})); + const RouterStub = createRoutesStub([ { Component: App, @@ -64,7 +73,54 @@ const renderApp = (initialEntries: string[] = ["/"]) => describe("App root agent-server availability guard", () => { beforeEach(() => { window.localStorage.clear(); + vi.unstubAllEnvs(); + delete (window as unknown as Record) + .__AGENT_CANVAS_AUTH_REQUIRED__; + ( + window as unknown as Record + ).__AGENT_CANVAS_SESSION_API_KEY__ = "test-session-key"; + __resetActiveStoreForTests(); + }); + + it("shows first-run onboarding before the auth gate when public mode has no backend key", async () => { + vi.stubEnv("VITE_AUTH_REQUIRED", "true"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.clear(); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + await screen.findByTestId("onboarding-step-check-backend"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("api-key-entry-screen"), + ).not.toBeInTheDocument(); + }); + + it("shows the auth gate after onboarding was already completed", async () => { + vi.stubEnv("VITE_AUTH_REQUIRED", "true"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.clear(); + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect(screen.getByTestId("api-key-entry-screen")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument(); }); it("shows the manage-backends modal when the connected server reports an old version", async () => { diff --git a/src/components/features/backends/backend-form-modal.tsx b/src/components/features/backends/backend-form-modal.tsx index 86c20e15a..9564ccf84 100644 --- a/src/components/features/backends/backend-form-modal.tsx +++ b/src/components/features/backends/backend-form-modal.tsx @@ -273,6 +273,8 @@ interface UseBackendFormOptions { onTestConnection: (payload: BackendFormSubmitPayload) => Promise; /** Called after a successful connection test and persistence. */ onSuccess: () => void; + /** Require a non-empty API key even when the host looks local. */ + requireApiKey?: boolean; /** * When provided, completely replaces the default submit flow * (onTestConnection + onSuccess). The hook still manages form state @@ -294,6 +296,7 @@ function useBackendForm({ initialApiKey = "", onTestConnection, onSuccess, + requireApiKey = false, onSubmitOverride, }: UseBackendFormOptions) { const { t } = useTranslation("openhands"); @@ -307,10 +310,11 @@ function useBackendForm({ const [isSubmitting, setIsSubmitting] = React.useState(false); const kind = inferKindFromHost(host); + const needsApiKey = requireApiKey || kind !== "local"; const canSubmit = name.trim().length > 0 && isValidHostUrl(host) && - (kind === "local" || apiKey.trim().length > 0); + (!needsApiKey || apiKey.trim().length > 0); const handleSubmit = React.useCallback( async (e: React.FormEvent) => { @@ -356,6 +360,7 @@ function useBackendForm({ kind, onTestConnection, onSuccess, + requireApiKey, onSubmitOverride, t, ], @@ -487,6 +492,7 @@ export function BackendForm({ } onSubmitted(); }, + requireApiKey, onSubmitOverride, }); @@ -654,15 +660,95 @@ function useRedirectAfterAddBackend() { }, [currentPath, navigate]); } +interface BackendConnectionOptionsProps { + onConnected: (payload: BackendFormSubmitPayload) => void; + testIdRoot?: string; + initialManualBackend?: Partial< + Pick + >; + requireManualApiKey?: boolean; + manualSubmitLabel?: React.ReactNode; + manualSubmittingLabel?: React.ReactNode; + manualSubmitTestId?: string; +} + /** - * Left column of the "Add a Backend" modal: manual connection via - * Host + API Key. Designed for self-hosted agent servers and - * self-hosted OpenHands Cloud with API key auth. + * Manual agent-server connection plus OpenHands Cloud OAuth login. + * Used by both the Add Backend modal and the onboarding backend step so + * supported backend choices stay consistent across first-run and settings UI. */ -function ManualConnectionColumn({ onClose }: { onClose: () => void }) { +export function BackendConnectionOptions({ + onConnected, + testIdRoot = "add-backend", + initialManualBackend, + requireManualApiKey = false, + manualSubmitLabel, + manualSubmittingLabel, + manualSubmitTestId, +}: BackendConnectionOptionsProps) { + const { t } = useTranslation("openhands"); + + return ( +
+
+ +
+ +
+
+ + {t(I18nKey.BACKEND$LOGIN_OR)} + +
+
+ +
+ +
+
+ ); +} + +interface ManualConnectionColumnProps { + onConnected: (payload: BackendFormSubmitPayload) => void; + testIdRoot: string; + initialBackend?: Partial< + Pick + >; + requireApiKey: boolean; + submitLabel: React.ReactNode; + submittingLabel: React.ReactNode; + submitTestId?: string; +} + +/** + * Manual connection via Host + API Key. Designed for self-hosted agent servers + * and self-hosted OpenHands Cloud with API key auth. + */ +function ManualConnectionColumn({ + onConnected, + testIdRoot, + initialBackend, + requireApiKey, + submitLabel, + submittingLabel, + submitTestId, +}: ManualConnectionColumnProps) { const { t } = useTranslation("openhands"); - const { addBackend } = useActiveBackendContext(); - const redirectAfterAdd = useRedirectAfterAddBackend(); const { name, @@ -678,29 +764,31 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) { canSubmit, handleSubmit, } = useBackendForm({ + initialName: initialBackend?.name ?? "", + initialHost: initialBackend?.host ?? "", + initialApiKey: initialBackend?.apiKey ?? "", onTestConnection: testBackendConnection, onSuccess: () => { - addBackend({ + onConnected({ name: name.trim(), host: normalizeHost(host), apiKey: apiKey.trim(), kind, }); - redirectAfterAdd(); - onClose(); }, + requireApiKey, }); return (
void }) {
void }) { />

{t(I18nKey.BACKEND$HOST_HELPER)}

void }) { {connectionError ? (
{connectionError} @@ -766,26 +854,27 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) { type="submit" variant="secondary" isDisabled={!canSubmit || isSubmitting} - testId="add-backend-submit" + testId={submitTestId ?? `${testIdRoot}-submit`} className="w-full text-center" > - {isSubmitting - ? t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING) - : t(I18nKey.BACKEND$CONNECT)} + {isSubmitting ? submittingLabel : submitLabel} ); } +interface CloudLoginColumnProps { + onConnected: (payload: BackendFormSubmitPayload) => void; + testIdRoot: string; +} + /** - * Right column of the "Add a Backend" modal: one-click OAuth login - * with OpenHands Cloud. Includes an "Advanced" disclosure for - * users who self-host OpenHands Cloud and need to override the host. + * One-click OAuth login with OpenHands Cloud. Includes an "Advanced" + * disclosure for users who self-host OpenHands Cloud and need to override the + * host. */ -function CloudLoginColumn({ onClose }: { onClose: () => void }) { +function CloudLoginColumn({ onConnected, testIdRoot }: CloudLoginColumnProps) { const { t } = useTranslation("openhands"); - const { addBackend } = useActiveBackendContext(); - const redirectAfterAdd = useRedirectAfterAddBackend(); const [advancedOpen, setAdvancedOpen] = React.useState(false); const [customHost, setCustomHost] = React.useState(""); @@ -793,14 +882,12 @@ function CloudLoginColumn({ onClose }: { onClose: () => void }) { const effectiveHost = customHost.trim() || DEFAULT_OPENHANDS_CLOUD_HOST; const handleLoginSuccess = (apiKey: string) => { - addBackend({ + onConnected({ name: "OpenHands Cloud", host: normalizeHost(effectiveHost), apiKey, kind: "cloud", }); - redirectAfterAdd(); - onClose(); }; return ( @@ -810,7 +897,7 @@ function CloudLoginColumn({ onClose }: { onClose: () => void }) {

{t(I18nKey.BACKEND$CLOUD_TITLE)}

@@ -823,7 +910,7 @@ function CloudLoginColumn({ onClose }: { onClose: () => void }) {
@@ -831,7 +918,7 @@ function CloudLoginColumn({ onClose }: { onClose: () => void }) { type="button" onClick={() => setAdvancedOpen((open) => !open)} aria-expanded={advancedOpen} - data-testid="add-backend-advanced-toggle" + data-testid={`${testIdRoot}-advanced-toggle`} className="flex w-full cursor-pointer items-center justify-center gap-1 text-center text-xs text-[var(--oh-muted)] transition-colors hover:text-content-2" > {t(I18nKey.BACKEND$ADVANCED)} @@ -851,8 +938,8 @@ function CloudLoginColumn({ onClose }: { onClose: () => void }) { aria-hidden={!advancedOpen} > void }) { ); } +function AddBackendConnectionOptions({ onClose }: { onClose: () => void }) { + const { addBackend } = useActiveBackendContext(); + const redirectAfterAdd = useRedirectAfterAddBackend(); + + const handleConnected = React.useCallback( + (payload: BackendFormSubmitPayload) => { + addBackend(payload); + redirectAfterAdd(); + onClose(); + }, + [addBackend, redirectAfterAdd, onClose], + ); + + return ; +} + // ── Modal wrappers ────────────────────────────────────────────────── /** @@ -906,26 +1009,8 @@ export function BackendFormModal({
- {/* Two-column body */} -
- {/* Left: manual connection */} -
- -
- - {/* Vertical OR divider */} -
-
- - {t(I18nKey.BACKEND$LOGIN_OR)} - -
-
- - {/* Right: cloud login */} -
- -
+
+
diff --git a/src/components/features/onboarding/onboarding-modal.tsx b/src/components/features/onboarding/onboarding-modal.tsx index 628d5001f..2737d90eb 100644 --- a/src/components/features/onboarding/onboarding-modal.tsx +++ b/src/components/features/onboarding/onboarding-modal.tsx @@ -82,8 +82,8 @@ interface OnboardingModalProps { * Top-level onboarding modal for first-time users. * * The flow is a fixed sequence of four steps: - * 0. Choose agent - * 1. Check backend + * 0. Check backend + * 1. Choose agent * 2. Set up LLM * 3. Say hello (creates a fresh conversation, then closes) * @@ -151,15 +151,16 @@ export function OnboardingModal({ className="relative overflow-clip" > + + + - - - {isOpenHands ? ( diff --git a/src/components/features/onboarding/steps/check-backend-step.tsx b/src/components/features/onboarding/steps/check-backend-step.tsx index 94ad54d80..b98977fc3 100644 --- a/src/components/features/onboarding/steps/check-backend-step.tsx +++ b/src/components/features/onboarding/steps/check-backend-step.tsx @@ -8,7 +8,10 @@ import { isAuthRequired, } from "#/api/agent-server-config"; import { DEFAULT_LOCAL_BACKEND_NAME } from "#/api/backend-registry/default-backend"; -import { BackendForm } from "#/components/features/backends/backend-form-modal"; +import { + BackendConnectionOptions, + type BackendFormSubmitPayload, +} from "#/components/features/backends/backend-form-modal"; import { BrandButton } from "#/components/features/settings/brand-button"; import { useActiveBackendContext } from "#/contexts/active-backend-context"; import { useBackendsHealth } from "#/hooks/query/use-backends-health"; @@ -94,13 +97,12 @@ function ConnectionBanner({ } /** - * Step 1: embed the "edit backend" form pre-populated with the - * default/active backend, plus a contextual success/error banner that - * reacts to the live health probe. + * First onboarding step: add the initial backend when none is selected, + * or edit/check the active backend with a contextual health banner. */ export function CheckBackendStep({ onBack, onNext }: CheckBackendStepProps) { const { t } = useTranslation("openhands"); - const { active } = useActiveBackendContext(); + const { active, addBackend, updateBackend } = useActiveBackendContext(); const { backend } = active; const noBackendSelected = isNoBackend(backend); const defaults = React.useMemo(() => getAgentServerFormDefaults(), []); @@ -117,7 +119,7 @@ export function CheckBackendStep({ onBack, onNext }: CheckBackendStepProps) { noBackendSelected ? [] : [backend], ); const isConnected = noBackendSelected - ? false + ? null : (healthByBackendId[backend.id]?.isConnected ?? null); const lastError = noBackendSelected ? null @@ -132,25 +134,46 @@ export function CheckBackendStep({ onBack, onNext }: CheckBackendStepProps) { const hideConfigurationFields = isConnected === true && !configurationOpen; + const handleConnected = React.useCallback( + (payload: BackendFormSubmitPayload) => { + if (noBackendSelected) { + addBackend(payload); + } else { + updateBackend(backend.id, payload); + } + onNext(); + }, + [addBackend, backend.id, noBackendSelected, onNext, updateBackend], + ); + + const actionRowClassName = cn( + "sticky bottom-0 mt-2 flex items-center gap-2 bg-base-secondary pt-4 pb-7", + onBack ? "justify-between" : "justify-end", + ); + const titleKey = noBackendSelected + ? I18nKey.BACKEND$ADD_TITLE + : I18nKey.ONBOARDING$BACKEND_TITLE; + const subtitleKey = noBackendSelected + ? I18nKey.ONBOARDING$ADD_BACKEND_SUBTITLE + : I18nKey.ONBOARDING$BACKEND_SUBTITLE; + return (
-

- {t(I18nKey.ONBOARDING$BACKEND_TITLE)} -

-

- {t(I18nKey.ONBOARDING$BACKEND_SUBTITLE)} -

+

{t(titleKey)}

+

{t(subtitleKey)}

- + {noBackendSelected ? null : ( + + )} {isConnected === true ? (
); } diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 294b00669..32025da07 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -28304,6 +28304,23 @@ "tr": "Sunucu tarafını kontrol edin", "uk": "Перевірте бекенд" }, + "ONBOARDING$ADD_BACKEND_SUBTITLE": { + "en": "Connect OpenHands to an agent server or OpenHands Cloud to get started.", + "ja": "開始するには、OpenHands をエージェントサーバーまたは OpenHands Cloud に接続してください。", + "zh-CN": "将 OpenHands 连接到智能体服务器或 OpenHands Cloud 即可开始。", + "zh-TW": "將 OpenHands 連線到代理伺服器或 OpenHands Cloud 即可開始。", + "ko-KR": "시작하려면 OpenHands를 에이전트 서버 또는 OpenHands Cloud에 연결하세요.", + "no": "Koble OpenHands til en agentserver eller OpenHands Cloud for å komme i gang.", + "ar": "وصّل OpenHands بخادم وكيل أو OpenHands Cloud للبدء.", + "de": "Verbinden Sie OpenHands mit einem Agent-Server oder OpenHands Cloud, um zu starten.", + "fr": "Connectez OpenHands à un serveur d’agent ou à OpenHands Cloud pour commencer.", + "it": "Collega OpenHands a un server agente o a OpenHands Cloud per iniziare.", + "pt": "Conecte o OpenHands a um servidor de agente ou ao OpenHands Cloud para começar.", + "es": "Conecta OpenHands a un servidor de agente o a OpenHands Cloud para empezar.", + "ca": "Connecteu OpenHands a un servidor d’agent o a OpenHands Cloud per començar.", + "tr": "Başlamak için OpenHands’i bir aracı sunucusuna veya OpenHands Cloud’a bağlayın.", + "uk": "Підключіть OpenHands до сервера агента або OpenHands Cloud, щоб почати." + }, "ONBOARDING$BACKEND_SUBTITLE": { "en": "OpenHands talks to an agent server. Make sure the default backend is reachable.", "ja": "OpenHands はエージェントサーバーと通信します。デフォルトのバックエンドに接続できることを確認してください。", diff --git a/src/root.tsx b/src/root.tsx index 88c55ec2b..bae8773c2 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -30,6 +30,7 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useConfig } from "#/hooks/query/use-config"; import { QUERY_KEYS } from "#/hooks/query/query-keys"; import { AgentServerUIRoot } from "#/components/providers"; +import { useOnboardingCompletion } from "#/components/features/onboarding/use-onboarding-completion"; import { applyColorTheme, readPersistedColorTheme, @@ -56,6 +57,14 @@ const ApiKeyEntryScreen = React.lazy( () => import("#/components/features/backends/api-key-entry-screen"), ); +// Rendered only for first-run public/frontend-only bootstraps; keep the +// onboarding flow out of the root bundle until this rare gate is active. +const OnboardingModal = React.lazy(() => + import("#/components/features/onboarding/onboarding-modal").then((m) => ({ + default: m.OnboardingModal, + })), +); + export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -125,6 +134,18 @@ function MissingAgentServerScreen() { ); } +function FirstRunOnboardingScreen({ onClose }: { onClose: () => void }) { + return ( +
+ }> + + +
+ ); +} export const links: LinksFunction = () => [ { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, @@ -150,10 +171,30 @@ export default function App() { const bakedKeyMissing = isAuthRequiredAndMissing(); const hasRegisteredKey = Boolean(getEffectiveLocalBackend()?.apiKey); const authMissing = bakedKeyMissing && !hasRegisteredKey; + const { isCompleted: onboardingCompleted, markCompleted } = + useOnboardingCompletion(); + const [showFirstRunOnboarding, setShowFirstRunOnboarding] = React.useState( + () => authMissing && !onboardingCompleted, + ); + + React.useEffect(() => { + if (authMissing && !onboardingCompleted) { + setShowFirstRunOnboarding(true); + return; + } + + if (onboardingCompleted) { + setShowFirstRunOnboarding(false); + } + }, [authMissing, onboardingCompleted]); // Skip the /server_info probe entirely when we already know auth is - // required and missing — it would just 401 and waste time. - const config = useConfig({ enabled: !authMissing }); + // required and missing — it would just 401 and waste time. Also keep the + // root bootstrap quiet while the first-run onboarding modal owns backend + // collection; the onboarding steps issue their own backend-specific queries. + const config = useConfig({ + enabled: !authMissing && !showFirstRunOnboarding, + }); const { active } = useActiveBackendContext(); const activeCloudHealth = useBackendsHealth( active.backend.kind === "cloud" ? [active.backend] : [], @@ -163,7 +204,11 @@ export default function App() { activeCloudHealth?.isConnected === false && isCloudBackendLoggedOutHealthError(activeCloudHealth.lastError); - // No key at all → instant auth screen (no network). + if (showFirstRunOnboarding) { + return ; + } + + // No key at all after onboarding was skipped/completed → auth screen. // Stale key → /server_info 401 → auth screen (public mode only). if (authMissing || isAgentServerAuthError(config.error)) { return ( diff --git a/tests/e2e/mock-llm/backends/mock-llm-auth-modes.spec.ts b/tests/e2e/mock-llm/backends/mock-llm-auth-modes.spec.ts index a5a77265d..f857877f9 100644 --- a/tests/e2e/mock-llm/backends/mock-llm-auth-modes.spec.ts +++ b/tests/e2e/mock-llm/backends/mock-llm-auth-modes.spec.ts @@ -26,7 +26,7 @@ * @spec BM-002 — Key rotation recovery via syncLauncherDefaultLocalBackend */ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; import { BACKEND_URL, SESSION_API_KEY, @@ -196,14 +196,24 @@ test.describe("auth mode: public gate", () => { }); }); - test("shows the auth screen when no key is configured", async ({ page }) => { + async function openPublicAuthScreenFromFirstRun(page: Page) { // Navigate to the public-mode static server (--auth-required, no // baked session key). The browser has a clean context (no localStorage) - // so isAuthRequiredAndMissing() should return true. + // so first-run users should see onboarding before the auth fallback. await page.goto(PUBLIC_MODE_URL, { waitUntil: "domcontentloaded" }); - // The ApiKeyEntryScreen should be visible. + await waitForTestId(page, "first-run-onboarding-screen"); + await waitForTestId(page, "onboarding-modal"); + await waitForTestId(page, "onboarding-step-check-backend"); + + await page.getByTestId("onboarding-skip").click(); await waitForTestId(page, "api-key-entry-screen"); + } + + test("shows first-run onboarding before the auth screen when no key is configured", async ({ + page, + }) => { + await openPublicAuthScreenFromFirstRun(page); // The main app UI should NOT be visible. const homeLauncher = page.getByTestId("home-chat-launcher"); @@ -211,8 +221,7 @@ test.describe("auth mode: public gate", () => { }); test("rejects an incorrect key with an inline error", async ({ page }) => { - await page.goto(PUBLIC_MODE_URL, { waitUntil: "domcontentloaded" }); - await waitForTestId(page, "api-key-entry-screen"); + await openPublicAuthScreenFromFirstRun(page); // Focus → fill pattern needed for React controlled inputs (see // mock-llm-conversation.spec.ts for the established pattern). @@ -234,8 +243,7 @@ test.describe("auth mode: public gate", () => { }); test("allows access after pasting the correct key", async ({ page }) => { - await page.goto(PUBLIC_MODE_URL, { waitUntil: "domcontentloaded" }); - await waitForTestId(page, "api-key-entry-screen"); + await openPublicAuthScreenFromFirstRun(page); // Focus → fill pattern needed for React controlled inputs. const nameInput = page.getByTestId("api-key-entry-name"); diff --git a/tests/e2e/mock-llm/regressions/mock-llm-ui-regressions.spec.ts b/tests/e2e/mock-llm/regressions/mock-llm-ui-regressions.spec.ts index 0f131de3a..997b9c702 100644 --- a/tests/e2e/mock-llm/regressions/mock-llm-ui-regressions.spec.ts +++ b/tests/e2e/mock-llm/regressions/mock-llm-ui-regressions.spec.ts @@ -370,13 +370,12 @@ test.describe("UI regressions", () => { await routeSessionApiKey(page); await page.goto("/", { waitUntil: "domcontentloaded" }); - await expect(page.locator("[data-agent-server-ui]").first()).toBeVisible({ - timeout: 15_000, - }); + const shell = page.locator("[data-agent-server-ui]").first(); + await expect(shell).toBeVisible({ timeout: 15_000 }); const layout = page.getByTestId("root-layout"); await expect(layout).toBeVisible(); - const insideBackground = await layout.evaluate( + const insideBackground = await shell.evaluate( (el) => getComputedStyle(el).backgroundColor, ); diff --git a/vitest.setup.ts b/vitest.setup.ts index f30333f94..b6c01e4cc 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -76,7 +76,15 @@ if (typeof ProgressEvent === "undefined") { } } - vi.stubGlobal("ProgressEvent", MockProgressEvent); + // MSW's XMLHttpRequest interceptor may dispatch progress events while + // Vitest is tearing down globals between files. Keep this process-level + // fallback outside `vi.stubGlobal()` so `vi.unstubAllGlobals()` does not + // remove it before late interceptor callbacks settle. + Object.defineProperty(globalThis, "ProgressEvent", { + configurable: true, + writable: true, + value: MockProgressEvent, + }); } // Mock ResizeObserver for test environment