Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
65 changes: 64 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,8 @@ describe("static-server.mjs", () => {
const tempDirs: string[] = [];

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

await Promise.all(
servers.splice(0).map(
(server) =>
Expand Down Expand Up @@ -85,6 +87,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 +252,57 @@ 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");
});
});

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
32 changes: 32 additions & 0 deletions scripts/static-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
authRequired: false,
runtimeServicesInfo: null,
lockToCloud: null,
disableOnboarding: false,
basePath: "/",
};

Expand Down Expand Up @@ -128,6 +129,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
case "--lock-to-cloud":
config.lockToCloud = argv[++i] || null;
break;
case "--disable-onboarding":
config.disableOnboarding = true;
break;
case "--base-path":
config.basePath = normalizeBasePath(argv[++i]);
break;
Expand Down Expand Up @@ -206,6 +210,10 @@ OPTIONS:
--lock-to-cloud <cloud-url> Lock backend setup to a single OpenHands Cloud
URL. Hides manual/local backend setup and the
custom Cloud URL field in the pre-built frontend.
--disable-onboarding Inject a flag into index.html so the pre-built
frontend skips the first-run onboarding wizard.
Also enabled by env
AGENT_CANVAS_DISABLE_ONBOARDING=true.
Comment thread
hieptl marked this conversation as resolved.
--base-path <path> Mount the SPA under <path> (default: /).
For example, --base-path /canvas serves
index.html and assets under /canvas.
Expand Down Expand Up @@ -263,6 +271,11 @@ ROUTING:
* `agent-server-config.ts` so pre-built frontend bundles can hide manual
* backend setup and the custom Cloud URL field at runtime.
*
* - `disableOnboarding`: sets `window.__AGENT_CANVAS_DISABLE_ONBOARDING__ =
* true` so the pre-built frontend skips the first-run onboarding wizard
* (read by `isOnboardingDisabled()` in `agent-server-config.ts`) without
* VITE_DISABLE_ONBOARDING baked in.
*
* - `basePath`: the path prefix the SPA is mounted under, exposed as
* `window.__AGENT_CANVAS_BASE_PATH__` so runtime static assets like locale
* files can resolve through the same subpath as the built bundle.
Expand All @@ -272,6 +285,7 @@ function makeConfigInjectionScript(
authRequired,
runtimeServicesInfo,
lockToCloud,
disableOnboarding,
basePath,
) {
const parts = [];
Expand Down Expand Up @@ -316,6 +330,10 @@ function makeConfigInjectionScript(
);
}

if (disableOnboarding) {
parts.push(`window.__AGENT_CANVAS_DISABLE_ONBOARDING__=true;`);
}

if (basePath && basePath !== "/") {
parts.push(
`window.__AGENT_CANVAS_BASE_PATH__=${JSON.stringify(basePath)};`,
Expand All @@ -340,6 +358,7 @@ async function serveInjectedIndexHtml(
authRequired,
runtimeServicesInfo,
lockToCloud,
disableOnboarding,
basePath,
} = {},
) {
Expand All @@ -355,6 +374,7 @@ async function serveInjectedIndexHtml(
authRequired,
runtimeServicesInfo,
lockToCloud,
disableOnboarding,
basePath,
);
// Inject right before </head> so the key is available before any app code runs.
Expand Down Expand Up @@ -404,6 +424,7 @@ function needsRuntimeInjection(injectionOpts) {
injectionOpts.authRequired ||
injectionOpts.runtimeServicesInfo ||
injectionOpts.lockToCloud ||
injectionOpts.disableOnboarding ||
(injectionOpts.basePath && injectionOpts.basePath !== "/"),
);
}
Expand Down Expand Up @@ -542,11 +563,19 @@ export function startStaticServer(config) {
const route = createRouter(config.routes);
const proxy = createProxyHandlers({ label: `static:${config.port}` });
const dirAbs = resolve(config.dir);
// Env fallback so deployments that can only set container env vars (e.g.
// the SaaS Helm chart execs this script directly and only has a generic
// `env:` passthrough) can enable the flag without changing the CLI
// invocation. Env can only turn the flag ON.
const disableOnboarding =
config.disableOnboarding ||
process.env.AGENT_CANVAS_DISABLE_ONBOARDING === "true";
const injectionOpts = {
sessionApiKey: config.sessionApiKey || null,
authRequired: config.authRequired || false,
runtimeServicesInfo: config.runtimeServicesInfo || null,
lockToCloud: config.lockToCloud || null,
disableOnboarding,
basePath: normalizeBasePath(config.basePath),
};
const basePath = injectionOpts.basePath;
Expand Down Expand Up @@ -611,6 +640,9 @@ export function startStaticServer(config) {
if (config.lockToCloud) {
console.log(` Backend setup locked to Cloud: ${config.lockToCloud}`);
}
if (disableOnboarding) {
console.log(" First-run onboarding: disabled");
}
console.log(" * (default) -> static files + SPA fallback");
console.log("");
resolveListen(server);
Expand Down
Loading
Loading