Skip to content
Merged
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 apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export function App() {
including auth callback, which renders no app shell. */}
<ProviderCliInstallLogDialogHost />
{/* First-run onboarding. Outside <Routes> so it is not tied to a
page, and self-gating on the persisted completion timestamp. */}
page. It self-gates on the experiment and completion timestamp. */}
<OnboardingHost />
</RouteNavigationProvider>
</AppCommandProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
newOnboarding: false,
toolsHub: true,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
newOnboarding: false,
toolsHub: true,
},
},
Expand Down
98 changes: 98 additions & 0 deletions apps/app/src/components/onboarding/OnboardingHost.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { defaultAppSettings, defaultExperiments } from "@bb/domain";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingHost } from "./OnboardingHost";

const mocks = vi.hoisted(() => ({
useCreateProject: vi.fn(),
useHostProviderCliStatus: vi.fn(),
usePrimaryHost: vi.fn(),
useProviderCliInstallRunner: vi.fn(),
useSidebarNavigation: vi.fn(),
useSystemConfig: vi.fn(),
useUpdateGeneralSettings: vi.fn(),
}));

vi.mock("@/hooks/queries/system-queries", () => ({
useHostProviderCliStatus: mocks.useHostProviderCliStatus,
useSystemConfig: mocks.useSystemConfig,
}));
vi.mock("@/hooks/mutations/settings-mutations", () => ({
useUpdateGeneralSettings: mocks.useUpdateGeneralSettings,
}));
vi.mock("@/hooks/mutations/project-mutations", () => ({
useCreateProject: mocks.useCreateProject,
}));
vi.mock("@/hooks/queries/host-queries", () => ({
usePrimaryHost: mocks.usePrimaryHost,
}));
vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({
useSidebarNavigation: mocks.useSidebarNavigation,
}));
vi.mock("@/components/provider-cli/provider-cli-install", () => ({
buildProviderCliIssue: vi.fn(),
hasProviderCliAction: vi.fn(),
providerCliEntries: vi.fn(() => []),
useProviderCliInstallRunner: mocks.useProviderCliInstallRunner,
}));
vi.mock("@/components/provider-cli/provider-cli-install-store", () => ({
providerCliJobKey: vi.fn(() => "job"),
}));
vi.mock("./OnboardingFlow", () => ({
OnboardingFlow: () => <div>Onboarding flow</div>,
}));

