Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ When adding code that needs a new string, decide up front which rule it falls un

- Changes tab / `FileDiffViewer` deleted-file note: the agent-server's `/api/git/diff` endpoint calls `path.exists()` first (see `openhands-sdk/openhands/sdk/git/git_diff.py` → `get_git_diff`), so requesting a diff for a `D` (deleted) file returns `GitPathError` → HTTP 400 and trips the global QueryCache error toast. `useUnifiedGitDiff` disables the query when `type === "D"` and `FileDiffViewer` renders a localized "file deleted" placeholder (`DIFF_VIEWER$FILE_DELETED`, `data-testid="file-deleted-message"`) instead of the view-mode toolbar / Monaco editor for that case.

- Onboarding modal: `src/components/features/onboarding/onboarding-modal.tsx` is rendered by `<OnboardingHost />` and gated by the `openhands-onboarded` localStorage flag. It tracks logical phases (`backend`, `agent`, `setup`, `hello`) rather than fixed numeric steps; the backend phase may be omitted for an already configured backend. Agent choices are derived from `ACP_PROVIDERS` plus OpenHands. The setup phase renders `SetupLlmStep` for OpenHands or `SetupAcpSecretsStep` for ACP providers. Keep the phase-based navigation so adding or removing the backend slide cannot move users to the wrong step.
- Onboarding modal: `src/components/features/onboarding/onboarding-modal.tsx` is rendered by `<OnboardingHost />` and normally gated by the `openhands-onboarded` localStorage flag. `OnboardingHost` also suppresses the modal, without writing that flag, when any active Cloud backend's settings report a usable LLM (non-empty model plus API-key or subscription auth); this readiness exception must not apply to Local backends. It tracks logical phases (`backend`, `agent`, `setup`, `hello`) rather than fixed numeric steps; the backend phase may be omitted for an already configured backend. Agent choices are derived from `ACP_PROVIDERS` plus OpenHands. The setup phase renders `SetupLlmStep` for OpenHands or `SetupAcpSecretsStep` for ACP providers. Keep the phase-based navigation so adding or removing the backend slide cannot move users to the wrong step.

- Files tab diff-view default logic: keyed off `useHasAttachedSource()` (`src/hooks/use-has-attached-source.ts`), which is true when the user explicitly attached _either_ a repo (`conversation.selected_repository`) _or_ a local workspace (`getStoredConversationMetadata(id).selected_workspace`, persisted by `createConversation` when `workingDirOverride` is supplied). The agent-server pre-initialises every conversation workspace as a git worktree for its own change tracking, so do NOT use a filesystem probe (`git status` / `useUnifiedGetGitChanges`) as the attachment signal — that was tried in earlier iterations and made every fresh no-attachment conversation incorrectly default to diff view. The companion `useHasGitCommits` probe (`src/hooks/query/use-has-git-commits.ts`) then suppresses diff view for attached-but-empty cases (unborn HEAD, non-git workspace).

Expand Down
85 changes: 61 additions & 24 deletions __tests__/components/onboarding/onboarding-host.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

Expand Down Expand Up @@ -108,43 +108,80 @@ describe("OnboardingHost", () => {
).toBeInTheDocument();
});

it("shows the modal for a fresh Cloud user even when the backend has a configured LLM", async () => {
it("skips the modal when the active Cloud backend has a configured LLM", async () => {
seedCloudBackend();
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...DEFAULT_SETTINGS,
llm_api_key_set: true,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "anthropic/claude-sonnet-4-5", api_key: "stored" },
},
});
const getSettings = vi
.spyOn(SettingsService, "getSettings")
.mockResolvedValue({
...DEFAULT_SETTINGS,
llm_api_key_set: true,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "anthropic/claude-sonnet-4-5", api_key: "stored" },
},
});

renderHost();

expect(
await screen.findByTestId("onboarding-modal-stub"),
).toBeInTheDocument();
await waitFor(() => {
expect(getSettings).toHaveBeenCalledOnce();
expect(
screen.queryByTestId("onboarding-modal-stub"),
).not.toBeInTheDocument();
});
expect(
window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
).toBeNull();
});

