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
113 changes: 6 additions & 107 deletions apps/app/src/components/onboarding/Onboarding.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ import {
type OnboardingConnectState,
type OnboardingRepo,
} from "./OnboardingViews";
import {
SetupChecklistBanner,
type SetupChecklistItem,
} from "./SetupChecklistHost";
import type { AgentSetupState, OnboardingStepId } from "./onboarding-model";
import claudeLogoUrl from "../../../../../plugins/provider-claude-code/icons/claude-code.svg";
import codexLogoUrl from "../../../../../plugins/provider-codex/icons/codex.svg";
Expand Down Expand Up @@ -490,66 +486,9 @@ function DevicesFrame({
);
}

function checklistItems(done: {
agent: boolean;
projects: boolean;
plugins: boolean;
devices: boolean;
}): SetupChecklistItem[] {
return [
{
id: "agent",
title: "Connect a coding agent",
detail: done.agent
? "Ready on this computer"
: "Needed before a thread can start",
done: done.agent,
actionLabel: "Set up",
},
{
id: "projects",
title: "Add your projects",
detail: done.projects
? "3 added"
: "Import the repos you've used recently",
done: done.projects,
actionLabel: "Review",
},
{
id: "plugins",
title: "Pick some plugins",
detail: done.plugins
? "2 turned on"
: "Browser automation, workflows, and more",
done: done.plugins,
actionLabel: "Browse",
},
{
id: "devices",
title: "Use bb from anywhere",
detail: done.devices ? CONNECT_URL : "Phone, browser, other machines",
done: done.devices,
actionLabel: "Set up",
},
];
}

