diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 540bf7ea7..247f3f1d9 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -120,17 +120,21 @@ function AppContent() { // Pre-workspace overlay routing for desktop. Mirrors the web entry-point // judgment in callback / login: - // un-onboarded: + // wsCount > 0 → no overlay, fall through to dashboard + // (workspace-layer OnboardingHelperModal + // handles un-onboarded mid-flow users) + // un-onboarded + no workspace: // pending invites on email → /invitations overlay // no invites → /onboarding overlay - // already onboarded: - // zero workspaces → /workspaces/new overlay - // ≥1 workspaces → no overlay, fall through to dashboard + // onboarded + no workspace → /workspaces/new overlay // - // The "un-onboarded but in workspace" state is now physically impossible - // because backend transactions atomically set onboarded_at when a user - // joins the `member` table. Anyone with workspaces is by definition - // onboarded. + // "un-onboarded but in workspace" is now a valid mid-flow state: + // CreateWorkspace no longer marks onboarded — only BootstrapOnboardingRuntime + // does, via the workspace OnboardingHelperModal that fires inside the + // workspace shell. AcceptInvitation still marks (invitee path skips the + // modal). The `wsCount > 0 → no overlay` rule covers this: if the user + // has at least one workspace, we fall through to the dashboard and the + // workspace shell mounts the modal if needed. useEffect(() => { if (!user || !workspaceListFetched) return undefined; const { overlay, open } = useWindowOverlayStore.getState(); diff --git a/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx b/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx index 27f9021eb..574bb58e6 100644 --- a/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx +++ b/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx @@ -9,6 +9,7 @@ import { import { setCurrentWorkspace } from "@multica/core/platform"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen"; +import { OnboardingHelperModal } from "@multica/views/workspace/onboarding-helper-modal"; import { WorkspacePresencePrefetch } from "@multica/views/layout"; import { useTabStore } from "@/stores/tab-store"; @@ -85,6 +86,10 @@ export function WorkspaceRouteLayout() { + {/* Blocking modal that fires once for new users when they reach the + * workspace before completing Multica Helper setup. Self-gates on + * `me.onboarded_at == null` — renders null otherwise. */} + ); } diff --git a/apps/web/app/[workspaceSlug]/layout.tsx b/apps/web/app/[workspaceSlug]/layout.tsx index c9446c7ca..c85a954e2 100644 --- a/apps/web/app/[workspaceSlug]/layout.tsx +++ b/apps/web/app/[workspaceSlug]/layout.tsx @@ -8,6 +8,7 @@ import { workspaceBySlugOptions } from "@multica/core/workspace"; import { setCurrentWorkspace } from "@multica/core/platform"; import { useAuthStore } from "@multica/core/auth"; import { NoAccessPage } from "@multica/views/workspace/no-access-page"; +import { OnboardingHelperModal } from "@multica/views/workspace/onboarding-helper-modal"; import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen"; @@ -86,6 +87,10 @@ export default function WorkspaceLayout({ return ( {children} + {/* Blocking modal that fires once for new users when they reach the + * workspace before completing Multica Helper setup. Self-gates on + * `me.onboarded_at == null` — renders null otherwise. */} + ); } diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 7052db040..62851a03f 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -402,6 +402,10 @@ export class ApiClient { async bootstrapOnboardingRuntime(payload: { workspace_id: string; runtime_id: string; + /** Optional: user's chosen first task from the workspace + * OnboardingHelperModal. Becomes the seeded onboarding issue's + * description; server falls back to a generic message when omitted. */ + starter_prompt?: string; }): Promise { const raw = await this.fetch( "/api/me/onboarding/runtime-bootstrap", diff --git a/packages/core/onboarding/step-order.ts b/packages/core/onboarding/step-order.ts index 206f975de..b2d66fb27 100644 --- a/packages/core/onboarding/step-order.ts +++ b/packages/core/onboarding/step-order.ts @@ -13,6 +13,11 @@ import type { OnboardingStep } from "./types"; * intro, not a persisted step. It doesn't show a progress indicator * for the same reason — users shouldn't think of reading the intro * as progress toward completing setup. + * + * Note: "teammate" (the old "Create your first agent" step) is no longer + * part of the in-flow sequence. Helper agent creation now happens after + * onboarding exits, via the workspace OnboardingHelperModal — see + * `packages/views/workspace/onboarding-helper-modal.tsx`. */ export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [ "source", @@ -20,5 +25,4 @@ export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [ "use_case", "workspace", "runtime", - "teammate", ] as const; diff --git a/packages/core/onboarding/store.ts b/packages/core/onboarding/store.ts index abc4a9dc9..bb0c46ece 100644 --- a/packages/core/onboarding/store.ts +++ b/packages/core/onboarding/store.ts @@ -57,14 +57,21 @@ export async function completeOnboarding( * Runtime-connected onboarding path. The server creates or reuses the * default Multica Helper agent and the single onboarding issue, then * marks onboarding complete. + * + * `starterPrompt` is the user's chosen first task (one of the three + * cards in the workspace OnboardingHelperModal). When provided, it + * becomes the seeded onboarding issue's description; when omitted + * (legacy callers), the server uses a generic fallback description. */ export async function bootstrapRuntimeOnboarding( workspaceId: string, runtimeId: string, + starterPrompt?: string, ): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> { const result = await api.bootstrapOnboardingRuntime({ workspace_id: workspaceId, runtime_id: runtimeId, + ...(starterPrompt ? { starter_prompt: starterPrompt } : {}), }); await useAuthStore.getState().refreshMe(); return result; diff --git a/packages/core/onboarding/types.ts b/packages/core/onboarding/types.ts index 2028f6337..bf6539d5f 100644 --- a/packages/core/onboarding/types.ts +++ b/packages/core/onboarding/types.ts @@ -5,7 +5,6 @@ export type OnboardingStep = | "use_case" | "workspace" | "runtime" - | "teammate" | "agent" | "first_issue"; diff --git a/packages/core/paths/resolve.test.ts b/packages/core/paths/resolve.test.ts index 33072dbb4..6638ce825 100644 --- a/packages/core/paths/resolve.test.ts +++ b/packages/core/paths/resolve.test.ts @@ -19,26 +19,30 @@ function makeWs(slug: string): Workspace { } describe("resolvePostAuthDestination", () => { - it("!onboarded → /onboarding regardless of workspace count", () => { - // Un-onboarded users are routed back to the onboarding flow. The - // "un-onboarded but in workspace" state is now physically impossible - // (backend invariant + migration 065 backfill), but the resolver still - // does the right thing if it ever appears: send the user to onboarding - // rather than dropping them into a workspace with `onboarded_at` null. - expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding()); - expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe( - paths.onboarding(), - ); - }); - - it("onboarded + has workspace → //issues", () => { + it("has workspace → //issues (regardless of onboarded)", () => { + // Workspace-presence is now the primary axis. A user who created a + // workspace but hasn't completed the OnboardingHelperModal (so + // `onboarded_at` is still NULL) lands directly in the workspace — + // the modal there picks up the un-finished setup. Routing them to + // /onboarding instead would loop them through Welcome / questionnaire + // they already completed. const ws = [makeWs("acme"), makeWs("beta")]; expect(resolvePostAuthDestination(ws, true)).toBe( paths.workspace("acme").issues(), ); + expect(resolvePostAuthDestination(ws, false)).toBe( + paths.workspace("acme").issues(), + ); }); - it("onboarded + zero workspaces → /workspaces/new", () => { + it("no workspace + !onboarded → /onboarding", () => { + expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding()); + }); + + it("no workspace + onboarded → /workspaces/new", () => { + // Already-onboarded user without any workspace — usually a returning + // user whose last workspace got deleted or who left it. They skip + // re-onboarding and go straight to workspace creation. expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace()); }); }); diff --git a/packages/core/paths/resolve.ts b/packages/core/paths/resolve.ts index 69cf41c1d..7c794d40d 100644 --- a/packages/core/paths/resolve.ts +++ b/packages/core/paths/resolve.ts @@ -3,17 +3,24 @@ import { useAuthStore } from "../auth"; import { paths } from "./paths"; /** - * Priority: - * !hasOnboarded → /onboarding - * hasOnboarded && has workspace → //issues - * hasOnboarded && zero workspaces → /workspaces/new + * Priority (workspace-presence first): + * has workspace → //issues (modal handles un-onboarded) + * no workspace + !hasOnboarded → /onboarding + * no workspace + hasOnboarded → /workspaces/new * - * `onboarded_at` is the single source of truth for whether the user has - * passed first-contact. Backend transactions (CreateWorkspace, - * AcceptInvitation) atomically set this field whenever a user joins a - * `member` row, so "has workspace but !onboarded" is now a - * physically impossible state — see migration 065 for the existing-data - * backfill that closed the door retroactively. + * Why workspace-presence is the primary axis (not `onboarded_at`): + * `CreateWorkspace` no longer marks the user as onboarded — that mark + * is reserved for the `BootstrapOnboardingRuntime` path that creates + * the user's first Multica Helper (triggered by the workspace + * `OnboardingHelperModal`). So a user can legitimately land in the + * "has workspace but !onboarded" mid-flow state. Routing them back to + * /onboarding would loop them through Welcome → questionnaire steps + * they've already completed; routing them to the workspace lets the + * blocking modal pick up exactly where they left off (the modal stays + * up until `onboarded_at != null`). + * + * `AcceptInvitation` still marks onboarded — invitees skip the helper + * modal entirely, so they hit `hasOnboarded` true and route normally. * * Callers that need invitation-aware routing (callback / login) handle the * "un-onboarded with pending invites" branch themselves before calling @@ -24,13 +31,15 @@ export function resolvePostAuthDestination( workspaces: Workspace[], hasOnboarded: boolean, ): string { - if (!hasOnboarded) { - return paths.onboarding(); - } const first = workspaces[0]; if (first) { + // Workspace exists → land in it regardless of onboarded status. + // The workspace-layer OnboardingHelperModal will fire if needed. return paths.workspace(first.slug).issues(); } + if (!hasOnboarded) { + return paths.onboarding(); + } return paths.newWorkspace(); } diff --git a/packages/views/layout/use-dashboard-guard.ts b/packages/views/layout/use-dashboard-guard.ts index 40afcc5b3..3185ac556 100644 --- a/packages/views/layout/use-dashboard-guard.ts +++ b/packages/views/layout/use-dashboard-guard.ts @@ -22,15 +22,22 @@ import { useNavigation } from "../navigation"; * - Not logged in → /login * - Logged in but workspace list not yet loaded → wait (don't bounce prematurely) * - Logged in but URL slug doesn't resolve to any workspace → - * `resolvePostAuthDestination(list, hasOnboarded)`: - * • un-onboarded → /onboarding - * • onboarded with workspaces → first workspace - * • onboarded with zero workspaces → /workspaces/new + * `resolvePostAuthDestination(list, hasOnboarded)` (workspace-presence first; + * see paths/resolve.ts for the full table) * - * The "un-onboarded but in workspace" state is now physically impossible: - * CreateWorkspace and AcceptInvitation both atomically set `onboarded_at` - * inside the same transaction that inserts the `member` row. - * Existing dirty rows from PR #1868 are cleaned by migration 065. + * The "un-onboarded but in workspace" state IS valid now — it's the + * mid-flow window between "user picked a runtime on the onboarding screen + * and got dropped into the workspace" and "user picked a starter prompt in + * the workspace OnboardingHelperModal, which fires BootstrapOnboardingRuntime + * and marks onboarded". This guard deliberately does NOT redirect that + * state out: it only redirects when the URL slug doesn't resolve, + * regardless of onboarded. The blocking modal inside the workspace shell + * handles completion. + * + * (Older comment claimed this state was physically impossible because + * CreateWorkspace and AcceptInvitation atomically marked onboarded. + * CreateWorkspace no longer marks; AcceptInvitation still does — invitees + * skip the modal entirely.) * * We read the workspace list query state directly (rather than relying on * useCurrentWorkspace's null return) so we can distinguish "list loading" diff --git a/packages/views/locales/en/onboarding.json b/packages/views/locales/en/onboarding.json index 11b356b01..748211156 100644 --- a/packages/views/locales/en/onboarding.json +++ b/packages/views/locales/en/onboarding.json @@ -179,19 +179,28 @@ "add_more_hint": "Add more agents anytime. A small team of specialized agents beats one jack-of-all-trades.", "docs_link": "Creating your first agent →" }, - "step_teammate": { - "eyebrow": "First teammate", - "headline": "Create your first teammate: Multica Helper", - "lede": "Multica Helper is a starter agent connected to {{runtime}}. It will open your first guided issue and help you learn the core workflow.", - "avatar_label": "Multica Helper avatar", - "name": "Multica Helper", - "role": "Onboarding guide", - "point_issue": "Starts from one onboarding issue", - "point_context": "Uses your workspace context and issue details", - "point_customize": "You can rename or replace it later", - "footer_hint": "We'll create one agent and one onboarding issue.", - "create_action": "Create Multica Helper", - "create_failed": "Failed to create Multica Helper" + "onboarding_helper_modal": { + "title": "Meet Multica Helper", + "subtitle": "Your first agent in this workspace. Pick something to assign as your first task — Helper will pick it up and reply on the issue.", + "cards": { + "intro": { + "title": "Introduce me to Multica", + "subtitle": "Have Helper walk you through what Multica is and how to work with agents.", + "prompt": "Introduce me to Multica. Explain what this workspace is, what agents do here, and what I should try first." + }, + "assign": { + "title": "Show me how to assign an issue", + "subtitle": "Helper will demonstrate assignees, @mentions, and the issue thread by walking through this one.", + "prompt": "Show me how to assign an issue to an agent and how @mentions work. Use this issue as the worked example." + }, + "second_agent": { + "title": "Help me create a second agent", + "subtitle": "Watch Helper spin up a new teammate from a template using the multica CLI.", + "prompt": "Help me create a second agent in this workspace. Ask me what kind of agent I want, then create it for me using the multica CLI." + } + }, + "dismiss_error": "Dismiss", + "error_generic": "Could not set up Multica Helper. Check your connection and try again." }, "step_workspace": { "eyebrow_first": "Your first workspace", @@ -284,7 +293,8 @@ "hint_selected": "Selected: {{name}}", "hint_pick": "Pick an agent runtime above to continue.", "hint_waiting": "Waiting for the first result…", - "hint_skip_or_refresh": "Refresh after installing one, or skip to enter your workspace." + "hint_skip_or_refresh": "Refresh after installing one, or skip to enter your workspace.", + "start_exploring": "Start exploring" }, "step_platform": { "eyebrow": "Connect a computer", diff --git a/packages/views/locales/zh-Hans/onboarding.json b/packages/views/locales/zh-Hans/onboarding.json index 3a3593471..d83e5b7db 100644 --- a/packages/views/locales/zh-Hans/onboarding.json +++ b/packages/views/locales/zh-Hans/onboarding.json @@ -179,19 +179,28 @@ "add_more_hint": "随时添加更多智能体。一支专长各异的小团队胜过一个万能选手。", "docs_link": "创建第一个智能体 →" }, - "step_teammate": { - "eyebrow": "第一个队友", - "headline": "创建你的第一个队友:Multica Helper", - "lede": "Multica Helper 是连接到 {{runtime}} 的入门智能体。它会打开一个上手引导 issue,带你走完核心流程。", - "avatar_label": "Multica Helper 头像", - "name": "Multica Helper", - "role": "上手引导", - "point_issue": "只从一个上手引导 issue 开始", - "point_context": "读取工作区上下文和 issue 详情", - "point_customize": "之后可以改名或替换", - "footer_hint": "我们会创建一个智能体和一个上手引导 issue。", - "create_action": "创建 Multica Helper", - "create_failed": "创建 Multica Helper 失败" + "onboarding_helper_modal": { + "title": "认识 Multica Helper", + "subtitle": "你在这个工作区的第一个 agent。挑一件事交给它当作第一个任务——它会在 issue 里接手并回复你。", + "cards": { + "intro": { + "title": "介绍一下 Multica", + "subtitle": "让 Helper 给你讲讲 Multica 是什么,以及怎么和 agent 协作。", + "prompt": "给我介绍一下 Multica。讲讲这个工作区是什么、agent 在这里能做什么、我应该先试什么。" + }, + "assign": { + "title": "教我怎么把 issue 派给 agent", + "subtitle": "Helper 会用这个 issue 当例子,演示 assignee、@mention 和评论串怎么用。", + "prompt": "教我怎么把一个 issue 派给 agent,还有 @mention 是怎么工作的。直接用这个 issue 当例子讲清楚。" + }, + "second_agent": { + "title": "帮我再创建一个 agent", + "subtitle": "看 Helper 用 multica CLI 从模板创建一个新队友。", + "prompt": "帮我在这个工作区再创建一个 agent。先问我想要什么类型的 agent,然后用 multica CLI 帮我建出来。" + } + }, + "dismiss_error": "知道了", + "error_generic": "无法创建 Multica Helper,请检查网络后重试。" }, "step_workspace": { "eyebrow_first": "你的第一个工作区", @@ -283,7 +292,8 @@ "hint_selected": "已选择:{{name}}", "hint_pick": "在上方选一个 agent 运行时继续。", "hint_waiting": "等待第一个结果...", - "hint_skip_or_refresh": "装好后点刷新,或跳过进入工作区。" + "hint_skip_or_refresh": "装好后点刷新,或跳过进入工作区。", + "start_exploring": "开始探索" }, "step_platform": { "eyebrow": "连接一台电脑", diff --git a/packages/views/onboarding/onboarding-flow.tsx b/packages/views/onboarding/onboarding-flow.tsx index 3c19f1fb8..d8f72b8d9 100644 --- a/packages/views/onboarding/onboarding-flow.tsx +++ b/packages/views/onboarding/onboarding-flow.tsx @@ -8,7 +8,6 @@ import { setCurrentWorkspace } from "@multica/core/platform"; import { useAuthStore } from "@multica/core/auth"; import { bootstrapNoRuntimeOnboarding, - bootstrapRuntimeOnboarding, completeOnboarding, ONBOARDING_STEP_ORDER, saveQuestionnaire, @@ -16,7 +15,7 @@ import { 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,7 +24,6 @@ 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 = { @@ -63,9 +61,18 @@ function mergeQuestionnaire( * 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): bootstrapNoRuntimeOnboarding + * marks onboarded + seeds install-runtime issue; navigate to that issue. + * - Runtime-connected (runtime picked on Step 3): NO bootstrap call — + * just store the runtime and navigate to the workspace. The user + * hits the workspace OnboardingHelperModal (which gates on + * `me.onboarded_at == null`); selecting a starter prompt there is what + * finally calls bootstrapRuntimeOnboarding + marks onboarded. */ export function OnboardingFlow({ onComplete, @@ -97,7 +104,6 @@ export function OnboardingFlow({ 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 @@ -203,7 +209,9 @@ export function OnboardingFlow({ if (!rt) { // No runtime -> no agent execution yet. Create one focused // install-runtime onboarding issue so the user lands on a - // concrete next step. + // concrete next step. (This path DOES mark onboarded — the user + // explicitly opted out of the runtime branch, so the workspace + // OnboardingHelperModal should not pop for them either.) try { const result = await bootstrapNoRuntimeOnboarding(workspace.id); await qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }); @@ -216,32 +224,19 @@ export function OnboardingFlow({ return; } - setRuntime(rt); - advanceFrom("runtime"); + // Runtime picked. Exit the onboarding flow into the workspace + // WITHOUT calling bootstrapRuntimeOnboarding — `onboarded_at` stays + // NULL so the workspace OnboardingHelperModal fires there. The + // modal owns the actual Helper-creation step (with a starter + // prompt the user picks from 3 cards), and fetches the workspace + // runtime list itself to know which runtime to bootstrap against + // — we don't need to thread `rt` through onComplete. + void rt; + onComplete(workspace, undefined); }, - [workspace, qc, onComplete, t, advanceFrom], + [workspace, qc, 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 +334,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-runtime-connect.tsx b/packages/views/onboarding/steps/step-runtime-connect.tsx index bc5ba30dd..9db0b6126 100644 --- a/packages/views/onboarding/steps/step-runtime-connect.tsx +++ b/packages/views/onboarding/steps/step-runtime-connect.tsx @@ -307,7 +307,7 @@ function FancyView({ onClick={handleContinue} > {submitting && } - {t(($) => $.common.continue)} + {t(($) => $.step_runtime.start_exploring)} diff --git a/packages/views/onboarding/steps/step-teammate.tsx b/packages/views/onboarding/steps/step-teammate.tsx deleted file mode 100644 index 9a6704eee..000000000 --- a/packages/views/onboarding/steps/step-teammate.tsx +++ /dev/null @@ -1,186 +0,0 @@ -"use client"; - -import { useRef, useState, type ComponentType } from "react"; -import { - ArrowLeft, - ArrowRight, - CheckCircle2, - Loader2, - MessageSquareText, - Settings2, - Sparkles, -} from "lucide-react"; -import { Button } from "@multica/ui/components/ui/button"; -import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; -import { cn } from "@multica/ui/lib/utils"; -import type { AgentRuntime } from "@multica/core/types"; -import { DragStrip } from "@multica/views/platform"; -import { StepHeader } from "../components/step-header"; -import { RuntimeAsidePanel } from "../components/runtime-aside-panel"; -import { ProviderLogo } from "../../runtimes/components/provider-logo"; -import { useT } from "../../i18n"; - -const MULTICA_HELPER_AVATAR_URL = - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E"; - -export function StepTeammate({ - runtime, - onCreate, - onBack, -}: { - runtime: AgentRuntime; - onCreate: () => void | Promise; - onBack?: () => void; -}) { - const { t } = useT("onboarding"); - const mainRef = useRef(null); - const fadeStyle = useScrollFade(mainRef); - const [creating, setCreating] = useState(false); - - const handleCreate = async () => { - if (creating) return; - setCreating(true); - try { - await onCreate(); - } catch { - // The parent owns the toast. This step only restores the button state. - } finally { - setCreating(false); - } - }; - - return ( -
-
- - -
- {onBack ? ( - - ) : ( - - )} -
- -
-
- -
-
-
- {t(($) => $.step_teammate.eyebrow)} -
-

- {t(($) => $.step_teammate.headline)} -

-

- {t(($) => $.step_teammate.lede, { runtime: runtime.name })} -

- -
-
- {t(($) $.step_teammate.avatar_label)} - className="h-24 w-24 shrink-0 rounded-2xl shadow-sm" - /> -
-
-

- {t(($) => $.step_teammate.name)} -

- - {t(($) => $.step_teammate.role)} - -
-
- - {runtime.name} - -
-
-
- -
- $.step_teammate.point_issue)} - /> - $.step_teammate.point_context)} - /> - $.step_teammate.point_customize)} - /> -
-
- -
- - {t(($) => $.step_teammate.footer_hint)} - - -
-
-
-
- - -
- ); -} - -function TeammatePoint({ - icon: Icon, - label, -}: { - icon: ComponentType<{ className?: string }>; - label: string; -}) { - return ( -
-
- -
-

{label}

-
- ); -} diff --git a/packages/views/package.json b/packages/views/package.json index 4aaa06ee3..5b97fdbff 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -35,6 +35,7 @@ "./workspace/create-workspace-form": "./workspace/create-workspace-form.tsx", "./workspace/no-access-page": "./workspace/no-access-page.tsx", "./workspace/new-workspace-page": "./workspace/new-workspace-page.tsx", + "./workspace/onboarding-helper-modal": "./workspace/onboarding-helper-modal.tsx", "./workspace/use-workspace-seen": "./workspace/use-workspace-seen.ts", "./layout": "./layout/index.ts", "./auth": "./auth/index.ts", diff --git a/packages/views/workspace/onboarding-helper-modal.test.tsx b/packages/views/workspace/onboarding-helper-modal.test.tsx new file mode 100644 index 000000000..dc20bc687 --- /dev/null +++ b/packages/views/workspace/onboarding-helper-modal.test.tsx @@ -0,0 +1,277 @@ +import type { ReactNode } from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../locales/en/common.json"; +import enOnboarding from "../locales/en/onboarding.json"; + +// --- Hoisted mock refs (the rendered component pulls values from these +// each render, so test cases can mutate them between renders to flip +// the modal's gate conditions without re-mounting the QueryClient.) --- +const userRef = vi.hoisted(() => ({ + current: null as { + id: string; + onboarded_at: string | null; + } | null, +})); +const workspaceRef = vi.hoisted(() => ({ + current: null as { id: string; slug: string } | null, +})); +const runtimesRef = vi.hoisted(() => ({ + current: [] as Array<{ id: string; name: string }>, +})); +const mockBootstrap = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock("@multica/core/auth", async () => { + const actual = + await vi.importActual( + "@multica/core/auth", + ); + const useAuthStore = Object.assign( + (sel?: (s: { user: typeof userRef.current }) => unknown) => + sel ? sel({ user: userRef.current }) : { user: userRef.current }, + { getState: () => ({ user: userRef.current }) }, + ); + return { ...actual, useAuthStore }; +}); + +vi.mock("@multica/core/paths", async () => { + const actual = + await vi.importActual( + "@multica/core/paths", + ); + return { + ...actual, + useCurrentWorkspace: () => workspaceRef.current, + }; +}); + +vi.mock("@multica/core/onboarding", async () => { + const actual = + await vi.importActual( + "@multica/core/onboarding", + ); + return { + ...actual, + bootstrapRuntimeOnboarding: mockBootstrap, + }; +}); + +vi.mock("@multica/core/runtimes", () => ({ + // The component calls useQuery({ ...runtimeListOptions(wsId), enabled }) + // We don't run the real query — we synthesize a queryFn that returns + // whatever runtimesRef holds, so a test can flip the runtime list by + // mutating the ref. + runtimeListOptions: (wsId: string) => ({ + queryKey: ["runtimes", wsId], + queryFn: () => runtimesRef.current, + }), +})); + +vi.mock("../navigation", () => ({ + useNavigation: () => ({ push: mockNavigate, replace: mockNavigate }), +})); + +// Import AFTER the mocks so the component picks up the mocked modules. +import { OnboardingHelperModal } from "./onboarding-helper-modal"; + +const TEST_RESOURCES = { + en: { common: enCommon, onboarding: enOnboarding }, +}; + +function renderModal() { + // Disable retries so a thrown mockBootstrap surfaces immediately. + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +function Wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + + {children} + + + ); +} + +describe("OnboardingHelperModal — gating", () => { + beforeEach(() => { + userRef.current = null; + workspaceRef.current = null; + runtimesRef.current = []; + mockBootstrap.mockReset(); + mockNavigate.mockReset(); + }); + + it("renders nothing when user is not yet loaded", () => { + renderModal(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders nothing when user is already onboarded", () => { + userRef.current = { id: "u1", onboarded_at: "2026-05-01T00:00:00Z" }; + workspaceRef.current = { id: "w1", slug: "acme" }; + runtimesRef.current = [{ id: "r1", name: "local" }]; + renderModal(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders nothing when workspace is missing", () => { + userRef.current = { id: "u1", onboarded_at: null }; + workspaceRef.current = null; + runtimesRef.current = [{ id: "r1", name: "local" }]; + renderModal(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders nothing when workspace has no runtime yet", async () => { + // No runtime → modal can't bootstrap, so it stays hidden until the + // runtime list resolves with at least one entry. This guards against + // showing a dead modal when daemon disconnect drops the count to zero + // mid-session. + userRef.current = { id: "u1", onboarded_at: null }; + workspaceRef.current = { id: "w1", slug: "acme" }; + runtimesRef.current = []; + renderModal(); + // Give the query one tick to settle, then assert silence. + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders dialog when all four conditions hold", async () => { + userRef.current = { id: "u1", onboarded_at: null }; + workspaceRef.current = { id: "w1", slug: "acme" }; + runtimesRef.current = [{ id: "r1", name: "local" }]; + renderModal(); + expect( + await screen.findByText(/Meet Multica Helper/i), + ).toBeInTheDocument(); + }); +}); + +describe("OnboardingHelperModal — three starter cards", () => { + beforeEach(() => { + userRef.current = { id: "u1", onboarded_at: null }; + workspaceRef.current = { id: "w1", slug: "acme" }; + runtimesRef.current = [{ id: "r1", name: "local" }]; + mockBootstrap.mockReset(); + mockNavigate.mockReset(); + }); + + it("renders all three starter cards with their localized titles", async () => { + renderModal(); + await screen.findByText(/Meet Multica Helper/i); + // Card titles from packages/views/locales/en/onboarding.json + expect(screen.getByText(/Introduce me to Multica/i)).toBeInTheDocument(); + expect( + screen.getByText(/Show me how to assign an issue/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/Help me create a second agent/i), + ).toBeInTheDocument(); + }); + + it("clicking a card calls bootstrap with that card's prompt and navigates to the issue", async () => { + mockBootstrap.mockResolvedValue({ + workspace_id: "w1", + agent_id: "a1", + issue_id: "i1", + }); + renderModal(); + await screen.findByText(/Meet Multica Helper/i); + const user = userEvent.setup(); + await user.click( + screen.getByText(/Show me how to assign an issue/i).closest("button")!, + ); + await waitFor(() => expect(mockBootstrap).toHaveBeenCalledTimes(1)); + const [wsId, rtId, prompt] = mockBootstrap.mock.calls[0]!; + expect(wsId).toBe("w1"); + expect(rtId).toBe("r1"); + // The prompt text comes from the i18n value for cards.assign.prompt. + expect(prompt).toMatch(/assign.*issue.*agent/i); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1)); + // Navigation lands on the new onboarding issue's detail page. + expect(mockNavigate.mock.calls[0]![0]).toContain("/acme/issues/i1"); + }); + + it("disables the other cards while one card's mutation is in flight", async () => { + // Hold the promise open so all three cards see a pending state. + let resolve!: (v: { + workspace_id: string; + agent_id: string; + issue_id: string; + }) => void; + mockBootstrap.mockReturnValue( + new Promise<{ + workspace_id: string; + agent_id: string; + issue_id: string; + }>((r) => { + resolve = r; + }), + ); + renderModal(); + await screen.findByText(/Meet Multica Helper/i); + const user = userEvent.setup(); + const introButton = screen + .getByText(/Introduce me to Multica/i) + .closest("button")!; + const assignButton = screen + .getByText(/Show me how to assign an issue/i) + .closest("button")!; + await user.click(introButton); + // The other two cards are disabled while the first is in flight. + await waitFor(() => expect(assignButton).toBeDisabled()); + // Let the test finish gracefully. + resolve({ workspace_id: "w1", agent_id: "a1", issue_id: "i1" }); + }); + + it("surfaces an inline error message if bootstrap throws, modal stays open", async () => { + mockBootstrap.mockRejectedValue(new Error("backend exploded")); + renderModal(); + await screen.findByText(/Meet Multica Helper/i); + const user = userEvent.setup(); + await user.click( + screen.getByText(/Introduce me to Multica/i).closest("button")!, + ); + expect(await screen.findByText(/backend exploded/i)).toBeInTheDocument(); + // Modal is still rendered — the error didn't unmount the dialog. + expect(screen.getByText(/Meet Multica Helper/i)).toBeInTheDocument(); + // Navigation never fired. + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); + +// Sanity check: the dialog has no close button rendered (the modal is +// designed to be dismissed implicitly by `me.onboarded_at` flipping +// non-null after a successful bootstrap, not by user action). Failing +// this test means someone re-introduced a close button — `dismissible` +// behavior must be re-examined alongside that. +describe("OnboardingHelperModal — dismissibility", () => { + it("has no close button", async () => { + userRef.current = { id: "u1", onboarded_at: null }; + workspaceRef.current = { id: "w1", slug: "acme" }; + runtimesRef.current = [{ id: "r1", name: "local" }]; + render( + + + , + ); + await screen.findByText(/Meet Multica Helper/i); + // The shadcn DialogContent close button has data-slot="dialog-close". + expect( + document.querySelector("[data-slot='dialog-close']"), + ).toBeNull(); + }); +}); diff --git a/packages/views/workspace/onboarding-helper-modal.tsx b/packages/views/workspace/onboarding-helper-modal.tsx new file mode 100644 index 000000000..a0b1d1d07 --- /dev/null +++ b/packages/views/workspace/onboarding-helper-modal.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowRight, Loader2 } from "lucide-react"; +import { bootstrapRuntimeOnboarding } from "@multica/core/onboarding"; +import { runtimeListOptions } from "@multica/core/runtimes"; +import { issueKeys } from "@multica/core/issues/queries"; +import { workspaceKeys } from "@multica/core/workspace/queries"; +import { useAuthStore } from "@multica/core/auth"; +import { useCurrentWorkspace, paths } from "@multica/core/paths"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from "@multica/ui/components/ui/dialog"; +import { Button } from "@multica/ui/components/ui/button"; +import { cn } from "@multica/ui/lib/utils"; +import { useNavigation } from "../navigation"; +import { useT } from "../i18n"; + +/** + * Inline copy of the Multica Helper avatar SVG (kept in sync with the + * server-side constant `onboardingAssistantAvatarURL` in + * server/internal/handler/onboarding.go). We could fetch the agent record + * after bootstrap to get the real avatar URL, but at modal-render time + * the agent doesn't exist yet — this constant is the introduction. + */ +const HELPER_AVATAR_URL = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='t' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%2323242C'/%3E%3Cstop offset='100%25' stop-color='%2313141A'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='28' fill='url(%23t)'/%3E%3Cg stroke='%23FFFFFF' stroke-width='13' stroke-linecap='round'%3E%3Cline x1='64' y1='32' x2='64' y2='96'/%3E%3Cline x1='32' y1='64' x2='96' y2='64'/%3E%3Cline x1='41.4' y1='41.4' x2='86.6' y2='86.6'/%3E%3Cline x1='86.6' y1='41.4' x2='41.4' y2='86.6'/%3E%3C/g%3E%3C/svg%3E"; + +/** + * Three card ids — used as i18n lookup keys AND as the message body when + * we POST to bootstrap. The actual user-facing prompt text comes from the + * locale file (onboarding.json) keyed by id, so localization is centralized. + */ +const STARTER_CARD_IDS = ["intro", "assign", "second_agent"] as const; +type StarterCardId = (typeof STARTER_CARD_IDS)[number]; + +/** + * Blocking onboarding modal mounted inside the workspace shell. + * + * Trigger: `me.onboarded_at == null` AND a workspace is in context AND + * the workspace already has at least one runtime. Renders nothing in any + * other state. + * + * What it does on submit: + * 1. POST /api/me/onboarding/runtime-bootstrap with workspace_id, + * runtime_id, and the picked card's prompt as starter_prompt. + * 2. Backend single-tx creates Multica Helper + onboarding issue + * (description = starter_prompt) and marks the user onboarded. + * 3. bootstrapRuntimeOnboarding's internal refreshMe pulls the new + * user into Zustand — `me.onboarded_at` is now non-null, so this + * component returns null next render (no flicker). + * 4. We invalidate workspace.agents and issues query caches so the + * issue page renders Helper as assignee immediately. + * 5. Navigate into the seeded onboarding issue. + * + * Why "blocking": + * - No close button rendered. + * - `disablePointerDismissal` so outside-click can't dismiss. + * - `onOpenChange` is a no-op so Escape can't dismiss. + * - `me.onboarded_at == null` is the only way out; the user must + * pick a card and complete bootstrap. + * + * Why "no runtime" silently no-ops (rather than rendering an alt UI): + * - A user who skipped runtime on the onboarding screen takes the + * bootstrapNoRuntimeOnboarding path which sets onboarded_at — this + * modal won't fire for them. + * - A user who reached this point WITH a runtime then later lost it + * (e.g. daemon disconnect) shouldn't be stuck behind the modal. + * The condition gates on `runtimes[0]` so the modal hides while we + * wait for / re-establish a runtime. + */ +export function OnboardingHelperModal() { + const { t } = useT("onboarding"); + const me = useAuthStore((s) => s.user); + const workspace = useCurrentWorkspace(); + const wsId = workspace?.id ?? ""; + const runtimes = useQuery({ + ...runtimeListOptions(wsId), + enabled: !!wsId && me?.onboarded_at == null, + }); + const navigation = useNavigation(); + const qc = useQueryClient(); + + const [submittingId, setSubmittingId] = useState(null); + const [error, setError] = useState(null); + + if (!me || me.onboarded_at != null) return null; + if (!workspace) return null; + const runtime = runtimes.data?.[0]; + if (!runtime) return null; + + const handlePick = async (cardId: StarterCardId) => { + if (submittingId !== null) return; + setError(null); + setSubmittingId(cardId); + const prompt = t(($) => $.onboarding_helper_modal.cards[cardId].prompt); + try { + const result = await bootstrapRuntimeOnboarding( + workspace.id, + runtime.id, + prompt, + ); + // bootstrapRuntimeOnboarding internally refreshes the auth store, so + // `me.onboarded_at` is non-null by the time we get here — this + // component will unmount on the next render. We still invalidate + // agents + issues so the destination issue page renders the new + // Helper as assignee immediately rather than waiting for a WS event. + await Promise.all([ + qc.invalidateQueries({ queryKey: workspaceKeys.agents(workspace.id) }), + qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }), + ]); + if (result.issue_id) { + navigation.push(paths.workspace(workspace.slug).issueDetail(result.issue_id)); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : t(($) => $.onboarding_helper_modal.error_generic), + ); + setSubmittingId(null); + } + }; + + return ( + { + /* no-op */ + }} + > + +
+ {/* Avatar */} + + + {t(($) => $.onboarding_helper_modal.title)} + + + {t(($) => $.onboarding_helper_modal.subtitle)} + +
+ + {/* Cards */} +
+ {STARTER_CARD_IDS.map((id, idx) => { + const busy = submittingId === id; + const otherBusy = submittingId !== null && submittingId !== id; + return ( + + ); + })} +
+ + {error ? ( +
+

{error}

+ +
+ ) : null} +
+
+ ); +} diff --git a/server/internal/handler/invitation.go b/server/internal/handler/invitation.go index 02ccfe013..b5df2095b 100644 --- a/server/internal/handler/invitation.go +++ b/server/internal/handler/invitation.go @@ -426,9 +426,19 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { return } - // Accepting an invite is the physical event that "completes" onboarding for an - // invitee — atomic with CreateMember so the invariant - // "member row exists ↔ onboarded_at != null" cannot be violated. + // Accepting an invite marks the invitee as onboarded — they are + // joining an existing workspace someone else set up, so the workspace + // OnboardingHelperModal must NOT fire for them. (CreateWorkspace no + // longer marks onboarded — the workspace owner's mark happens later + // in BootstrapOnboardingRuntime when they pick a starter task via the + // modal — but invitees skip that whole flow.) + // + // DO NOT REMOVE this MarkUserOnboarded call without simultaneously + // updating the OnboardingHelperModal trigger in packages/views/workspace/ + // to also exclude invited-only members. The current trigger is purely + // `me.onboarded_at == null`, so dropping this mark would pop the modal + // in front of an invitee in someone else's workspace. + // // COALESCE in MarkUserOnboarded keeps this idempotent for users joining // additional workspaces after their first. firstOnboardingCompletion := !user.OnboardedAt.Valid diff --git a/server/internal/handler/onboarding.go b/server/internal/handler/onboarding.go index 0116ed75b..c20dc4a0d 100644 --- a/server/internal/handler/onboarding.go +++ b/server/internal/handler/onboarding.go @@ -2,10 +2,12 @@ package handler import ( "encoding/json" + "fmt" "log/slog" "net/http" "net/mail" "strings" + "unicode/utf8" "github.com/jackc/pgx/v5/pgtype" @@ -40,19 +42,56 @@ const ( onboardingAgentTemplate = "multica_helper" ) -const onboardingAssistantDescription = "Default guide for your first Multica workspace." +const onboardingAssistantDescription = "Built-in workspace assistant. Answers Multica questions and runs CLI operations." -const onboardingAssistantAvatarURL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E" +const onboardingAssistantAvatarURL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='t' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%2323242C'/%3E%3Cstop offset='100%25' stop-color='%2313141A'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='28' fill='url(%23t)'/%3E%3Cg stroke='%23FFFFFF' stroke-width='13' stroke-linecap='round'%3E%3Cline x1='64' y1='32' x2='64' y2='96'/%3E%3Cline x1='32' y1='64' x2='96' y2='64'/%3E%3Cline x1='41.4' y1='41.4' x2='86.6' y2='86.6'/%3E%3Cline x1='86.6' y1='41.4' x2='41.4' y2='86.6'/%3E%3C/g%3E%3C/svg%3E" -const onboardingAssistantInstructions = `You are Multica Helper, the user's first Multica teammate. Your job is to onboard them inside the first issue. +// onboardingAssistantInstructions is the system prompt persisted on every +// newly-created Multica Helper agent. It becomes the agent's `## Agent +// Identity` block in CLAUDE.md / AGENTS.md / GEMINI.md (see +// runtime_config.go:buildMetaSkillContent), so it's read on every task that +// agent runs, not just the first onboarding issue. +// +// Four sections (matching the design reviewed by product): +// 1. Identity — built-in workspace assistant, not onboarding-only +// 2. What Multica is — concept map + docs / source pointers + GitHub +// issues as the official feedback channel +// 3. What you can do — toolbox = `multica` CLI; `multica --help` is the +// manifest; never invent commands +// 4. Tone — concise, like a colleague; match user's language; never +// fabricate URLs / flags / file paths +// +// Things intentionally NOT in here, because brief already injects them: +// - CLI command examples (## Available Commands) +// - "Use CLI, not curl" hard rule (## Important: Always Use the multica CLI) +// - @mention loop rules (## Mentions) +// - Per-task workflow (### Workflow, branched by task mode) +// - Output via comment add (## Output) +// - Attachment handling (## Attachments) +const onboardingAssistantInstructions = `You are Multica Helper, the built-in AI assistant for this Multica workspace. Your role is to help any member use Multica better — answer questions, give advice, and execute workspace operations on their behalf. -When the onboarding issue starts, leave a concise first comment that: -1. Explains that issues are where work happens in Multica. -2. Tells the user they can reply in the thread or @mention you to continue. -3. Asks for one concrete task they want help with. -4. Mentions that they can create more agents and connect more runtimes later. +## What Multica is -Keep the tone practical. Do not create extra issues or projects unless the user asks.` +Multica is an open-source, AI-native team workspace (source: https://github.com/multica-ai/multica). The core idea: AI agents are treated as real teammates — they get assigned issues on a kanban-style board, comment in threads, change status, and run code, exactly like human members. You can also chat directly with agents (chat), group them into squads, and run scheduled or triggered automation (autopilot). + +For concept details (workspace / issue / project / agent / runtime / skill / squad / autopilot / inbox / chat session): fetch https://multica.ai/docs via WebFetch — that's authoritative. For the "why" or implementation, fetch the GitHub repo above. Never paraphrase concepts from memory. + +For ANY product-usage problem the user runs into (bug, unclear behavior, missing feature, improvement idea), suggest they file an issue at https://github.com/multica-ai/multica/issues — that's the official feedback channel. + +## What you can do + +Your toolbox is the ` + "`multica`" + ` CLI. It's already on your PATH and authenticated as the workspace owner. + +Your full capability surface = whatever ` + "`multica --help`" + ` shows. Run ` + "`multica --help`" + ` first, then ` + "`multica --help`" + ` for any subcommand; use ` + "`--output json`" + ` for structured data. The CLI is your manifest — never invent commands or flags. + +A few things you can actually do (non-exhaustive — ` + "`--help`" + ` is the source of truth): +- Create issues, post comments +- Create or iterate on agents +- Manage projects, squads, autopilots, skills, runtimes, etc. + +## Tone + +Be concise and direct, like a colleague. Respond in the user's language (Chinese in, Chinese out). When pointing at a UI location, name the exact path ("Settings → Agents → New"); when pointing at a doc, link to the specific page, not the homepage. Never fabricate URLs, flags, or file paths.` const onboardingIssueDescription = `Welcome to Multica. @@ -212,9 +251,24 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, userToResponse(user)) } +// maxStarterPromptLen caps the user-supplied StarterPrompt on +// bootstrapOnboardingRuntimeRequest. The prompt becomes the seeded onboarding +// issue's description and is read by the agent as task context, so we want +// enough room for a real prompt (a paragraph or two) without inviting bulk +// payload. 2 KiB is comfortably above the canonical three starter prompts +// rendered by the workspace OnboardingHelperModal and still fits inside the +// 8 KiB runtimeBootstrapBodyLimit alongside the workspace/runtime UUIDs. +const maxStarterPromptLen = 2 * 1024 + type bootstrapOnboardingRuntimeRequest struct { WorkspaceID string `json:"workspace_id"` RuntimeID string `json:"runtime_id"` + // StarterPrompt is the user's chosen first task for Multica Helper, + // rendered as the seeded onboarding issue's description. Optional — + // when empty (legacy clients or no choice made), the issue uses + // onboardingIssueDescription as a fallback. Server-capped at + // maxStarterPromptLen runes. + StarterPrompt string `json:"starter_prompt,omitempty"` } type bootstrapOnboardingRuntimeResponse struct { @@ -255,6 +309,15 @@ func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusBadRequest, "runtime_id is required") return } + // Normalize StarterPrompt: trim outer whitespace so leading/trailing + // newlines from copy-pasted templates don't bloat the issue body, then + // cap by rune count (not bytes) so multi-byte CJK input gets the same + // budget as ASCII. + req.StarterPrompt = strings.TrimSpace(req.StarterPrompt) + if utf8.RuneCountInString(req.StarterPrompt) > maxStarterPromptLen { + writeError(w, http.StatusBadRequest, fmt.Sprintf("starter_prompt exceeds %d characters", maxStarterPromptLen)) + return + } wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id") if !ok { return @@ -370,10 +433,20 @@ func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusInternalServerError, "failed to allocate issue number") return } + // Description prefers the user's chosen starter prompt from the + // workspace OnboardingHelperModal. Empty StarterPrompt (legacy + // client / no card selected) falls back to the generic + // onboardingIssueDescription so the issue is never blank. Title + // stays fixed — LockAndFindActiveDuplicate above keys on it for + // idempotency, so varying the title would break dedupe. + description := onboardingIssueDescription + if req.StarterPrompt != "" { + description = req.StarterPrompt + } issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{ WorkspaceID: wsUUID, Title: onboardingIssueTitle, - Description: strOrNullText(onboardingIssueDescription), + Description: strOrNullText(description), Status: "todo", Priority: "high", AssigneeType: pgtype.Text{String: "agent", Valid: true}, diff --git a/server/internal/handler/onboarding_test.go b/server/internal/handler/onboarding_test.go index f54283df2..3854cfa73 100644 --- a/server/internal/handler/onboarding_test.go +++ b/server/internal/handler/onboarding_test.go @@ -267,8 +267,8 @@ func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) { if agentRuntime != testRuntimeID { t.Fatalf("agent runtime = %q, want %q", agentRuntime, testRuntimeID) } - if !strings.Contains(instructions, "onboard them inside the first issue") { - t.Fatalf("assistant instructions were not seeded: %q", instructions) + if !strings.Contains(instructions, "built-in AI assistant") { + t.Fatalf("assistant instructions were not seeded with the new identity: %q", instructions) } if avatarURL == nil || *avatarURL != onboardingAssistantAvatarURL { t.Fatalf("agent avatar_url = %v, want seeded Multica Helper avatar", avatarURL) @@ -344,6 +344,146 @@ func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) { } } +// TestBootstrapOnboardingRuntime_WithStarterPrompt asserts that the +// caller-supplied starter_prompt becomes the seeded onboarding issue's +// description — that's how the workspace OnboardingHelperModal delivers +// the user's chosen first task to Multica Helper. +func TestBootstrapOnboardingRuntime_WithStarterPrompt(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + + ctx := context.Background() + t.Cleanup(func() { + testPool.Exec(ctx, ` + DELETE FROM agent_task_queue + WHERE agent_id IN ( + SELECT id FROM agent + WHERE workspace_id = $1 AND name = $2 + ) + `, testWorkspaceID, onboardingAssistantName) + testPool.Exec(ctx, + `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, + testWorkspaceID, onboardingIssueTitle, + ) + testPool.Exec(ctx, + `DELETE FROM agent WHERE workspace_id = $1 AND name = $2`, + testWorkspaceID, onboardingAssistantName, + ) + testPool.Exec(ctx, + `UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`, + testUserID, + ) + }) + testPool.Exec(ctx, + `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, + testWorkspaceID, onboardingIssueTitle, + ) + testPool.Exec(ctx, + `DELETE FROM agent WHERE workspace_id = $1 AND name = $2`, + testWorkspaceID, onboardingAssistantName, + ) + testPool.Exec(ctx, + `UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`, + testUserID, + ) + + const wantPrompt = "Introduce Multica to me, please." + body := map[string]string{ + "workspace_id": testWorkspaceID, + "runtime_id": testRuntimeID, + "starter_prompt": wantPrompt, + } + w := httptest.NewRecorder() + testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body)) + if w.Code != http.StatusOK { + t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp bootstrapOnboardingRuntimeResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + var description *string + if err := testPool.QueryRow(ctx, ` + SELECT description FROM issue WHERE id = $1 + `, resp.IssueID).Scan(&description); err != nil { + t.Fatalf("lookup issue description: %v", err) + } + if description == nil || *description != wantPrompt { + t.Fatalf("issue description = %v, want %q", description, wantPrompt) + } +} + +// TestBootstrapOnboardingRuntime_NoStarterPrompt asserts that legacy +// clients (no starter_prompt field, or empty) still get a non-blank +// issue body via the onboardingIssueDescription fallback. +func TestBootstrapOnboardingRuntime_NoStarterPrompt(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + + ctx := context.Background() + t.Cleanup(func() { + testPool.Exec(ctx, ` + DELETE FROM agent_task_queue + WHERE agent_id IN ( + SELECT id FROM agent + WHERE workspace_id = $1 AND name = $2 + ) + `, testWorkspaceID, onboardingAssistantName) + testPool.Exec(ctx, + `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, + testWorkspaceID, onboardingIssueTitle, + ) + testPool.Exec(ctx, + `DELETE FROM agent WHERE workspace_id = $1 AND name = $2`, + testWorkspaceID, onboardingAssistantName, + ) + testPool.Exec(ctx, + `UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`, + testUserID, + ) + }) + testPool.Exec(ctx, + `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, + testWorkspaceID, onboardingIssueTitle, + ) + testPool.Exec(ctx, + `DELETE FROM agent WHERE workspace_id = $1 AND name = $2`, + testWorkspaceID, onboardingAssistantName, + ) + testPool.Exec(ctx, + `UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`, + testUserID, + ) + + body := map[string]string{ + "workspace_id": testWorkspaceID, + "runtime_id": testRuntimeID, + // starter_prompt omitted — legacy client behavior + } + w := httptest.NewRecorder() + testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body)) + if w.Code != http.StatusOK { + t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp bootstrapOnboardingRuntimeResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + var description *string + if err := testPool.QueryRow(ctx, ` + SELECT description FROM issue WHERE id = $1 + `, resp.IssueID).Scan(&description); err != nil { + t.Fatalf("lookup issue description: %v", err) + } + if description == nil || *description != onboardingIssueDescription { + t.Fatalf("issue description = %v, want fallback onboardingIssueDescription", description) + } +} + func TestBootstrapOnboardingNoRuntimeCreatesSingleGuideIssue(t *testing.T) { if testHandler == nil { t.Skip("database not available") diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index 9e4ca8bd2..877dcae6f 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -201,12 +201,24 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { return } - // Becoming a workspace member is the physical event that "completes" onboarding — - // keep this atomic with CreateMember so `member` and `onboarded_at` - // can never disagree. COALESCE in MarkUserOnboarded keeps it idempotent. - updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID)) + // NOTE: CreateWorkspace deliberately does NOT mark the user as + // onboarded. The `onboarded_at` flag is reserved for the path that + // creates the user's first Multica Helper agent — either + // BootstrapOnboardingRuntime (via the workspace OnboardingHelperModal), + // BootstrapOnboardingNoRuntime (explicit skip-runtime), AcceptInvitation + // (invitee joining an existing workspace), or CompleteOnboarding's + // skip_existing / cloud_waitlist exits. This decouples "the user has a + // workspace" from "the user has finished setup", letting the workspace + // layout render a blocking OnboardingHelperModal for users who reach + // the workspace UI without a Helper. + // + // Older guard comments saying "un-onboarded but in workspace is + // physically impossible" are now stale. The web `useDashboardGuard` + // and desktop `App.tsx` overlay guards already gate on workspace + // presence (not onboarded), so they tolerate this mid-flow state. + currentUser, err := qtx.GetUser(r.Context(), parseUUID(userID)) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to mark user onboarded") + writeError(w, http.StatusInternalServerError, "failed to load user") return } @@ -216,14 +228,14 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { // dialog on older desktop builds that still render it when the column // is NULL. seededIssue, seededIssueCreated, err := ensureNoRuntimeOnboardingIssue( - r.Context(), qtx, ws.ID, parseUUID(userID), updatedUser.Language, + r.Context(), qtx, ws.ID, parseUUID(userID), currentUser.Language, ) if err != nil { slog.Warn("create workspace: ensure install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", uuidToString(ws.ID))...) writeError(w, http.StatusInternalServerError, "failed to seed onboarding issue") return } - if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), updatedUser.StarterContentState); err != nil { + if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), currentUser.StarterContentState); err != nil { writeError(w, http.StatusInternalServerError, "failed to record starter content state") return } diff --git a/server/internal/handler/workspace_test.go b/server/internal/handler/workspace_test.go index cb52369f3..8143f0102 100644 --- a/server/internal/handler/workspace_test.go +++ b/server/internal/handler/workspace_test.go @@ -36,6 +36,51 @@ func TestCreateWorkspace_RejectsReservedSlug(t *testing.T) { } } +// TestCreateWorkspace_DoesNotMarkOnboarded guards the new onboarding +// contract: creating a workspace MUST leave user.onboarded_at NULL so the +// workspace OnboardingHelperModal can fire when the user lands in the +// new workspace UI. The previous behavior atomically set onboarded_at +// inside CreateWorkspace (workspace.go:207); this test makes the new +// invariant explicit and regression-protected. +// +// AcceptInvitation, BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime +// and the CompleteOnboarding skip-paths still mark onboarded — those are +// guarded by their own tests. +func TestCreateWorkspace_DoesNotMarkOnboarded(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + + ctx := context.Background() + const slug = "handler-tests-onboarded-null" + _, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug) + // Ensure the test user starts un-onboarded so the assertion is meaningful. + _, _ = testPool.Exec(ctx, `UPDATE "user" SET onboarded_at = NULL WHERE id = $1`, testUserID) + + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE slug = $1`, slug) + _, _ = testPool.Exec(context.Background(), `UPDATE "user" SET onboarded_at = NULL WHERE id = $1`, testUserID) + }) + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/workspaces", map[string]any{ + "name": "Onboarding Invariant Probe", + "slug": slug, + }) + testHandler.CreateWorkspace(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateWorkspace: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var onboardedAt *string + if err := testPool.QueryRow(ctx, `SELECT onboarded_at FROM "user" WHERE id = $1`, testUserID).Scan(&onboardedAt); err != nil { + t.Fatalf("lookup user: %v", err) + } + if onboardedAt != nil { + t.Fatalf("CreateWorkspace marked user as onboarded; expected NULL, got %q. The workspace OnboardingHelperModal relies on this staying NULL until BootstrapOnboardingRuntime fires.", *onboardedAt) + } +} + // TestDeleteWorkspace_RequiresOwner exercises the in-handler authorization // added to DeleteWorkspace by calling the handler directly (bypassing the // router-level RequireWorkspaceRoleFromURL middleware). Without the handler