beforeEach(() => {
mocks.useCreateProject.mockReturnValue({ mutateAsync: vi.fn() });
mocks.useHostProviderCliStatus.mockReturnValue({ data: undefined });
mocks.usePrimaryHost.mockReturnValue({ id: "host-1" });
mocks.useProviderCliInstallRunner.mockReturnValue({
queuedJobKeys: new Set(),
runningJobKey: null,
startInstall: vi.fn(),
});
mocks.useSidebarNavigation.mockReturnValue({ data: { projects: [] } });
mocks.useUpdateGeneralSettings.mockReturnValue({ mutate: vi.fn() });
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe("OnboardingHost", () => {
it("does not show or run provider checks while the experiment is off", () => {
mocks.useSystemConfig.mockReturnValue({
data: {
experiments: defaultExperiments,
generalSettings: defaultAppSettings,
},
});

render(<OnboardingHost />);

expect(screen.queryByText("Onboarding flow")).toBeNull();
expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({
enabled: false,
hostId: "host-1",
});
});

it("shows onboarding when the experiment is on and setup is incomplete", () => {
mocks.useSystemConfig.mockReturnValue({
data: {
experiments: { ...defaultExperiments, newOnboarding: true },
generalSettings: defaultAppSettings,
},
});

render(<OnboardingHost />);

expect(screen.getByText("Onboarding flow")).toBeTruthy();
expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({
enabled: true,
hostId: "host-1",
});
});
});
19 changes: 11 additions & 8 deletions apps/app/src/components/onboarding/OnboardingHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ import {
* creating the chosen projects, persisting the completion timestamp, and
* reporting the funnel to the server's telemetry.
*
* Mounted once by the app shell. `onboardingCompletedAt` is the only gate —
* whether an agent is actually usable is answered live by the agents query, so
* dismissing onboarding never claims the machine is configured.
* Mounted once by the app shell. The new-onboarding experiment and the
* `onboardingCompletedAt` timestamp gate the flow. Whether an agent is actually
* usable is answered live by the agents query, so dismissing onboarding never
* claims the machine is configured.
*/
export function OnboardingHost() {
const configQuery = useSystemConfig();
Expand All @@ -66,18 +67,22 @@ export function OnboardingHost() {
const startedAt = useRef<number | null>(null);

const settings = configQuery.data?.generalSettings;
const newOnboardingEnabled =
configQuery.data?.experiments.newOnboarding ?? false;
const primaryHostId = primaryHost?.id ?? null;
// Migration 0085 stamps existing installs as already onboarded, so a null
// timestamp means exactly one thing here: show the flow. That is what lets
// Settings re-trigger it by clearing the column.
// timestamp means exactly one thing here: the flow remains incomplete. That
// is what lets Settings re-trigger it by clearing the column.
const neverOnboarded =
settings !== undefined && settings.onboardingCompletedAt === null;
const shouldShow =
newOnboardingEnabled && neverOnboarded && primaryHostId !== null;
const cliStatusQuery = useHostProviderCliStatus({
hostId: primaryHostId,
// Only needed to build an install job, and only while the flow is open.
// Left ungated this runs provider CLI and package-registry checks on every
// app start, forever, for users who finished onboarding long ago.
enabled: primaryHostId !== null && neverOnboarded,
enabled: shouldShow,
});

const projects = navigationQuery.data?.projects;
Expand Down Expand Up @@ -111,8 +116,6 @@ export function OnboardingHost() {
[cliStatusQuery.data, installRunner, primaryHostId],
);

const shouldShow = neverOnboarded && primaryHostId !== null;

// Stamp when the flow actually opens, so a re-trigger hours into a session
// does not report the whole session as its duration.
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/lib/system-config-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const unavailableSystemConfig: SystemConfigResponse = {
keybindingOverrides: [],
experiments: {
claudeCodeMockCliTraffic: false,
newOnboarding: false,
toolsHub: false,
},
appearance: defaultAppTheme,
Expand Down
12 changes: 12 additions & 0 deletions apps/app/src/views/SettingsView.experiments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,32 @@ import { ExperimentsSettingsSection } from "./SettingsView";
afterEach(cleanup);

function renderSection(overrides?: {
onNewOnboardingEnabledChange?: (enabled: boolean) => void;
onToolsHubEnabledChange?: (enabled: boolean) => void;
}) {
return render(
<ExperimentsSettingsSection
claudeCodeMockCliTrafficEnabled={false}
disabled={false}
newOnboardingEnabled={false}
onClaudeCodeMockCliTrafficEnabledChange={vi.fn()}
onNewOnboardingEnabledChange={
overrides?.onNewOnboardingEnabledChange ?? vi.fn()
}
onToolsHubEnabledChange={overrides?.onToolsHubEnabledChange ?? vi.fn()}
toolsHubEnabled={false}
/>,
);
}

describe("ExperimentsSettingsSection", () => {
it("reports new onboarding changes", () => {
const onChange = vi.fn();
renderSection({ onNewOnboardingEnabledChange: onChange });
fireEvent.click(screen.getByLabelText("New onboarding"));
expect(onChange).toHaveBeenCalledWith(true);
});

it("reports Extensions changes", () => {
const onChange = vi.fn();
renderSection({ onToolsHubEnabledChange: onChange });
Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/views/SettingsView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ function GeneralSettingsStory({
openLinksInAppBrowser={state.openLinksInAppBrowser}
rewriteLocalhostLinks={state.rewriteLocalhostLinks}
richTextEditing={state.richTextEditing}
replayOnboardingAvailable={state.experiments.newOnboarding}
steerActiveThreadOnEnter={state.steerActiveThreadOnEnter}
steerActiveThreadOnEnterDisabled={false}
/>
Expand Down Expand Up @@ -340,12 +341,19 @@ function ExperimentsStory() {
state.experiments.claudeCodeMockCliTraffic
}
disabled={false}
newOnboardingEnabled={state.experiments.newOnboarding}
onClaudeCodeMockCliTrafficEnabledChange={(enabled) =>
state.setExperiments((current) => ({
...current,
claudeCodeMockCliTraffic: enabled,
}))
}
onNewOnboardingEnabledChange={(enabled) =>
state.setExperiments((current) => ({
...current,
newOnboarding: enabled,
}))
}
onToolsHubEnabledChange={(enabled) =>
state.setExperiments((current) => ({
...current,
Expand Down
35 changes: 32 additions & 3 deletions apps/app/src/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export interface GeneralSettingsSectionProps {
openLinksInAppBrowser: boolean;
rewriteLocalhostLinks: boolean;
richTextEditing: boolean;
replayOnboardingAvailable: boolean;
steerActiveThreadOnEnter: boolean;
steerActiveThreadOnEnterDisabled: boolean;
}
Expand All @@ -210,7 +211,9 @@ export interface ExperimentsSettingsSectionProps {
/** True while the config query hasn't loaded or a toggle write is in flight. */
disabled: boolean;
claudeCodeMockCliTrafficEnabled: boolean;
newOnboardingEnabled: boolean;
onClaudeCodeMockCliTrafficEnabledChange: (enabled: boolean) => void;
onNewOnboardingEnabledChange: (enabled: boolean) => void;
onToolsHubEnabledChange: (enabled: boolean) => void;
toolsHubEnabled: boolean;
}
Expand Down Expand Up @@ -781,6 +784,7 @@ export function GeneralSettingsSection({
openLinksInAppBrowser,
rewriteLocalhostLinks,
richTextEditing,
replayOnboardingAvailable,
steerActiveThreadOnEnter,
steerActiveThreadOnEnterDisabled,
onReplayOnboarding,
Expand Down Expand Up @@ -826,15 +830,17 @@ export function GeneralSettingsSection({
onEnabledChange={onRewriteLocalhostLinksChange}
/>

<ReplayOnboardingSettingsControl onReplay={onReplayOnboarding} />
{replayOnboardingAvailable ? (
<ReplayOnboardingSettingsControl onReplay={onReplayOnboarding} />
) : null}
</div>
</SettingsSection>
);
}

/**
* Clearing `onboardingCompletedAt` is all it takes: the onboarding host gates
* purely on that timestamp, so the flow reopens on the spot.
* The parent only shows this control when the new-onboarding experiment is on.
* Clearing `onboardingCompletedAt` then reopens the flow on the spot.
*/
function ReplayOnboardingSettingsControl({
onReplay,
Expand Down Expand Up @@ -947,11 +953,14 @@ export function ProviderSettingsSection({
}

const CLAUDE_CODE_MOCK_CLI_TRAFFIC_EXPERIMENT_LABEL = "Mock CLI Traffic";
const NEW_ONBOARDING_EXPERIMENT_LABEL = "New onboarding";
const EXTENSIONS_EXPERIMENT_LABEL = "Extensions";
export function ExperimentsSettingsSection({
claudeCodeMockCliTrafficEnabled,
disabled,
newOnboardingEnabled,
onClaudeCodeMockCliTrafficEnabledChange,
onNewOnboardingEnabledChange,
onToolsHubEnabledChange,
toolsHubEnabled,
}: ExperimentsSettingsSectionProps) {
Expand All @@ -974,6 +983,18 @@ export function ExperimentsSettingsSection({
/>
</SettingsWithControl>

<SettingsWithControl
label={NEW_ONBOARDING_EXPERIMENT_LABEL}
description="Enable the new first-run guide for agent setup and project selection."
>
<Switch
checked={newOnboardingEnabled}
disabled={disabled}
onCheckedChange={onNewOnboardingEnabledChange}
aria-label={NEW_ONBOARDING_EXPERIMENT_LABEL}
/>
</SettingsWithControl>

<SettingsWithControl
label={EXTENSIONS_EXPERIMENT_LABEL}
description="Enable Extensions for managing skills and plugins. Automations stay in the Plugins section beside threads, and installed skills and plugin runtimes keep working while it is off."
Expand Down Expand Up @@ -1142,6 +1163,13 @@ export function SettingsView() {
claudeCodeMockCliTraffic: enabled,
})
}
newOnboardingEnabled={experiments.newOnboarding}
onNewOnboardingEnabledChange={(enabled) =>
updateExperimentsMutation.mutate({
...experiments,
newOnboarding: enabled,
})
}
onToolsHubEnabledChange={(enabled) =>
updateExperimentsMutation.mutate({
...experiments,
Expand Down Expand Up @@ -1174,6 +1202,7 @@ export function SettingsView() {
openLinksInAppBrowser={openLinksInAppBrowser}
rewriteLocalhostLinks={rewriteLocalhostLinks}
richTextEditing={richTextEditing}
replayOnboardingAvailable={experiments.newOnboarding}
steerActiveThreadOnEnter={generalSettings.steerActiveThreadOnEnter}
steerActiveThreadOnEnterDisabled={
systemConfigQuery.data === undefined ||
Expand Down
Loading
Loading