Skip to content
Closed
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
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ VITE_FRONTEND_PORT="3001" # Port to run the frontend application
VITE_USE_TLS="false" # Use HTTPS/WSS for proxied backend connections
VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxied backend requests
# VITE_BASE_PATH="/canvas" # Build/serve the SPA under a subpath. Leave unset for root-local development.
# VITE_DISABLE_ONBOARDING="true" # Skip the first-run onboarding wizard. Runtime equivalent for prebuilt bundles: static-server.mjs --disable-onboarding (or env AGENT_CANVAS_DISABLE_ONBOARDING=true).

# Public PostHog client key compiled into source builds. Precompiled library
# consumers can instead pass apiKey/apiHost/uiHost to AgentServerUIProviders.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- `VITE_WORKING_DIR` for the default workspace path sent when starting conversations.
- `VITE_ENABLE_BROWSER_TOOLS=false` to omit `BrowserToolSet` from new conversation payloads.
- `VITE_BASE_PATH` for serving the SPA under a subpath such as `/canvas`; pair it with `scripts/static-server.mjs --base-path` at runtime.
- `VITE_DISABLE_ONBOARDING=true` to skip the first-run onboarding wizard (users land directly on the home screen; Cloud login is never suppressed); pair it with `scripts/static-server.mjs --disable-onboarding` or env `AGENT_CANVAS_DISABLE_ONBOARDING=true` at runtime for prebuilt bundles.
Comment thread
hieptl marked this conversation as resolved.
- Public skills are loaded from the `@openhands/extensions` npm package at build time via `SKILLS_CATALOG` (exported from `@openhands/extensions/skills`). The frontend's `SkillsService` maps catalog entries to `SkillInfo` objects and merges them with user/project skills fetched from the agent-server (with `load_public: false`). The agent-server no longer clones the extensions repo or uses `EXTENSIONS_REF` for public skills.
- Default working-dir fallback is now the relative path `workspace/project` (exported as `DEFAULT_WORKING_DIR` from `src/api/agent-server-config.ts`); git-path heuristics and the default PLAN preview path should reuse that constant instead of hardcoding `/workspace/project`.
- Current Cloud behavior is implemented explicitly through the backend registry, Cloud service layer, and device authorization flow.
Expand Down Expand Up @@ -584,7 +585,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 gated by the `openhands-onboarded` localStorage flag plus the deployment-level disable flag (`isOnboardingDisabled()` — see `VITE_DISABLE_ONBOARDING` above). 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
31 changes: 31 additions & 0 deletions __tests__/api/agent-server-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isAuthRequired,
isSameCloudHost,
isAuthRequiredAndMissing,
isOnboardingDisabled,
} from "#/api/agent-server-config";

