mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 00:45:55 +02:00
* feat(onboarding): per-question v2 questionnaire (source/role/use_case) Replaces the 3-questions-on-one-screen gate with three lightweight, individually-skippable steps. New step order: welcome → source → role → use_case → workspace → runtime → agent → first_issue - New v2 questionnaire schema: source/role/use_case + per-slot `*_skipped` markers. `team_size` removed. - Click-to-advance card grid with lucide + emoji icons (RFC Option B). - Skip is a footer text button; Other expands a free-text input. - Recommendation table updated for new role × use_case vocabulary, with use_case-only fallback when role is skipped. - DB migration v1 → v2 maps existing role/use_case answers and drops team_size; historical nulls stay null (not retroactively skipped). - Re-entry treats skipped slots as fresh; analytics record kept in DB. - onboarding_questionnaire_submitted event payload updated: source replaces team_size, per-slot skip booleans added. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): tighten question UX (Continue, layout, brand icons) Address review feedback on Source/Role/Use-case: - Replace auto-advance with an explicit Continue button so selections are reviewable. Continue is disabled until something is picked (and, for Other, until the free-text input is non-empty). - Move Back/Skip/Continue inline under the option grid; drop the duplicate Back from the top header — the page now has a single, anchored action row. - Swap the placeholder lucide marks for real brand SVGs on Source: Google, X, LinkedIn, YouTube, and an OpenAI mark for the AI-assistant option. Generic options stay on lucide. - Replace the awkward expanded underline input on the Other card with an inline borderless input that swaps in for the label slot, so the Other state has the same height and weight as the other cards. E2E smoke test updated to click Continue between question steps. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): unify step nav, rename Runtime step around "where agents run" - Refactor the Source/Role/Use case questionnaire steps to use the same 3-region chrome (header with Back + step indicator, scrolling main, sticky footer with Skip + Continue) that Workspace/Runtime/Agent already use, so the Back/Skip/Continue affordances stay in the same on-screen position across the whole flow. - Reframe the Runtime step around the user-visible question — "Where will your agents run?" — instead of the internal "runtime" concept. The aside panel keeps the educational "What's a runtime?" copy for users who want to learn. - Drop the hard-coded "Step 3 · Runtime" eyebrow on the web fork step: Runtime is now step 5 of 7 after the per-question split, and the step indicator already shows the correct count. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): tighten Skip/Continue spacing in step footer Group Skip and Continue inside a sub-flex with gap-2 so they read as a single action cluster on the right, while the status hint still anchors left via mr-auto. Applied to both the questionnaire steps and the runtime step so the footer layout stays consistent across onboarding. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): move Skip/Continue inline below form, drop sticky footer The sticky bottom footer left a large dead zone between the form content and the action buttons — most onboarding steps only fill the top third of the viewport. Move the hint + Skip + Continue inline, directly below the form/options grid, so the buttons sit where the eye already is after picking an option. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): match Skip button size to Continue (size="lg") Skip used the default button size (h-8) while Continue used size="lg" (h-9), so the two adjacent action buttons rendered visibly different heights. Promote Skip to size="lg" in step-question and step-runtime-connect so they line up. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): reframe step 3 as 'connect a computer' / 'pick an agent runtime' Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): replace cloud waitlist with "Coming soon", reword CLI intro - Web Step 3 cloud card: remove "Join waitlist" CTA + dialog and render a static "Coming soon" badge instead. Drops CloudWaitlistDialog, the cloud DialogState, waitlistSubmitted local state, and the onWaitlistSubmitted prop on StepPlatformFork (desktop's StepRuntimeConnect still owns its own waitlist path). - Tighten cloud_subtitle to drop the "join the waitlist" half now that the action is gone. - cli_install.intro: "AI coding tool" → "agent runtime", EN + zh-Hans. Tests updated to match: asserts the Coming soon badge is non-actionable and drops the four cloud-dialog scenarios (now unreachable). Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): refresh button, "agent runtime" wording, coming-soon card Three fixes on the desktop Step 3 empty state per review: 1. Empty headline + hints now say "agent runtime", matching the picker-context terminology established earlier in this PR. 2. Add a Refresh button (header pill in Found, inline with the headline in Empty). Desktop wires it to restart the bundled daemon so a freshly-installed Claude/Codex/Cursor CLI is picked up — the daemon's PATH probe runs once at boot, so without a restart the install would only take effect on next launch. 3. "Use a cloud computer" loses the waitlist dialog and renders as a disabled "Coming soon" badge, aligning with the web fork. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): address review follow-ups (i18n, step-order, version, tests) - runtime-aside-panel: point "Learn more" to /docs/install-agent-runtime, branching by language so zh users land on /docs/zh/... - zh-Hans: unify Cloud "Coming soon" wording to "即将推出"; translate step_workspace.preview.more_meta ("and more" -> "等等") - onboarding-flow: derive forward navigation from ONBOARDING_STEP_ORDER via advanceFrom(curr) so inserting/reordering a step only requires editing the canonical array; runtime → agent/first_issue branch keeps its bespoke routing with a comment explaining why - onboarding handler: gate questionnaireAnswers.complete() on Version == 2 so a future schema bump can't be silently mis-counted against v2 funnel semantics - add unit tests for step-source / step-role / step-use-case (option click, Skip patch, Other free-text) and step-question shell (canContinue + pendingOther state machine) Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): rename useCaseFallback to fallbackFromUseCase ESLint's react-hooks/rules-of-hooks treats any function starting with "use" as a React hook. The helper is a pure switch — give it a name that doesn't trip the rule. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
181 lines
6.0 KiB
TypeScript
181 lines
6.0 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { AgentRuntime } from "@multica/core/types";
|
|
import { I18nProvider } from "@multica/core/i18n/react";
|
|
import enCommon from "../../locales/en/common.json";
|
|
import enOnboarding from "../../locales/en/onboarding.json";
|
|
|
|
const TEST_RESOURCES = { en: { common: enCommon, onboarding: enOnboarding } };
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
pickerState: {
|
|
runtimes: [] as AgentRuntime[],
|
|
selected: null as AgentRuntime | null,
|
|
selectedId: null as string | null,
|
|
setSelectedId: vi.fn<(id: string) => void>(),
|
|
hasRuntimes: false,
|
|
},
|
|
}));
|
|
|
|
// Swap out the runtime picker so tests can drive runtimes / selection
|
|
// without a real TanStack Query + WS stack.
|
|
vi.mock("../components/use-runtime-picker", () => ({
|
|
useRuntimePicker: () => mocks.pickerState,
|
|
}));
|
|
|
|
import { StepPlatformFork } from "./step-platform-fork";
|
|
|
|
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
|
return {
|
|
id: "rt_test",
|
|
workspace_id: "ws_test",
|
|
name: "Claude Code",
|
|
provider: "claude",
|
|
status: "online",
|
|
runtime_mode: "local",
|
|
runtime_config: {},
|
|
device_info: "",
|
|
metadata: {},
|
|
daemon_id: null,
|
|
last_seen_at: new Date().toISOString(),
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
...overrides,
|
|
} as unknown as AgentRuntime;
|
|
}
|
|
|
|
function renderFork(
|
|
overrides: Partial<React.ComponentProps<typeof StepPlatformFork>> = {},
|
|
) {
|
|
const onNext = vi.fn();
|
|
render(
|
|
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
|
<StepPlatformFork
|
|
wsId="ws_test"
|
|
onNext={onNext}
|
|
cliInstructions={<div data-testid="cli-instructions">install me</div>}
|
|
{...overrides}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
return { onNext };
|
|
}
|
|
|
|
function resetPicker(patch: Partial<typeof mocks.pickerState> = {}) {
|
|
mocks.pickerState.runtimes = patch.runtimes ?? [];
|
|
mocks.pickerState.selected = patch.selected ?? null;
|
|
mocks.pickerState.selectedId = patch.selectedId ?? null;
|
|
mocks.pickerState.hasRuntimes = patch.hasRuntimes ?? false;
|
|
mocks.pickerState.setSelectedId = vi.fn();
|
|
}
|
|
|
|
describe("StepPlatformFork", () => {
|
|
beforeEach(() => {
|
|
resetPicker();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("renders the three fork options at rest", () => {
|
|
renderFork();
|
|
expect(screen.getByText(/^use this computer$/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/^connect from the terminal$/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/^use a cloud computer$/i)).toBeInTheDocument();
|
|
// Cloud option is a "Coming soon" preview — not yet wired up.
|
|
expect(screen.getByText(/^coming soon$/i)).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: /^coming soon$/i }),
|
|
).not.toBeInTheDocument();
|
|
// CLI dialog closed at rest → no CLI instructions.
|
|
expect(screen.queryByTestId("cli-instructions")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("footer: Skip only + explanatory hint (no Continue)", () => {
|
|
renderFork();
|
|
expect(
|
|
screen.getByRole("button", { name: /skip for now/i }),
|
|
).toBeEnabled();
|
|
// Continue is gone — it lived in the footer before; now advancement
|
|
// for the CLI path is owned by the CLI dialog's own button.
|
|
expect(
|
|
screen.queryByRole("button", { name: /^continue$/i }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/pick a way to connect — or skip and connect a computer later/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("Skip is always enabled and calls onNext(null)", async () => {
|
|
const user = userEvent.setup();
|
|
const { onNext } = renderFork();
|
|
await user.click(screen.getByRole("button", { name: /skip for now/i }));
|
|
expect(onNext).toHaveBeenCalledTimes(1);
|
|
expect(onNext).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("opens the download page and flips the card to a post-click state", async () => {
|
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
|
const user = userEvent.setup();
|
|
renderFork();
|
|
|
|
await user.click(screen.getByText(/^use this computer$/i));
|
|
|
|
// Routes to the new /download page (not GitHub releases) so the
|
|
// user lands on the OS auto-detect surface.
|
|
expect(openSpy).toHaveBeenCalledWith(
|
|
"/download",
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
expect(
|
|
screen.getByText(/opening the download page/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("CLI dialog: opens with instructions + 'waiting' and a disabled Connect button", async () => {
|
|
const user = userEvent.setup();
|
|
renderFork();
|
|
|
|
await user.click(screen.getByRole("button", { name: /show steps/i }));
|
|
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(within(dialog).getByTestId("cli-instructions")).toBeInTheDocument();
|
|
expect(
|
|
within(dialog).getByText(/waiting for your computer/i),
|
|
).toBeInTheDocument();
|
|
// Connect & continue stays disabled while no runtime is selected.
|
|
expect(
|
|
within(dialog).getByRole("button", { name: /connect & continue/i }),
|
|
).toBeDisabled();
|
|
});
|
|
|
|
it("CLI dialog with a selected runtime: Connect enables and fires onNext(runtime)", async () => {
|
|
const rt = makeRuntime({ id: "rt_claude", name: "Claude Code" });
|
|
resetPicker({
|
|
runtimes: [rt],
|
|
selected: rt,
|
|
selectedId: rt.id,
|
|
hasRuntimes: true,
|
|
});
|
|
const user = userEvent.setup();
|
|
const { onNext } = renderFork();
|
|
|
|
await user.click(screen.getByRole("button", { name: /show steps/i }));
|
|
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(within(dialog).getByText(/1 computer connected/i)).toBeInTheDocument();
|
|
expect(
|
|
within(dialog).getByText(/selected: claude code/i),
|
|
).toBeInTheDocument();
|
|
|
|
const connect = within(dialog).getByRole("button", {
|
|
name: /connect & continue/i,
|
|
});
|
|
expect(connect).toBeEnabled();
|
|
await user.click(connect);
|
|
expect(onNext).toHaveBeenCalledTimes(1);
|
|
expect(onNext).toHaveBeenCalledWith(rt);
|
|
});
|
|
|
|
});
|