function HomeFrame({
items,
agentMissing,
onOpen = noop,
onDismiss = noop,
}: {
items: SetupChecklistItem[] | null;
agentMissing: boolean;
onOpen?: (step: OnboardingStepId) => void;
onDismiss?: () => void;
}) {
function HomeFrame() {
return (
<div className="mx-auto flex w-full max-w-[760px] flex-1 flex-col px-6 pt-14">
<SetupChecklistBanner
checklist={{ items, agentMissing, open: onOpen, dismiss: onDismiss }}
/>
<p className="rounded-lg border border-border px-3 py-6 text-sm text-muted-foreground">
The usual bb home composer goes here.
</p>
Expand Down Expand Up @@ -587,12 +526,10 @@ function InteractiveFlowRun({ scenario }: { scenario: Scenario }) {
const [selectedRepoIds, setSelectedRepoIds] = useState<ReadonlySet<string>>(
() => new Set(DEFAULT_REPO_IDS),
);
const [imported, setImported] = useState(false);
const [enabledPluginIds, setEnabledPluginIds] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [connectSetupOpen, setConnectSetupOpen] = useState(false);
const [checklistDismissed, setChecklistDismissed] = useState(false);
const timers = useRef<number[]>([]);

const later = (delayMs: number, run: () => void) => {
Expand Down Expand Up @@ -640,20 +577,9 @@ function InteractiveFlowRun({ scenario }: { scenario: Scenario }) {
};

if (view === "home") {
const items = checklistItems({
agent: anyReady(states),
projects: imported,
plugins: enabledPluginIds.size > 0,
devices: false,
});
return (
<StoryWindow>
<HomeFrame
items={checklistDismissed ? null : items}
agentMissing={!anyReady(states)}
onOpen={setView}
onDismiss={() => setChecklistDismissed(true)}
/>
<HomeFrame />
</StoryWindow>
);
}
Expand Down Expand Up @@ -709,10 +635,7 @@ function InteractiveFlowRun({ scenario }: { scenario: Scenario }) {
)
}
onSelectNone={() => setSelectedRepoIds(new Set())}
onPrimary={() => {
setImported(selectedRepoIds.size > 0);
setView("plugins");
}}
onPrimary={() => setView("plugins")}
onSecondary={() => setView("plugins")}
onBack={() => setView("agent")}
/>
Expand Down Expand Up @@ -1037,35 +960,11 @@ export function HomeAfterSkipping() {
return (
<Gallery>
<Captioned
label="Home after Skip setup, no agent ready"
hint="The home composer leads with the missing agent. Dismissing the checklist also stops this notice."
label="Home after Skip setup"
hint="The home composer is ready."
>
<StoryWindow>
<HomeFrame
agentMissing
items={checklistItems({
agent: false,
projects: true,
plugins: false,
devices: false,
})}
/>
</StoryWindow>
</Captioned>
<Captioned
label="Home with an agent ready and steps left"
hint="A one-line checklist names the next open step; Continue reopens the guide there."
>
<StoryWindow>
<HomeFrame
agentMissing={false}
items={checklistItems({
agent: true,
projects: true,
plugins: false,
devices: false,
})}
/>
<HomeFrame />
</StoryWindow>
</Captioned>
</Gallery>
Expand Down
48 changes: 13 additions & 35 deletions apps/app/src/components/onboarding/OnboardingGate.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { defaultAppSettings, type AppSettings } from "@bb/domain";
import { Provider, createStore } from "jotai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingGate } from "./OnboardingGate";
import { onboardingReopenStepAtom } from "./onboarding-state";

const mocks = vi.hoisted(() => ({
mutate: vi.fn(),
Expand Down Expand Up @@ -40,15 +38,13 @@ function configWith(settings: Partial<AppSettings>) {
});
}

function renderGate(store = createStore()) {
function renderGate() {
const view = render(
<Provider store={store}>
<OnboardingGate>
<div>App shell</div>
</OnboardingGate>
</Provider>,
<OnboardingGate>
<div>App shell</div>
</OnboardingGate>,
);
return { store, view };
return { view };
}

beforeEach(() => {
Expand Down Expand Up @@ -79,7 +75,7 @@ describe("OnboardingGate", () => {
expect(screen.queryByText(/Setup guide/u)).toBeNull();
});

it("records completion, turns the checklist on, and reveals the app when first-run setup closes", async () => {
it("records completion and reveals the app when first-run setup closes", async () => {
configWith({ onboardingCompletedAt: null, streamerMode: true });
renderGate();

Expand All @@ -90,48 +86,30 @@ describe("OnboardingGate", () => {
const written = mocks.mutate.mock.calls[0]?.[0] as AppSettings;
expect(written).toMatchObject({
streamerMode: true,
setupChecklistVisible: true,
});
expect(Number.isNaN(Date.parse(written.onboardingCompletedAt ?? ""))).toBe(
false,
);
});

it("reopens at a requested step without rewriting the completion time", async () => {
configWith({ onboardingCompletedAt: COMPLETED_AT });
const store = createStore();
store.set(onboardingReopenStepAtom, "plugins");

renderGate(store);
fireEvent.click(await screen.findByText("Setup guide at plugins"));

expect(screen.getByText("App shell")).toBeTruthy();
expect(store.get(onboardingReopenStepAtom)).toBeNull();
expect(mocks.mutate).not.toHaveBeenCalled();
});

it("shows the guide again when the completion time is cleared after a close in the same session", async () => {
configWith({ onboardingCompletedAt: null });
const { view, store } = renderGate();
const { view } = renderGate();
fireEvent.click(await screen.findByText("Setup guide at agent"));

configWith({ onboardingCompletedAt: COMPLETED_AT });
view.rerender(
<Provider store={store}>
<OnboardingGate>
<div>App shell</div>
</OnboardingGate>
</Provider>,
<OnboardingGate>
<div>App shell</div>
</OnboardingGate>,
);
expect(screen.getByText("App shell")).toBeTruthy();

configWith({ onboardingCompletedAt: null });
view.rerender(
<Provider store={store}>
<OnboardingGate>
<div>App shell</div>
</OnboardingGate>
</Provider>,
<OnboardingGate>
<div>App shell</div>
</OnboardingGate>,
);

expect(await screen.findByText("Setup guide at agent")).toBeTruthy();
Expand Down
22 changes: 7 additions & 15 deletions apps/app/src/components/onboarding/OnboardingGate.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Suspense, lazy, useEffect, useState, type ReactNode } from "react";
import { useAtom } from "jotai";
import { TooltipProvider } from "@bb/shared-ui/tooltip";
import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import { onboardingReopenStepAtom } from "./onboarding-state";

const OnboardingFlow = lazy(() =>
import("./OnboardingFlow").then((module) => ({
Expand Down Expand Up @@ -32,7 +30,6 @@ function rememberOnboardingSeen(): void {
export function OnboardingGate({ children }: { children: ReactNode }) {
const configQuery = useSystemConfig();
const updateSettings = useUpdateGeneralSettings();
const [reopenStep, setReopenStep] = useAtom(onboardingReopenStepAtom);
const [firstRunClosed, setFirstRunClosed] = useState(false);
const [seenBefore] = useState(readOnboardingSeen);

Expand All @@ -53,25 +50,20 @@ export function OnboardingGate({ children }: { children: ReactNode }) {
}

const firstRun = settings.onboardingCompletedAt === null && !firstRunClosed;
if (!firstRun && reopenStep === null) return children;
if (!firstRun) return children;

return (
<TooltipProvider delayDuration={300} disableHoverableContent>
<div className="flex h-dvh w-full flex-col bg-background">
<Suspense fallback={null}>
<OnboardingFlow
key={reopenStep ?? "first-run"}
initialStep={reopenStep ?? "agent"}
initialStep="agent"
onClose={() => {
if (firstRun) {
setFirstRunClosed(true);
updateSettings.mutate({
...settings,
onboardingCompletedAt: new Date().toISOString(),
setupChecklistVisible: true,
});
}
setReopenStep(null);
setFirstRunClosed(true);
updateSettings.mutate({
...settings,
onboardingCompletedAt: new Date().toISOString(),
});
}}
/>
</Suspense>
Expand Down
Loading
Loading