const ORIGINAL_LOCATION = window.location;
Expand All @@ -30,6 +31,8 @@ afterEach(() => {
.__AGENT_CANVAS_SESSION_API_KEY__;
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_DISABLE_ONBOARDING__;
Object.defineProperty(window, "location", {
configurable: true,
value: ORIGINAL_LOCATION,
Expand Down Expand Up @@ -247,6 +250,34 @@ describe("isAuthRequiredAndMissing", () => {
});
});

describe("isOnboardingDisabled", () => {
it("returns false when neither env var nor window flag is set", () => {
expect(isOnboardingDisabled()).toBe(false);
});

it("returns true when VITE_DISABLE_ONBOARDING is 'true'", () => {
vi.stubEnv("VITE_DISABLE_ONBOARDING", "true");

expect(isOnboardingDisabled()).toBe(true);
});

it("returns true when window.__AGENT_CANVAS_DISABLE_ONBOARDING__ is set", () => {
(
window as unknown as Record<string, unknown>
).__AGENT_CANVAS_DISABLE_ONBOARDING__ = true;

expect(isOnboardingDisabled()).toBe(true);
});

it("returns false when window flag is a non-true value", () => {
(
window as unknown as Record<string, unknown>
).__AGENT_CANVAS_DISABLE_ONBOARDING__ = "true";

expect(isOnboardingDisabled()).toBe(false);
});
});

// Covers the published `agent-canvas` binary path: the prebuilt bundle has
// no VITE_SESSION_API_KEY baked in, but `scripts/static-server.mjs` injects
// the runtime key into `window.__AGENT_CANVAS_SESSION_API_KEY__`. Without
Expand Down
32 changes: 32 additions & 0 deletions __tests__/components/onboarding/onboarding-host.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ afterEach(() => {
window.localStorage.clear();
vi.unstubAllEnvs();
__resetActiveStoreForTests();
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_DISABLE_ONBOARDING__;
});

describe("OnboardingHost", () => {
Expand Down Expand Up @@ -243,4 +245,34 @@ describe("OnboardingHost", () => {
window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
).toBeNull();
});

it("does not mount the modal for a fresh Cloud user when onboarding is disabled for the deployment", () => {
// Runtime half of the flag — the injected window global is the path SaaS
// actually uses (the prebuilt bundle has no VITE_DISABLE_ONBOARDING baked
// in). The build-time half is covered through the root gate in
// __tests__/root.test.tsx.
(
window as unknown as Record<string, unknown>
).__AGENT_CANVAS_DISABLE_ONBOARDING__ = true;
seedCloudBackend();
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...DEFAULT_SETTINGS,
llm_api_key_set: true,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "openhands/minimax-m2.7", api_key: "stored" },
},
});

renderHost();

expect(
screen.queryByTestId("onboarding-modal-stub"),
).not.toBeInTheDocument();
// Suppression comes from the deployment flag, not a fake completion
// marker — nothing is written to localStorage.
expect(
window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
).toBeNull();
});
});
17 changes: 17 additions & 0 deletions __tests__/root.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,23 @@ describe("App root agent-server availability guard", () => {
).not.toBeInTheDocument();
});

it("skips first-run onboarding for a fresh install when onboarding is disabled for the deployment", async () => {
vi.stubEnv("VITE_DISABLE_ONBOARDING", "true");
server.use(
http.get("*/server_info", () =>
HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }),
),
);

renderApp(["/"]);

expect(await screen.findByTestId("app-outlet")).toBeInTheDocument();
expect(
screen.queryByTestId("first-run-onboarding-screen"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument();
});

