{
if (!selected) onSelect();
diff --git a/packages/views/onboarding/onboarding-flow.tsx b/packages/views/onboarding/onboarding-flow.tsx
index 3c19f1fb8..30d943645 100644
--- a/packages/views/onboarding/onboarding-flow.tsx
+++ b/packages/views/onboarding/onboarding-flow.tsx
@@ -1,22 +1,20 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { captureEvent } from "@multica/core/analytics";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import {
- bootstrapNoRuntimeOnboarding,
- bootstrapRuntimeOnboarding,
completeOnboarding,
ONBOARDING_STEP_ORDER,
saveQuestionnaire,
+ useWelcomeStore,
type OnboardingStep,
type QuestionnaireAnswers,
} from "@multica/core/onboarding";
-import { issueKeys } from "@multica/core/issues/queries";
-import { workspaceListOptions, workspaceKeys } from "@multica/core/workspace/queries";
+import { workspaceListOptions } from "@multica/core/workspace/queries";
import type { AgentRuntime, Workspace } from "@multica/core/types";
import { StepWelcome } from "./steps/step-welcome";
import { StepSource } from "./steps/step-source";
@@ -25,22 +23,38 @@ import { StepUseCase } from "./steps/step-use-case";
import { StepWorkspace } from "./steps/step-workspace";
import { StepRuntimeConnect } from "./steps/step-runtime-connect";
import { StepPlatformFork } from "./steps/step-platform-fork";
-import { StepTeammate } from "./steps/step-teammate";
import { useT } from "../i18n";
const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
- source: null,
+ source: [],
source_other: null,
source_skipped: false,
role: null,
role_other: null,
role_skipped: false,
- use_case: null,
+ use_case: [],
use_case_other: null,
use_case_skipped: false,
version: 2,
};
+/**
+ * Coerce a stored questionnaire slot into the array shape used by the
+ * current UI. Earlier versions of this app wrote `source` / `use_case`
+ * as a single string; tolerate that on read so a user who started
+ * onboarding before this change doesn't see their previous answer
+ * disappear on re-entry. Empty string and null both collapse to [].
+ */
+function coerceToArray
(value: unknown): T[] {
+ if (Array.isArray(value)) {
+ return value.filter((v): v is T => typeof v === "string" && v.length > 0);
+ }
+ if (typeof value === "string" && value.length > 0) {
+ return [value as T];
+ }
+ return [];
+}
+
/**
* Merge persisted answers into the empty default. Re-entry pre-fills
* answered slots but treats `*_skipped` as fresh (the user can answer
@@ -50,22 +64,44 @@ const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
function mergeQuestionnaire(
raw: Record,
): QuestionnaireAnswers {
- const merged = { ...EMPTY_QUESTIONNAIRE, ...(raw as Partial) };
+ const merged = {
+ ...EMPTY_QUESTIONNAIRE,
+ ...(raw as Partial),
+ };
return {
...merged,
+ source: coerceToArray(raw.source),
+ use_case: coerceToArray(
+ raw.use_case,
+ ),
source_skipped: false,
role_skipped: false,
use_case_skipped: false,
};
}
+/**
/**
* Shell's onComplete contract:
* onComplete(workspace?, issueId?) — if an issue id is present, navigate
* straight into that onboarding issue; otherwise navigate into the
- * workspace issues list. Runtime-connected onboarding creates one
- * Multica Helper agent plus one issue; runtime-skipped onboarding creates one
- * self-serve install-runtime issue.
+ * workspace issues list.
+ *
+ * Three exit shapes feed onComplete:
+ * - Skip-existing (Welcome): completeOnboarding marks onboarded; navigate
+ * to the existing workspace's issue list.
+ * - Runtime-skipped (no runtime on Step 3): completeOnboarding marks
+ * onboarded; we push a {choice:"skip"} welcome signal and navigate
+ * to the workspace. The welcome hook in the workspace shell creates
+ * the install-runtime / create-agent guide issues on landing.
+ * - Runtime-connected (runtime picked on Step 3): completeOnboarding
+ * marks onboarded; we push a {choice:"runtime", runtimeId} welcome
+ * signal and navigate. The welcome hook creates the Multica Helper
+ * agent on the picked runtime and shows the starter-card Modal.
+ *
+ * V3 contract: this file never touches createAgent / createIssue. The
+ * "what runs in the workspace shell after onboarding" decision is in
+ * `packages/views/workspace/welcome-after-onboarding.tsx`.
*/
export function OnboardingFlow({
onComplete,
@@ -93,11 +129,8 @@ export function OnboardingFlow({
const storedQuestionnaire = mergeQuestionnaire(user.onboarding_questionnaire);
const [answers, setAnswers] = useState(storedQuestionnaire);
- const qc = useQueryClient();
-
const [step, setStep] = useState("welcome");
const [workspace, setWorkspace] = useState(null);
- const [runtime, setRuntime] = useState(null);
// Fetched at Step 0 + Step 2. Step 2 uses it to detect a pre-existing
// workspace from an earlier abandoned onboarding (so StepWorkspace shows
@@ -200,48 +233,35 @@ export function OnboardingFlow({
const handleRuntimeNext = useCallback(
async (rt: AgentRuntime | null) => {
if (!workspace) return;
- if (!rt) {
- // No runtime -> no agent execution yet. Create one focused
- // install-runtime onboarding issue so the user lands on a
- // concrete next step.
- try {
- const result = await bootstrapNoRuntimeOnboarding(workspace.id);
- await qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) });
- onComplete(workspace, result.issue_id || undefined);
- } catch (err) {
- toast.error(
- err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
- );
- }
+ // Step 3 in v3 does exactly two things:
+ // 1. Mark onboarded server-side (the workspace layout hard gate
+ // will redirect us back to /onboarding without this).
+ // 2. Park a transient welcome signal for the workspace shell to
+ // consume on the next render, telling it what the user chose.
+ // Helper-agent creation and starter-issue creation happen in the
+ // workspace shell's welcome hook, AFTER navigation, via the generic
+ // createAgent / createIssue endpoints.
+ try {
+ await completeOnboarding(
+ rt ? "full" : "runtime_skipped",
+ workspace.id,
+ );
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
+ );
return;
}
-
- setRuntime(rt);
- advanceFrom("runtime");
+ useWelcomeStore.getState().set({
+ workspaceId: workspace.id,
+ choice: rt ? "runtime" : "skip",
+ ...(rt ? { runtimeId: rt.id } : {}),
+ });
+ onComplete(workspace, undefined);
},
- [workspace, qc, onComplete, t, advanceFrom],
+ [workspace, onComplete, t],
);
- const handleCreateTeammate = useCallback(async () => {
- if (!workspace || !runtime) return;
-
- try {
- const result = await bootstrapRuntimeOnboarding(workspace.id, runtime.id);
- await Promise.all([
- qc.invalidateQueries({ queryKey: workspaceKeys.agents(workspace.id) }),
- qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }),
- ]);
- onComplete(workspace, result.issue_id || undefined);
- } catch (err) {
- toast.error(
- err instanceof Error
- ? err.message
- : t(($) => $.step_teammate.create_failed),
- );
- throw err;
- }
- }, [workspace, runtime, qc, onComplete, t]);
-
const handleBack = useCallback((from: OnboardingStep) => {
const idx = ONBOARDING_STEP_ORDER.indexOf(from);
if (idx <= 0) {
@@ -339,16 +359,6 @@ export function OnboardingFlow({
);
}
- if (step === "teammate" && workspace && runtime) {
- return (
- handleBack("teammate")}
- />
- );
- }
-
return null;
}
diff --git a/packages/views/onboarding/steps/step-platform-fork.test.tsx b/packages/views/onboarding/steps/step-platform-fork.test.tsx
index bd1a08814..6799ceb2e 100644
--- a/packages/views/onboarding/steps/step-platform-fork.test.tsx
+++ b/packages/views/onboarding/steps/step-platform-fork.test.tsx
@@ -143,9 +143,9 @@ describe("StepPlatformFork", () => {
expect(
within(dialog).getByText(/waiting for your computer/i),
).toBeInTheDocument();
- // Connect & continue stays disabled while no runtime is selected.
+ // Start exploring stays disabled while no runtime is selected.
expect(
- within(dialog).getByRole("button", { name: /connect & continue/i }),
+ within(dialog).getByRole("button", { name: /start exploring/i }),
).toBeDisabled();
});
@@ -169,7 +169,7 @@ describe("StepPlatformFork", () => {
).toBeInTheDocument();
const connect = within(dialog).getByRole("button", {
- name: /connect & continue/i,
+ name: /start exploring/i,
});
expect(connect).toBeEnabled();
await user.click(connect);
diff --git a/packages/views/onboarding/steps/step-platform-fork.tsx b/packages/views/onboarding/steps/step-platform-fork.tsx
index 0af90e9a7..e04f84729 100644
--- a/packages/views/onboarding/steps/step-platform-fork.tsx
+++ b/packages/views/onboarding/steps/step-platform-fork.tsx
@@ -418,7 +418,7 @@ function CliInstallDialog({
{t(($) => $.common.cancel)}
diff --git a/packages/views/onboarding/steps/step-question.test.tsx b/packages/views/onboarding/steps/step-question.test.tsx
index ee92dfb30..498137edf 100644
--- a/packages/views/onboarding/steps/step-question.test.tsx
+++ b/packages/views/onboarding/steps/step-question.test.tsx
@@ -27,7 +27,7 @@ function renderShell(overrides: Partial