it("shows the modal for a fresh Cloud user even when the active LLM uses subscription auth", async () => {
it("skips the modal for a configured Cloud backend that does not match the locked host", async () => {
vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://other-cloud.example.com");
seedCloudBackend();
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...DEFAULT_SETTINGS,
llm_api_key_set: false,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "openai/gpt-5.5", auth_type: "subscription" },
},
});
const getSettings = vi
.spyOn(SettingsService, "getSettings")
.mockResolvedValue({
...DEFAULT_SETTINGS,
llm_api_key_set: true,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "anthropic/claude-sonnet-4-5", api_key: "stored" },
},
});

renderHost();

await waitFor(() => {
expect(getSettings).toHaveBeenCalledOnce();
expect(
screen.queryByTestId("onboarding-modal-stub"),
).not.toBeInTheDocument();
});
expect(
await screen.findByTestId("onboarding-modal-stub"),
).toBeInTheDocument();
window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
).toBeNull();
});

it("skips the modal when the active Cloud backend uses subscription auth", async () => {
seedCloudBackend();
const getSettings = vi
.spyOn(SettingsService, "getSettings")
.mockResolvedValue({
...DEFAULT_SETTINGS,
llm_api_key_set: false,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "openai/gpt-5.5", auth_type: "subscription" },
},
});

renderHost();

await waitFor(() => {
expect(getSettings).toHaveBeenCalledOnce();
expect(
screen.queryByTestId("onboarding-modal-stub"),
).not.toBeInTheDocument();
});
expect(
window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
).toBeNull();
Expand Down
29 changes: 26 additions & 3 deletions src/components/features/onboarding/onboarding-host.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
import { useLocation } from "react-router";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useSettings } from "#/hooks/query/use-settings";
import { isSubscriptionLlmConfig } from "#/constants/llm-subscription";
import type { Settings } from "#/types/settings";
import { OnboardingModal } from "./onboarding-modal";
import {
isOnboardingPreviewActive,
readOnboardingPreviewStep,
} from "./onboarding-preview";
import { useOnboardingCompletion } from "./use-onboarding-completion";

function hasUsableCloudLlm(settings: Settings | undefined): boolean {
const llm = settings?.agent_settings?.llm as
| Record<string, unknown>
| undefined;
const hasModel =
typeof llm?.model === "string" && llm.model.trim().length > 0;
const hasAuth =
settings?.llm_api_key_set === true || isSubscriptionLlmConfig(llm);
return hasModel && hasAuth;
}

/**
* Mounts the onboarding modal automatically the first time the user
* lands on a host route (i.e. when the localStorage onboarding flag
* isn't set yet). Closing or completing the flow marks it done so the
* modal won't re-appear on subsequent visits.
*
* Backend readiness is intentionally not treated as onboarding completion:
* a fresh browser/origin should see onboarding once even when it connects
* to an existing backend that already has an LLM configured.
* An already-authenticated Cloud backend may provide everything onboarding
* would collect. Once the active Cloud backend reports a usable LLM, suppress
* the redundant modal without writing a fake completion marker. Local backends
* remain browser-onboarding-driven.
*
* With `?previewOnboardingStep=<0-3>` the modal opens on that slide for
* design review without persisting completion (works on any route when
Expand All @@ -25,9 +41,16 @@ export function OnboardingHost() {
const previewStep = readOnboardingPreviewStep(location.search);
const isPreview = isOnboardingPreviewActive(location.search);
const { isCompleted, markCompleted } = useOnboardingCompletion();
const { backend } = useActiveBackend();
const settings = useSettings();
const isCloudBackend = backend.kind === "cloud";

if (!isPreview) {
if (isCompleted) return null;
if (isCloudBackend && settings.isLoading) return null;
if (isCloudBackend && hasUsableCloudLlm(settings.data)) {
return null;
}
}

const handleClose = () => {
Expand Down
Loading