it("shows first-run onboarding before the recovery modal when locked to Cloud with no backend", async () => {
vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
vi.stubEnv("VITE_SESSION_API_KEY", "");
Expand Down
87 changes: 86 additions & 1 deletion __tests__/scripts/static-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Server } from "node:http";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import { parseArgs, startStaticServer } from "../../scripts/static-server.mjs";

Expand All @@ -11,6 +11,9 @@ describe("static-server.mjs", () => {
const tempDirs: string[] = [];

afterEach(async () => {
vi.unstubAllEnvs();
vi.restoreAllMocks();

await Promise.all(
servers.splice(0).map(
(server) =>
Expand Down Expand Up @@ -85,6 +88,16 @@ describe("static-server.mjs", () => {
expect(config.lockToCloud).toBeNull();
});

it("defaults disableOnboarding to false", () => {
const config = parseArgs([]);
expect(config.disableOnboarding).toBe(false);
});

it("parses --disable-onboarding", () => {
const config = parseArgs(["--disable-onboarding"]);
expect(config.disableOnboarding).toBe(true);
});

it("defaults basePath to root", () => {
const config = parseArgs([]);
expect(config.basePath).toBe("/");
Expand Down Expand Up @@ -240,6 +253,78 @@ describe("static-server.mjs", () => {
});
});

describe("disable-onboarding injection", () => {
// Covers the SaaS / prebuilt-bundle path: the published build has no
// VITE_DISABLE_ONBOARDING baked in, so the wizard is suppressed through
// this injected window global (see `isOnboardingDisabled()` in
// src/api/agent-server-config.ts).
it("exposes the flag on window.__AGENT_CANVAS_DISABLE_ONBOARDING__", async () => {
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
tempDirs.push(buildDir);
writeFileSync(
path.join(buildDir, "index.html"),
"<html><head></head><body>app</body></html>",
);

const origin = await startServer(buildDir, { disableOnboarding: true });
const body = await (await fetch(`${origin}/`)).text();

expect(body).toContain("window.__AGENT_CANVAS_DISABLE_ONBOARDING__=true");
});

it("does not inject the flag by default", async () => {
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
tempDirs.push(buildDir);
writeFileSync(
path.join(buildDir, "index.html"),
"<html><head></head><body>app</body></html>",
);

const origin = await startServer(buildDir);
const body = await (await fetch(`${origin}/`)).text();

expect(body).not.toContain("__AGENT_CANVAS_DISABLE_ONBOARDING__");
});

it("honors the AGENT_CANVAS_DISABLE_ONBOARDING env fallback", async () => {
// The SaaS Helm chart execs static-server.mjs directly and can only
// set container env vars (generic `env:` passthrough), not CLI flags.
vi.stubEnv("AGENT_CANVAS_DISABLE_ONBOARDING", "true");
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
tempDirs.push(buildDir);
writeFileSync(
path.join(buildDir, "index.html"),
"<html><head></head><body>app</body></html>",
);

const origin = await startServer(buildDir);
const body = await (await fetch(`${origin}/`)).text();

expect(body).toContain("window.__AGENT_CANVAS_DISABLE_ONBOARDING__=true");
});

it("warns and stays off when the env var has an unrecognized value", async () => {
// A Helm typo like "True" or "1" must surface in the container logs
// instead of silently no-oping.
vi.stubEnv("AGENT_CANVAS_DISABLE_ONBOARDING", "True");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
tempDirs.push(buildDir);
writeFileSync(
path.join(buildDir, "index.html"),
"<html><head></head><body>app</body></html>",
);

const origin = await startServer(buildDir);
const body = await (await fetch(`${origin}/`)).text();

expect(body).not.toContain("__AGENT_CANVAS_DISABLE_ONBOARDING__");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('AGENT_CANVAS_DISABLE_ONBOARDING="True"'),
);
});
});

describe("session key injection", () => {
async function startServerWithKey(dir: string, sessionApiKey: string) {
const server = await startStaticServer({
Expand Down
4 changes: 4 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
# AGENT_SERVER_PORT – Internal agent-server port (default: 18000)
# AUTOMATION_PORT – Internal automation port (default: 18001)
# AGENT_CANVAS_BASE_PATH – Static frontend mount path (default: /canvas)
# AGENT_CANVAS_DISABLE_ONBOARDING – "true" skips the first-run onboarding
# wizard (read from the environment by
# static-server.mjs; deployment-wide, so the optional
# public-mode server below inherits it too)
# PUBLIC_MODE_PORT – If set, starts a second static server on this port
# with --auth-required (no session key injected)
# OH_SECRET_KEY – Secret key for settings encryption (auto-generated
Expand Down
1 change: 1 addition & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ You can create a `.env` file in the project directory with these variables based
| `VITE_WORKING_DIR` | Workspace path sent when starting new conversations | `workspace/project` |
| `VITE_ENABLE_BROWSER_TOOLS` | Set to `false` to omit `BrowserToolSet` from new conversation payloads | `true` |
| `VITE_BASE_PATH` | Build/serve the SPA under a subpath such as `/canvas` | `/` |
| `VITE_DISABLE_ONBOARDING` | Set to `true` to skip the first-run onboarding wizard (users land on the home screen) | `false` |
| `VITE_MOCK_API` | Enable/disable API mocking with MSW | `false` |
| `VITE_USE_TLS` | Use HTTPS/WSS for the Vite proxy target | `false` |
| `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` |
Expand Down
Loading
Loading