From fbd965e5bf776795f383def7cdb32aab1ddfc17c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 21 May 2026 19:00:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20v3=20=E2=80=94=20thin=20ser?= =?UTF-8?q?ver,=20frontend-orchestrated=20welcome=20(#3008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(onboarding): Multica Helper as general workspace assistant + blocking modal Reshape Multica Helper from an onboarding-only guide into the workspace's general-purpose AI assistant. The agent's permanent identity (injected as `## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md via execenv.InjectRuntimeConfig) is rewritten to three sections that don't overlap with what the brief already provides: - Who I am (built-in workspace assistant, not onboarding-only) - What Multica is + docs/source/issues URLs as knowledge sources - What I can do (CLI = manifest, `multica --help` is the source of truth) - Tone (concise, like a colleague, match user's language) Bootstrap moves out of the in-flow Step 4. Runtime step now exits the onboarding shell with no bootstrap call; a blocking OnboardingHelperModal mounts inside the workspace layout (web + desktop) and gates purely on `me.onboarded_at == null`. The user picks one of three starter prompts (intro / assign / second_agent) and the modal calls BootstrapOnboardingRuntime with a new optional `starter_prompt` field that becomes the seeded onboarding issue's description. Side effects required to make `onboarded_at == null` an honest signal: - CreateWorkspace no longer marks onboarded (was atomic with CreateMember). The "member exists ⟹ onboarded_at != null" invariant is intentionally broken; guards (useDashboardGuard / desktop App.tsx) already tolerate this — comments updated to reflect the new contract. - AcceptInvitation still marks (invitee skips the modal in someone else's workspace). Code comment added warning future removers. - resolvePostAuthDestination flips to workspace-presence-first: a user with a workspace lands in it regardless of `onboarded_at`, so the modal can pick up an interrupted setup on relogin. Other backend changes: - `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…") - `onboardingAssistantInstructions` rewritten to the 3-section identity - `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune cap, empty-falls-back-to onboardingIssueDescription) Frontend changes: - Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a persisted step) - `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"` - `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no bootstrap, `onboarded_at` stays NULL so the modal fires - Runtime step next-button copy → "Start exploring" / "开始探索" - New `packages/views/workspace/onboarding-helper-modal.tsx`: Base UI Dialog, dismissible=false, three localized cards, mutation invalidates agents + issues queries then navigates to the seeded issue - Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and `apps/desktop/src/renderer/src/components/workspace-route-layout.tsx` Tests: - Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and TestCreateWorkspace_DoesNotMarkOnboarded - Frontend: onboarding-helper-modal.test.tsx covers all four gating conditions, three-card behavior, mutation pending state, and the "no close button" invariant Compatibility: - Already-onboarded users: zero impact (modal can't fire) - Invitees: AcceptInvitation still marks → modal can't fire - Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire - Old desktop / web clients: legacy teammate-step path keeps working (bootstrap accepts missing starter_prompt) — the new modal only fires on the new frontend bundle - Avatar SVG kept (asterisk variant) — no migration of existing Helper agents, only newly-created Helpers pick up the new instructions/description Co-Authored-By: Claude Opus 4.7 (1M context) * fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open On desktop, App.tsx auto-creates a tab pointing at the user's first workspace as soon as workspaces.length flips from 0 → 1 (during onboarding Step 2). The new tab mounts WorkspaceRouteLayout under the overlay, which mounts OnboardingHelperModal. The modal's Portal renders to document.body — appearing AFTER the WindowOverlay in DOM order, so its z-50 wins and the modal floats in front of the still-active onboarding Step 3 (runtime). Suppress the modal whenever any WindowOverlay is active. When the overlay closes (onComplete fires after the user finishes onboarding), the modal re-evaluates `me.onboarded_at == null` and pops on its own. Web is unaffected (onboarding flow lives at /onboarding, not under /[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the onboarding flow). Co-Authored-By: Claude Opus 4.7 (1M context) * docs(onboarding): add v2 refactor plan Captures the design + 8-step implementation order for collapsing the onboarding state machine: single mark-onboarded entry point, persisted Step 3 user choice, dumb Modal, single install-runtime seed call site. Includes old-user compatibility analysis (4 existing gates) and per-PR risk/rollback. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2) Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN columns to "user" and the CHECK constraint enforcing the 3-state machine (unset / picked-runtime / explicit-skip; the fourth combination is forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades to "unset" rather than dangling. PatchUserOnboarding gains the two narg fields plus CASE expressions that collapse the runtime/skipped pair atomically — a follow-up PATCH that flips one side now clears the other in the same statement, instead of preserving it via per-field COALESCE and tripping the CHECK constraint. Backwards compatible for existing users: both new fields default to (NULL, false), which is the "unset" leaf of the state machine, and four upstream gates on me.onboarded_at != null already short-circuit the new fields' readers for everyone who's already onboarded. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(server): collapse onboarding side effects to service layer Introduces OnboardingService.MarkComplete and WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single authorities for the two onboarding side effects that used to be duplicated across four handlers: - MarkUserOnboarded + claim starter_content_state + optional install-runtime fallback seed: was inline in BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime, AcceptInvitation, and CompleteOnboarding. - install-runtime issue seeding: was inline in CreateWorkspace and AcceptInvitation as a "no runtime yet" fallback. After this refactor: - MarkUserOnboarded is called from exactly one place (the service). - install-runtime issue is seeded from exactly one place (the service). - CreateWorkspace deliberately does not seed — the new /ensure-onboarding-content endpoint (also added here) lets the workspace-entry init component request the seed on first mount, so workspaces created but never opened don't accumulate stale issues. - The PatchOnboarding handler now accepts the new runtime_id / runtime_skipped fields and rejects (uuid, skipped=true) up front. - UserResponse exposes the two new persisted fields so the frontend can read them off `me` without an extra round-trip. Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the explicit cross-request switch path that the original COALESCE design would have 500'd on) + TestPatchOnboarding_PreserveUntouched. Old handler-local file no_runtime_issue.go is deleted; its content moved to service/workspace_content.go with the helpers exported. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(core): API + types for persisted onboarding runtime choice User type / Zod schema gain onboarding_runtime_id (string | null) and onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated to match. api.patchOnboarding accepts the new optional fields and the new api.ensureOnboardingContent endpoint is wired so the workspace shell can request the fallback seed. Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and recordOnboardingRuntimeSkipped() — replace the prior pattern of Step 3 calling bootstrap directly. They PATCH the user's choice, sync the auth store, and return. Mutually exclusive on the server side via the CHECK constraint; the client just ships one intent at a time. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal Replaces OnboardingHelperModal's self-gating render path with a 4-branch dispatcher that runs once on workspace-shell mount: branch 0 me.onboarded_at != null → ensure install-runtime issue fallback, render nothing branch 1 me.onboarding_runtime_skipped → SkipBootstrapping component: loading veil → bootstrap → navigate. On failure shows a Retry UI instead of silently freezing the veil branch 2 me.onboarding_runtime_id → render Modal with the runtime id from `me` (no internal list query) branch 3 (none of the above) → useEffect navigate back to /onboarding so the user walks Step 3 again The Modal itself is now a dumb component — receives `workspace` and `runtimeId` as props, no internal gates, no runtimeListOptions query. Tests rewritten to cover the props-driven render + pick-card paths; the prior gating tests move into the new workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure behaviour. Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop workspace-route-layout. Desktop keeps its `!overlayActive` suppression guard so the init doesn't portal-jump in front of an active WindowOverlay. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(onboarding): Step 3 records user choice instead of calling bootstrap handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime {Choice,Skipped}) and navigates straight into the workspace shell. The workspace-entry WorkspaceOnboardingInit reads the persisted choice off `me` and runs the appropriate branch — Step 3 is pure intent capture with zero side effects on its own. PATCH must succeed before navigation: if it fails the user stays on Step 3 with a toast, because navigating with no persisted intent would land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue and trigger a redirect loop back to /onboarding. The prior asymmetry (Connect deferred bootstrap to the workspace, Skip ran bootstrap inline) is gone — both paths defer to the workspace shell now. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(onboarding): v3 — thin server, frontend-orchestrated welcome Collapse v2's persisted runtime-choice fields + 4-branch dispatcher + OnboardingService/WorkspaceContentService stack down to a single rule: `onboarded_at` is the only state field, layout hard-gates on it, and the welcome experience after Step 3 is owned entirely by the frontend. V3 flow - Step 3 button: await POST /api/me/onboarding/complete (mark only) + park a transient signal in `useWelcomeStore` + navigate - Workspace layout: hard gate `onboarded_at == null` -> /onboarding - `` reads the welcome-store signal: - runtime path: find-or-create Multica Helper via generic createAgent with bilingual instructions from `templates/helper-instructions.ts`, blocking modal with 3 starter cards, pick -> createIssue + navigate - skip path: provision install-runtime (in_progress) -> agent-guide (todo, body embeds install-runtime mention chip) -> follow-up comment on install-runtime mentioning agent-guide; then pop celebration modal with 🎉 emoji pop animation, 2 read-only preview cards, single [Got it] CTA that navigates to install-runtime Server cleanup - Drop OnboardingService, WorkspaceContentService, v2 runtime-choice columns/CHECK on user, EnsureOnboardingContent endpoint - CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded directly (no service indirection) - BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a deprecation shim in onboarding_shim.go for desktop < v3 during the rollout window — handlers inlined to qtx.* calls, no service layer Localization - Persisted strings (issue titles/bodies, Helper instructions/ description, comment prefix) live as TS const `{en, zh}` maps in `packages/views/onboarding/templates/` — i18n bundle staleness can no longer write raw key paths into DB - UI-rendered strings (modal copy, status chips, buttons) stay in `packages/views/locales/{en,zh-Hans}/onboarding.json` - Language picked from live `i18n.language` (not `me.language`, which is null for new users until they pick a preference) Race protection - Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`, `postCommentDeduped`) so React StrictMode double-mount can't fire two parallel API calls that the server would then 409 Cross-references between the two skip-path issues render via Multica's mention-chip protocol `[](mention://issue/)` so they match the styled IssueChip pills used elsewhere. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety Welcome modal polish (the post-Step-3 surface this branch already introduced): Runtime path - Helper avatar replaces the bouncy 🎉 hero; tone-down animation to fade. New copy: "Hi, welcome to Multica / I'm your first Agent assistant" + capability hint sentence so users discover assignment + chat from the first screen. - Cards changed from "click = submit" to multi-select with the existing border-primary + ring selection pattern used by compact-runtime-row; bottom CTA "Assign N tasks to me →" appears only with N>0. - New starter cards: intro / tour / welcome_page (the last one tells Helper to paste an HTML welcome page into the issue comment — works on any runtime regardless of fs access). - Success state added between createIssue and navigation: 🎉 + "All set!" + "Sit tight ☕ — your {agentName} is on it" + inbox/chat hints, single [Got it] button. - Title/prompt for starter cards now live in TS const HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n bundle being loaded); subtitle stays in onboarding.json. Skip path - Body restructured into three independent ```md blocks (Name / Description / Instructions) so each picks up the markdown renderer's per-block copy button — no manual extraction. - ZH body now embeds the ZH Helper Description + Instructions (was Chinese-around-English-block). - Follow-up comment uses Multica's mention-chip protocol [identifier](mention://issue/uuid) so it renders as the styled IssueChip pill. - Issue titles bilingual with "Step 1 / Step 2" prefix. Cross-user / cross-workspace safety (code review feedback) - web onLogout + desktop handleDaemonLogout now call useWelcomeStore.reset() so user B logging into the same browser doesn't inherit user A's signal. - WelcomeAfterOnboarding gates on currentWorkspace.id === signal.workspaceId — prevents firing the modal in workspace B when the signal was parked for workspace A (desktop multi-tab, back/forward, deep-link). - Module-level promise dedupes (pendingHelperSetup, pendingIssueSeed, pendingCommentSeed) for the three API calls so React 18+ StrictMode dev double-mount can't race-create duplicates. Other small fixes carried in this commit - Helper instructions / agent description / starter card titles all read i18n.language (not me.language, which is null for new users who haven't picked a UI language preference yet). - Reverted welcome-emoji-pop animation to a small fade for the runtime avatar (kept the bouncy variant for the skip 🎉 hero where the celebration is the whole point). - Removed the duplicate 🎉 from the skip modal title (kept the hero one only). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(views): i18n hardcoded "Close" in welcome FullScreenError CI lint (i18next/no-literal-string) blocked on a literal "Close" string inside `FullScreenError` — surfaced as a nit in the original code review but missed in the merge. Add `error_close` to onboarding.json (EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel` prop, matching the existing `retryLabel` plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- apps/desktop/src/renderer/src/App.tsx | 37 +- .../src/components/workspace-route-layout.tsx | 19 + apps/web/app/[workspaceSlug]/layout.tsx | 18 + apps/web/components/web-providers.tsx | 12 +- docs/onboarding-refactor-plan.md | 218 ++++ packages/core/api/client.ts | 63 -- packages/core/api/schemas.ts | 11 - packages/core/onboarding/index.ts | 6 +- .../onboarding/recommend-template.test.ts | 138 ++- .../core/onboarding/recommend-template.ts | 42 +- packages/core/onboarding/step-order.ts | 6 +- packages/core/onboarding/store.ts | 55 +- packages/core/onboarding/types.ts | 27 +- .../core/onboarding/welcome-store.test.ts | 51 + packages/core/onboarding/welcome-store.ts | 74 ++ packages/core/paths/resolve.test.ts | 24 +- packages/core/paths/resolve.ts | 31 +- packages/ui/styles/base.css | 33 +- .../views/editor/attachment-preview-modal.tsx | 15 +- .../views/editor/html-attachment-preview.tsx | 47 +- .../views/editor/html-block-preview.test.tsx | 89 ++ packages/views/editor/html-block-preview.tsx | 48 +- packages/views/editor/html-preview-body.tsx | 141 +++ packages/views/layout/use-dashboard-guard.ts | 23 +- packages/views/locales/en/editor.json | 3 +- packages/views/locales/en/onboarding.json | 71 +- packages/views/locales/zh-Hans/editor.json | 3 +- .../views/locales/zh-Hans/onboarding.json | 71 +- .../components/icon-option-card.tsx | 14 +- packages/views/onboarding/onboarding-flow.tsx | 134 +-- .../steps/step-platform-fork.test.tsx | 6 +- .../onboarding/steps/step-platform-fork.tsx | 2 +- .../onboarding/steps/step-question.test.tsx | 10 +- .../views/onboarding/steps/step-question.tsx | 56 +- .../views/onboarding/steps/step-role.test.tsx | 4 +- packages/views/onboarding/steps/step-role.tsx | 9 +- .../onboarding/steps/step-runtime-connect.tsx | 2 +- .../onboarding/steps/step-source.test.tsx | 54 +- .../views/onboarding/steps/step-source.tsx | 54 +- .../views/onboarding/steps/step-teammate.tsx | 186 ---- .../onboarding/steps/step-use-case.test.tsx | 31 +- .../views/onboarding/steps/step-use-case.tsx | 51 +- .../templates/create-agent-guide-issue.ts | 123 +++ .../templates/helper-instructions.ts | 98 ++ .../templates/helper-starter-prompts.ts | 55 + packages/views/onboarding/templates/index.ts | 36 + .../templates/install-runtime-issue.ts | 121 +++ .../onboarding/templates/user-context.test.ts | 148 +++ .../onboarding/templates/user-context.ts | 101 ++ packages/views/package.json | 1 + .../welcome-after-onboarding.test.tsx | 367 +++++++ .../workspace/welcome-after-onboarding.tsx | 971 ++++++++++++++++++ server/cmd/server/router.go | 8 +- server/internal/analytics/events.go | 27 +- server/internal/handler/invitation.go | 44 +- server/internal/handler/no_runtime_issue.go | 199 ---- server/internal/handler/onboarding.go | 561 ++-------- server/internal/handler/onboarding_shim.go | 607 +++++++++++ server/internal/handler/onboarding_test.go | 145 ++- server/internal/handler/workspace.go | 48 +- server/internal/handler/workspace_test.go | 45 + ...98_user_onboarding_runtime_choice.down.sql | 6 + .../098_user_onboarding_runtime_choice.up.sql | 16 + server/pkg/db/generated/user.sql.go | 4 + server/pkg/db/queries/user.sql | 4 + 65 files changed, 4299 insertions(+), 1425 deletions(-) create mode 100644 docs/onboarding-refactor-plan.md create mode 100644 packages/core/onboarding/welcome-store.test.ts create mode 100644 packages/core/onboarding/welcome-store.ts create mode 100644 packages/views/editor/html-block-preview.test.tsx create mode 100644 packages/views/editor/html-preview-body.tsx delete mode 100644 packages/views/onboarding/steps/step-teammate.tsx create mode 100644 packages/views/onboarding/templates/create-agent-guide-issue.ts create mode 100644 packages/views/onboarding/templates/helper-instructions.ts create mode 100644 packages/views/onboarding/templates/helper-starter-prompts.ts create mode 100644 packages/views/onboarding/templates/index.ts create mode 100644 packages/views/onboarding/templates/install-runtime-issue.ts create mode 100644 packages/views/onboarding/templates/user-context.test.ts create mode 100644 packages/views/onboarding/templates/user-context.ts create mode 100644 packages/views/workspace/welcome-after-onboarding.test.tsx create mode 100644 packages/views/workspace/welcome-after-onboarding.tsx delete mode 100644 server/internal/handler/no_runtime_issue.go create mode 100644 server/internal/handler/onboarding_shim.go create mode 100644 server/migrations/098_user_onboarding_runtime_choice.down.sql create mode 100644 server/migrations/098_user_onboarding_runtime_choice.up.sql diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 540bf7ea7..26bc77ad2 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -3,9 +3,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { CoreProvider } from "@multica/core/platform"; import { pickLocale } from "@multica/core/i18n"; import { useAuthStore } from "@multica/core/auth"; +import { useWelcomeStore } from "@multica/core/onboarding"; import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries"; import { api } from "@multica/core/api"; import { useHasOnboarded } from "@multica/core/paths"; +import { setCurrentWorkspace } from "@multica/core/platform"; import { ThemeProvider } from "@multica/ui/components/common/theme-provider"; import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { Toaster } from "@multica/ui/components/ui/sonner"; @@ -118,25 +120,31 @@ function AppContent() { : undefined; useDaemonIPCBridge(activeWsId); - // Pre-workspace overlay routing for desktop. Mirrors the web entry-point - // judgment in callback / login: - // un-onboarded: - // 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 + // Pre-workspace overlay routing for desktop. Mirrors the web layout + // hard gate via overlays (desktop has no URL bar, so we open the + // onboarding overlay instead of router.replace): + // onboarded + has workspace → no overlay, dashboard + // un-onboarded (any wsCount): + // pending invites on email → /invitations overlay + // no invites → /onboarding overlay + // 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. + // V3 invariant: `onboarded_at != null` is the only path into the + // dashboard. CreateWorkspace does not mark onboarded; only Step 3's + // CompleteOnboarding (and AcceptInvitation) flip the flag. A user who + // somehow has a workspace but no onboarded mark must be sent back to + // /onboarding — we also clear the active workspace so the dashboard + // doesn't render under the overlay with stale workspace context. useEffect(() => { if (!user || !workspaceListFetched) return undefined; const { overlay, open } = useWindowOverlayStore.getState(); if (overlay) return undefined; - if (wsCount > 0) return undefined; + if (hasOnboarded && wsCount > 0) return undefined; if (!hasOnboarded) { + // Stale workspace context (if any) would leak X-Workspace-Slug + // headers into onboarding-time API calls. Clear it before opening + // the overlay. + setCurrentWorkspace(null, null); // Look up pending invitations by email. Network blip is non-fatal — // fall through to onboarding so the user isn't stuck on a blank // window. The sidebar's pending-invitations dropdown will surface @@ -258,6 +266,9 @@ function BlockingRuntimeConfigError({ message }: { message: string }) { async function handleDaemonLogout() { useTabStore.getState().reset(); useWindowOverlayStore.getState().close(); + // Drop any post-onboarding welcome signal so user B logging in next + // doesn't inherit user A's pending modal state. + useWelcomeStore.getState().reset(); try { await window.daemonAPI.clearToken(); } catch { 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..dae311ae3 100644 --- a/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx +++ b/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx @@ -9,8 +9,10 @@ import { import { setCurrentWorkspace } from "@multica/core/platform"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen"; +import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding"; import { WorkspacePresencePrefetch } from "@multica/views/layout"; import { useTabStore } from "@/stores/tab-store"; +import { useWindowOverlayStore } from "@/stores/window-overlay-store"; /** * Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx. @@ -34,6 +36,15 @@ export function WorkspaceRouteLayout() { const navigate = useNavigate(); const user = useAuthStore((s) => s.user); const isAuthLoading = useAuthStore((s) => s.isLoading); + // While a WindowOverlay is open (onboarding, accept-invite, new-workspace), + // the underlying tab is still mounted in the React tree — so this layout + // and its WelcomeAfterOnboarding Modal would render UNDER the overlay. + // Because the modal uses a Portal that targets document.body, it ends up + // rendered LATER in the DOM and visually outranks the overlay's z-50. + // Suppress the modal whenever any overlay is active; the moment the + // overlay closes the welcome hook re-evaluates and pops if its store + // signal is still set. + const overlayActive = useWindowOverlayStore((s) => s.overlay !== null); // Workspace routes require auth. If user is unauthenticated, bounce to /login. useEffect(() => { @@ -85,6 +96,14 @@ export function WorkspaceRouteLayout() { + {/* Reads the welcome-store transient signal parked by + * OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay + * (onboarding / accept-invite / new-workspace) is open so the modal + * doesn't portal-jump in front of an active pre-workspace flow. + * Once the overlay closes the hook re-evaluates and pops the + * Modal — unless the store signal has already been consumed, in + * which case the hook renders null. */} + {!overlayActive && } ); } diff --git a/apps/web/app/[workspaceSlug]/layout.tsx b/apps/web/app/[workspaceSlug]/layout.tsx index c9446c7ca..e0b9d00b9 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 { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding"; import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen"; @@ -31,6 +32,18 @@ export default function WorkspaceLayout({ if (!isAuthLoading && !user) router.replace(paths.login()); }, [isAuthLoading, user, router]); + // Hard onboarding gate. Authenticated user but onboarded_at NULL means + // they bypassed /onboarding (typed the URL, deeplink, etc.). Redirect + // back so the questionnaire + Step 3 finish. The reverse gate lives in + // `apps/web/app/(auth)/onboarding/page.tsx` — onboarded users hitting + // /onboarding bounce out to their workspace. Together those two effects + // make `onboarded_at` the single source of truth for "may access //*". + useEffect(() => { + if (user && user.onboarded_at == null) { + router.replace(paths.onboarding()); + } + }, [user, router]); + // Resolve workspace by slug from the React Query list cache. // Enabled only when user is authenticated — otherwise the list query isn't seeded. const { data: workspace, isFetched: listFetched } = useQuery({ @@ -86,6 +99,11 @@ export default function WorkspaceLayout({ return ( {children} + {/* Reads the welcome-store transient signal parked by + * OnboardingFlow.handleRuntimeNext. Runtime path → loading veil → + * blocking Modal with Helper + starter cards. Skip path → Modal + * with two seeded issues. No signal → null. */} + ); } diff --git a/apps/web/components/web-providers.tsx b/apps/web/components/web-providers.tsx index c5832602c..12e9fb514 100644 --- a/apps/web/components/web-providers.tsx +++ b/apps/web/components/web-providers.tsx @@ -4,6 +4,7 @@ import { Suspense, useMemo } from "react"; import { CoreProvider } from "@multica/core/platform"; import { createBrowserCookieLocaleAdapter } from "@multica/core/i18n/browser"; import type { LocaleResources, SupportedLocale } from "@multica/core/i18n"; +import { useWelcomeStore } from "@multica/core/onboarding"; import packageJson from "../package.json"; import { WebNavigationProvider } from "@/platform/navigation"; import { @@ -66,7 +67,16 @@ export function WebProviders({ wsUrl={deriveWsUrl()} cookieAuth={cookieAuth} onLogin={setLoggedInCookie} - onLogout={clearLoggedInCookie} + onLogout={() => { + // welcome-store holds the transient post-onboarding signal. Must + // clear on logout so user B logging into the same browser doesn't + // inherit user A's signal and have fire + // listAgents / createIssue against a workspace user B doesn't even + // belong to. The store's own docstring promises this reset; this + // is where it gets wired. + useWelcomeStore.getState().reset(); + clearLoggedInCookie(); + }} identity={identity} locale={locale} resources={resources} diff --git a/docs/onboarding-refactor-plan.md b/docs/onboarding-refactor-plan.md new file mode 100644 index 000000000..779179eb0 --- /dev/null +++ b/docs/onboarding-refactor-plan.md @@ -0,0 +1,218 @@ +# Onboarding v3 — Thin Server, Frontend-Orchestrated Welcome + +## 1. 背景 + +两次错位重构(MUL-2438 + da0ecb6a)叠加产出了一个味道很重的 onboarding: +- `users.onboarded_at` 在 4 个 handler 里分别 `MarkUserOnboarded`,每处副作用还不一样 +- 同样的"种 install-runtime issue"在 4 个调用点重复,靠 advisory lock 在底层做去重 +- Step 3 Connect 与 Skip 路径不对称(一个延迟 mark,一个立即 mark) +- 工作区 Modal 自己 query runtime 列表挑第一个,完全丢弃用户在 Step 3 的选择 +- `onboarded_at` 字段同时承担"完成状态"和"Modal 触发条件"双语义 + +最初的 v2 方案(本 wip 分支的 PR 1-7)走"持久化字段 + 工作区 init 4 分支 dispatcher"路线 — 把 Step 3 的选择 PATCH 进 `users.onboarding_runtime_id` / `onboarding_runtime_skipped`,工作区入口读这两个字段决定弹什么。可工作,但把过去 React 内存里的瞬态意图变成了数据库的持久化字段,后端业务复杂度不降反升:多了一个 `OnboardingService`,多了一个 `WorkspaceContentService`,handler 多了一个 `EnsureOnboardingContent` endpoint,前端多了 4 分支 dispatcher 组件。 + +数据分析师确认生产里 0 个 mid-flow 用户(`有 workspace + onboarded_at IS NULL`),v2 加的两字段没有真实使用,可以安全删除。 + +v3 在 v2 之上做更激进的清理:**后端 onboarding 业务复杂度归零,Helper agent / starter issue / 中英文案全部在前端 hook 里调通用 `createAgent` / `createIssue` 完成**。 + +## 2. 设计 — 5 条核心原则 + +1. **`users.onboarded_at` 是唯一 onboarding 状态字段**(删 v2 加的两字段) +2. **Onboarding 阶段(Step 1-3)纯收集**,Step 3 按钮 = mark + welcome-store.set + navigate +3. **后端 onboarding 业务复杂度归零** — 删 `BootstrapOnboarding*` / `OnboardingService` / `WorkspaceContentService` / `EnsureOnboardingContent` 全套;后端 onboarding 只剩 mark + 问卷 +4. **进工作区 hook 看 welcome-store**,有信号 → 按 runtime/skip 分两路调通用 `createAgent` / `createIssue` +5. **Helper instructions / starter prompt / install-runtime issue 描述(EN/ZH)全部在前端 TS 模块**(`packages/views/onboarding/templates/`) + +外加双向路由 hard gate:`apps/web/app/[workspaceSlug]/layout.tsx` 拦未 onboarded → `/onboarding`;`apps/web/app/(auth)/onboarding/page.tsx` 拦已 onboarded → workspace。桌面端用 `App.tsx` 的 overlay 决策达成同样效果(no URL bar, no router.replace)。 + +## 3. 4 类用户链路 + +### 3.1 新用户,Step 3 选 runtime + +``` +Step 3 [开始探索] + → completeOnboarding("full", workspace.id) ─ 后端只 mark + → welcomeStore.set({wsId, choice:"runtime", runtimeId}) + → navigate(//issues) +workspace layout gate: onboarded_at != NULL ✓ 放行 + consumes welcome-store: + phase 1: full-screen loading "Preparing your Helper…" + api.listAgents → 查重名 Multica Helper(workspace 可见的) + → 找到 → 复用 + → 没找到 → api.createAgent({name, instructions: HELPER_INSTRUCTIONS[lang], runtime_id, ...}) + phase 2: blocking Modal(无关闭按钮 / Escape no-op / outside-click no-op) + Helper avatar + name + description + 3 张 starter cards + phase 3: 用户挑卡 → api.createIssue({title, description: prompt, + assignee_type:"agent", assignee_id: agent.id}) → navigate(issue) +``` + +### 3.2 新用户,Step 3 点 Skip + +``` +Step 3 [跳过] + → completeOnboarding("runtime_skipped", workspace.id) + → welcomeStore.set({wsId, choice:"skip"}) + → navigate(//issues) +workspace layout gate ✓ 放行 + consumes signal: + Modal 打开(可关闭) + 后台并发 api.createIssue × 2: + - Connect a runtime to start using agents + - Create your first Multica Agent + 渲染 2 张 issue 卡;点卡 → navigate;关 Modal → 留在 /issues 列表 +``` + +### 3.3 被邀请用户 + +``` +AcceptInvitation handler: + 事务内 qtx.CreateMember + qtx.MarkUserOnboarded(必须保留!) +navigate(//issues) 不带 welcome-store 信号 +workspace layout gate ✓ 放行 + 无信号 → 渲染 null +``` + +### 3.4 老用户回访 + +``` +resolvePostAuthDestination: hasOnboarded ✓ + workspace[0] → //issues +layout gate ✓ 放行 +welcome-store 无信号 → 不弹 +``` + +## 4. 数据 / 字段终态 + +| 字段 | 含义 | 谁读 | +|---|---|---| +| `users.onboarded_at` | 唯一 onboarding 完成信号 | layout hard gate × 2 | +| `users.onboarding_questionnaire` | 问卷答案 | OnboardingFlow 预填 | +| (PostHog `OnboardingCompleted` event) | Skip vs Connect 历史分析 | 数据分析 | + +**删掉**:`onboarding_runtime_id`、`onboarding_runtime_skipped` 列 + `user_onboarding_runtime_choice_check` CHECK 约束。Welcome 触发改用前端 Zustand transient store(`packages/core/onboarding/welcome-store.ts`),read-once-then-clear 语义。 + +## 5. 关键设计决策 + +### D1:NavigationAdapter 不支持 state → Zustand transient store + +`packages/views/navigation/types.ts` 的 `push(path: string): void` 不接 state,所以不能用 `navigate(path, {state})` 模式。改用 `packages/core/onboarding/welcome-store.ts`:Step 3 完成时 `set({...})`,Welcome hook mount 时 `consume()`(一次读 + 清空)。store 非 persist → 刷新即丢 = Welcome Modal 不会被刷新重弹,符合"一次性体验"预期。 + +### D2:Desktop 路由 hard gate 走 overlay 而非 router.replace + +桌面端 onboarding 是 `WindowOverlay`,不是 react-router 路由。`apps/desktop/src/renderer/src/App.tsx` 的 overlay 决策 effect 增加规则:`!hasOnboarded` 一律 `setCurrentWorkspace(null,null) + open onboarding overlay`(不管 wsCount)。web 端在 `layout.tsx` 用 `router.replace(paths.onboarding())` 直接跳。两套实现在效果上等价。 + +### D3:`resolvePostAuthDestination` 改回 onboarded-first + +V2 改成了 workspace-presence-first(为了不把 mid-flow 用户踢回 onboarding),v3 还原成 onboarded-first。理由:layout hard gate 会在 `!onboarded` 时强制 redirect,resolver 直接走 workspace 只是浪费一次 navigation;mid-flow 在 v3 下概率极低(仅 Step 2 完关 app 这一窗口)。 + +### D4:Helper instructions / 长文案放 TS 模块,不放 i18n JSON + +instructions 是 94 行 markdown,issue 描述是 60+ 行带列表 / 代码块。放在 `packages/views/onboarding/templates/` 下 3 个 TS 模块,每个导出 `{en, zh}` 字符串 const。短文案(Modal 标题 / 副标题 / 按钮 / 卡片标题)仍走 i18n JSON。 + +### D5:重名 Helper 防重 + +刷新 / StrictMode 双 mount 都可能让 hook 多次进入。前端用: +1. `useRef` 一次性锁 prevents 同 mount 内重复触发 +2. Welcome 信号 `consume()` 一读即清,即使 React 多次 mount 也只第一次拿到信号 +3. 进 `createAgent` 前先 `api.listAgents` 查重名 + visibility="workspace" + !archived,有则复用 + +### D6:失败处理 + +- runtime 路径:阻塞 Modal + 失败 retry UI(无关闭),用户卡死时 `onAbandon` 把信号清空恢复路由 +- skip 路径:Modal 可关闭,每条 issue 失败显示 per-card retry +- `onboarded_at` 已 set,失败不阻塞 — 用户最坏情况是手动建 agent / 关 Modal 用空 workspace + +## 6. 文件改动盘点(相对 main) + +### 删 +- `server/internal/service/onboarding.go` +- `server/internal/service/workspace_content.go` +- `server/internal/handler/onboarding.go`:`BootstrapOnboardingRuntime`、`BootstrapOnboardingNoRuntime` handler 整体 + 相关常量(`onboardingAssistantInstructions` 94 行 markdown、`onboardingAssistantName`、`onboardingIssueTitle`、`onboardingIssueDescription`、`onboardingAssistantAvatarURL`、`onboardingAssistantDescription`、`onboardingAgentTemplate`) +- `server/internal/handler/workspace.go`:`EnsureOnboardingContent` handler +- `server/cmd/server/router.go`:3 条路由(`runtime-bootstrap` / `no-runtime-bootstrap` / `ensure-onboarding-content`) +- `server/internal/handler/handler.go`:`OnboardingService` / `WorkspaceContent` 字段 + 实例化 +- `server/internal/handler/onboarding_test.go`:5 个 Bootstrap* 测试 + 2 个 PatchOnboarding 测试(只留 JoinCloudWaitlist 测试) +- `packages/views/workspace/workspace-onboarding-init.tsx` + 测试 +- `packages/views/workspace/onboarding-helper-modal.tsx` + 测试 +- `packages/core/onboarding/store.ts`:`recordOnboardingRuntimeChoice` / `recordOnboardingRuntimeSkipped` / `bootstrapRuntimeOnboarding` / `bootstrapNoRuntimeOnboarding` +- `packages/core/api/client.ts`:`bootstrapOnboardingRuntime` / `bootstrapOnboardingNoRuntime` / `ensureOnboardingContent` + Response interfaces + EMPTY consts +- `packages/core/api/schemas.ts`:两个 Bootstrap schemas + User schema 的两字段 +- `packages/core/types/workspace.ts`:User 的两个新字段 +- `packages/views/locales/{en,zh-Hans}/onboarding.json`:`onboarding_helper_modal.*` 和 `workspace_init.*` keys + +### 改 +- `server/migrations/098_user_onboarding_runtime_choice.up.sql`:反转为 DROP CONSTRAINT/COLUMN IF EXISTS(同名文件、保留 098 序号) +- `server/migrations/098_user_onboarding_runtime_choice.down.sql`:no-op + 注释说明不可逆 +- `server/pkg/db/queries/user.sql`:`PatchUserOnboarding` 回退到只接 questionnaire +- `server/internal/handler/onboarding.go`:`CompleteOnboarding` 简化为直接 `qtx.MarkUserOnboarded`(无 service 包装)+ `PatchOnboarding` 只接 questionnaire +- `server/internal/handler/invitation.go`:`AcceptInvitation` 改回直接 `qtx.MarkUserOnboarded`(保留 mark 调用 — 注释强调 layout gate 依赖) +- `server/internal/handler/workspace.go`:`CreateWorkspace` 删 `ClaimStarterContentStateIfUnset` 调用(后端不再触碰 `starter_content_state`) +- `server/internal/handler/auth.go`:`UserResponse` / `userToResponse` 删两字段 +- `server/internal/handler/workspace_test.go`:测试断言保留,注释更新引用 layout gate 而非 OnboardingHelperModal +- `apps/web/app/[workspaceSlug]/layout.tsx`:加 hard gate effect + 改挂 `` +- `apps/desktop/src/renderer/src/App.tsx`:overlay 决策加规则 — `!hasOnboarded` 一律开 onboarding overlay 并先 `setCurrentWorkspace(null,null)` +- `apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`:`` → `` +- `packages/core/onboarding/index.ts`:精简 exports + 加 welcome-store exports +- `packages/core/paths/resolve.ts`:回到 onboarded-first 优先级 +- `packages/core/paths/resolve.test.ts`:断言同步翻新 +- `packages/views/onboarding/onboarding-flow.tsx`:`handleRuntimeNext` 改为 await `completeOnboarding` + `welcomeStore.set` + navigate +- `apps/web/test/helpers.tsx`:mockUser 删两字段 +- `packages/views/package.json` exports:删 v2 两个 workspace 入口,加 `./workspace/welcome-after-onboarding` + +### 加 +- `packages/core/onboarding/welcome-store.ts` + 测试:Zustand transient store +- `packages/views/onboarding/templates/helper-instructions.ts`:Helper agent 系统提示词,EN/ZH 各一份 +- `packages/views/onboarding/templates/install-runtime-issue.ts`:Skip 路径第 1 条 issue 文案(从 server 的 EN/ZH 双语描述完整搬过来) +- `packages/views/onboarding/templates/create-agent-guide-issue.ts`:Skip 路径第 2 条新 issue 文案(EN/ZH) +- `packages/views/onboarding/templates/index.ts`:barrel + `pickContentLang(language)` +- `packages/views/workspace/welcome-after-onboarding.tsx`:替代 v2 两个组件的统一 Welcome 体验 +- `packages/views/workspace/welcome-after-onboarding.test.tsx`:5 个测试覆盖 runtime / skip / 重名复用 / navigate / 渲染门 +- `packages/views/locales/{en,zh-Hans}/onboarding.json`:新增 `welcome_after_onboarding.{loading_helper,error_*,retry,dismiss_error,runtime.{title,subtitle,helper_description,cards.*},skip.{title,subtitle,open_issue,close,cards.*}}` + +## 7. 老用户兼容性 + +生产数据(数据分析师 read-only audit): +- `users_with_workspace` = 33,172 +- 其中 `onboarded_at IS NULL` = **0** + +→ 没有 mid-flow 用户需要 backfill 或邮件通知。migration 098 反转 SQL 在生产里是 no-op(没有列要删,因为生产从未运行过 v2 的 add-column);在 dev 数据库上 IF EXISTS 保护让任何状态都能干净收敛到"无字段"。 + +老用户(`onboarded_at != NULL`)100% 安全: +- web layout hard gate `if (user.onboarded_at == null)` → 永不触发 +- desktop App.tsx overlay 决策 `!hasOnboarded && wsCount > 0` → 永不触发 +- welcome-store 在 onboarding-flow.tsx Step 3 之外永不 `set` → Welcome Modal 永不弹 + +被邀请用户(invitee)的 `AcceptInvitation` 必须保留 `qtx.MarkUserOnboarded` — 注释里强调:"DO NOT REMOVE 否则 layout gate 把 invitee 踢回 /onboarding"。 + +## 8. 验收 — invariant + deprecation shim + +每条可用 grep 或单元测试机械验证: + +1. `onboarded_at` 的写入收敛到 3 个 handler:`CompleteOnboarding`(v3 主路径)、`AcceptInvitation`(invitee 必须)、`BootstrapOnboarding*`(**shim,见 §10**)。全部直接调 `qtx.MarkUserOnboarded`,无 service 包装。`grep -rln "qtx.MarkUserOnboarded\|h.Queries.MarkUserOnboarded" server/internal/handler/ | grep -v _test.go` +2. v3 前端永不调旧 bootstrap endpoint:`grep -rn "bootstrapOnboardingRuntime\|bootstrapOnboardingNoRuntime\|ensureOnboardingContent" packages/ apps/` → 0 命中 +3. v2 字段全删:`grep -rn "onboarding_runtime_id\|onboarding_runtime_skipped" packages/ apps/` → 0 命中 +4. Welcome Modal 不再自查环境:`grep -rn "runtimeListOptions" packages/views/workspace/` → 0 命中 +5. welcome-store 信号只来自 onboarding-flow,只被 welcome-after-onboarding 消费(`grep -rln "useWelcomeStore"` 应该只命中 4 个文件:store、welcome 组件、onboarding-flow、各自的测试) +6. 老用户登录 → 直接进工作区 → 不弹 Modal、无 loading veil(场景 4 手动验证) + +## 10. Deprecation shim — `BootstrapOnboarding*` + +**为什么保留**:v3 server 发布到 desktop auto-update 完成之间有 ~30 分钟真空期,期间老桌面会调旧 endpoint。删了 endpoint → 老桌面 404 → 新用户 onboarding 死循环。 + +**位置**:`server/internal/handler/onboarding_shim.go`(独立文件,所有 deprecated 代码都在这里;v3 主路径 `onboarding.go` 不含一行 shim 代码)。 + +**约束**: +- 简化版实现:handler 直接 `qtx.MarkUserOnboarded` + `qtx.CreateAgent` + `qtx.CreateIssue` + `qtx.SetStarterContentState`,不重新引入 `OnboardingService` / `WorkspaceContentService` 抽象层(那是 v2 的复杂度,已经死了) +- 文案常量完整保留(`onboardingAssistantInstructions` 94 行 markdown、`enNoRuntimeIssueDescription` 等),和前端 `packages/views/onboarding/templates/` 双轨并行,**这两份必须保持同步**直到 shim 删除 +- `claimStarterContentStateIfUnset` 局部 helper 也保留,因为老桌面靠这个字段抑制 legacy starter-content dialog +- 5 个回归测试(`TestBootstrapOnboarding*`)保留,保护 shim 行为不被无意改动 + +**删除条件**:`X-Client-Version` telemetry 确认无任何活跃 desktop 在调这俩 endpoint(典型窗口:2-3 个 release 后)。删的时候一并删: +- `server/internal/handler/onboarding_shim.go` 整文件 +- `server/cmd/server/router.go` 里 2 条 deprecated 注释 + 路由 +- `server/internal/handler/onboarding_test.go` 末尾 5 个 `TestBootstrapOnboarding*` + +## 9. 未来工作 + +- `starter_content_state` 列(老桌面端兼容)— v3 后端不再触碰,但列保留(老桌面读 NULL → 渲染 legacy 导入 dialog)。可在 desktop 0.2.x 之前的版本全部 EOL 后单独 PR 删 +- `OnboardingCompletionPath` 里的 `cloud_waitlist` enum 值实际无人发送 — 可清理 +- `apps/web/app/[workspaceSlug]/layout.test.tsx`(新增)+ `apps/desktop/src/renderer/src/App.test.tsx`(新增用例)覆盖两端 hard gate 的 redirect 行为 — 当前由手动 E2E 覆盖 diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index b04a7139e..62d522123 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -139,8 +139,6 @@ import { GroupedIssuesResponseSchema, ListIssuesResponseSchema, ListWebhookDeliveriesResponseSchema, - OnboardingNoRuntimeBootstrapResponseSchema, - OnboardingRuntimeBootstrapResponseSchema, RuntimeHourlyActivityListSchema, RuntimeUsageByAgentListSchema, RuntimeUsageByHourListSchema, @@ -177,30 +175,6 @@ export interface LoginResponse { user: User; } -export interface OnboardingRuntimeBootstrapResponse { - workspace_id: string; - agent_id: string; - issue_id: string; -} - -const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE: - OnboardingRuntimeBootstrapResponse = { - workspace_id: "", - agent_id: "", - issue_id: "", -}; - -export interface OnboardingNoRuntimeBootstrapResponse { - workspace_id: string; - issue_id: string; -} - -const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE: - OnboardingNoRuntimeBootstrapResponse = { - workspace_id: "", - issue_id: "", -}; - export class ApiError extends Error { readonly status: number; readonly statusText: string; @@ -415,43 +389,6 @@ export class ApiClient { }); } - async bootstrapOnboardingRuntime(payload: { - workspace_id: string; - runtime_id: string; - }): Promise { - const raw = await this.fetch( - "/api/me/onboarding/runtime-bootstrap", - { - method: "POST", - body: JSON.stringify(payload), - }, - ); - return parseWithFallback( - raw, - OnboardingRuntimeBootstrapResponseSchema, - EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE, - { endpoint: "POST /api/me/onboarding/runtime-bootstrap" }, - ); - } - - async bootstrapOnboardingNoRuntime(payload: { - workspace_id: string; - }): Promise { - const raw = await this.fetch( - "/api/me/onboarding/no-runtime-bootstrap", - { - method: "POST", - body: JSON.stringify(payload), - }, - ); - return parseWithFallback( - raw, - OnboardingNoRuntimeBootstrapResponseSchema, - EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE, - { endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" }, - ); - } - async joinCloudWaitlist(payload: { email: string; reason?: string; diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 8efb8dbd1..735b49847 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -206,17 +206,6 @@ export const ChildIssuesResponseSchema = z.object({ issues: z.array(IssueSchema).default([]), }).loose(); -export const OnboardingRuntimeBootstrapResponseSchema = z.object({ - workspace_id: z.string(), - agent_id: z.string(), - issue_id: z.string(), -}).loose(); - -export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({ - workspace_id: z.string(), - issue_id: z.string(), -}).loose(); - export const CloudRuntimeNodeSchema = z.object({ id: z.string(), owner_id: z.string(), diff --git a/packages/core/onboarding/index.ts b/packages/core/onboarding/index.ts index 8fb143f56..df5cb3c60 100644 --- a/packages/core/onboarding/index.ts +++ b/packages/core/onboarding/index.ts @@ -9,9 +9,11 @@ export type { export { saveQuestionnaire, completeOnboarding, - bootstrapRuntimeOnboarding, - bootstrapNoRuntimeOnboarding, joinCloudWaitlist, } from "./store"; export { ONBOARDING_STEP_ORDER } from "./step-order"; export { recommendTemplate, type AgentTemplateId } from "./recommend-template"; +export { + useWelcomeStore, + type WelcomeSignal, +} from "./welcome-store"; diff --git a/packages/core/onboarding/recommend-template.test.ts b/packages/core/onboarding/recommend-template.test.ts index c32b65ac4..264419cae 100644 --- a/packages/core/onboarding/recommend-template.test.ts +++ b/packages/core/onboarding/recommend-template.test.ts @@ -26,19 +26,19 @@ const ALL_ROLES: Role[] = [ "other", ]; -describe("recommendTemplate (v2)", () => { +describe("recommendTemplate", () => { describe("engineer × use_case tiebreaker", () => { it.each(["manage_team", "plan_research"])( - "engineer × %s → planning", + "engineer × [%s] → planning", (use_case) => { - expect(recommendTemplate({ role: "engineer", use_case })).toBe( - "planning", - ); + expect( + recommendTemplate({ role: "engineer", use_case: [use_case] }), + ).toBe("planning"); }, ); - it("engineer × write_publish → writing", () => { + it("engineer × [write_publish] → writing", () => { expect( - recommendTemplate({ role: "engineer", use_case: "write_publish" }), + recommendTemplate({ role: "engineer", use_case: ["write_publish"] }), ).toBe("writing"); }); it.each([ @@ -47,32 +47,72 @@ describe("recommendTemplate (v2)", () => { "automate_ops", "evaluate", "other", - ])("engineer × %s → coding", (use_case) => { - expect(recommendTemplate({ role: "engineer", use_case })).toBe("coding"); + ])("engineer × [%s] → coding", (use_case) => { + expect( + recommendTemplate({ role: "engineer", use_case: [use_case] }), + ).toBe("coding"); }); - it("engineer × null → coding", () => { - expect(recommendTemplate({ role: "engineer", use_case: null })).toBe( + it("engineer × [] → coding", () => { + expect(recommendTemplate({ role: "engineer", use_case: [] })).toBe( "coding", ); }); }); - describe("product × use_case", () => { - it("product × ship_code → coding", () => { + describe("multi-select priority (first matching branch wins)", () => { + // Engineer + (manage_team OR plan_research) wins over write_publish + // wins over the default coding fallback. The order inside the + // recommendTemplate switch is the implicit priority. + it("engineer × [ship_code, manage_team] → planning (manage_team wins over default)", () => { expect( - recommendTemplate({ role: "product", use_case: "ship_code" }), + recommendTemplate({ + role: "engineer", + use_case: ["ship_code", "manage_team"], + }), + ).toBe("planning"); + }); + it("engineer × [write_publish, ship_code] → writing", () => { + expect( + recommendTemplate({ + role: "engineer", + use_case: ["write_publish", "ship_code"], + }), + ).toBe("writing"); + }); + it("engineer × [manage_team, write_publish] → planning (earlier branch wins)", () => { + expect( + recommendTemplate({ + role: "engineer", + use_case: ["manage_team", "write_publish"], + }), + ).toBe("planning"); + }); + it("null × [ship_code, write_publish] → coding (fallback priority)", () => { + expect( + recommendTemplate({ + role: null, + use_case: ["ship_code", "write_publish"], + }), + ).toBe("coding"); + }); + }); + + describe("product × use_case", () => { + it("product × [ship_code] → coding", () => { + expect( + recommendTemplate({ role: "product", use_case: ["ship_code"] }), ).toBe("coding"); }); it.each(["manage_team", "plan_research", "evaluate", "other"])( - "product × %s → planning", + "product × [%s] → planning", (use_case) => { - expect(recommendTemplate({ role: "product", use_case })).toBe( - "planning", - ); + expect( + recommendTemplate({ role: "product", use_case: [use_case] }), + ).toBe("planning"); }, ); - it("product × null → planning", () => { - expect(recommendTemplate({ role: "product", use_case: null })).toBe( + it("product × [] → planning", () => { + expect(recommendTemplate({ role: "product", use_case: [] })).toBe( "planning", ); }); @@ -80,69 +120,71 @@ describe("recommendTemplate (v2)", () => { describe("marketing × use_case", () => { it.each(["write_publish", "plan_research"])( - "marketing × %s → writing", + "marketing × [%s] → writing", (use_case) => { - expect(recommendTemplate({ role: "marketing", use_case })).toBe( - "writing", - ); + expect( + recommendTemplate({ role: "marketing", use_case: [use_case] }), + ).toBe("writing"); }, ); - it("marketing × manage_team → planning", () => { + it("marketing × [manage_team] → planning", () => { expect( - recommendTemplate({ role: "marketing", use_case: "manage_team" }), + recommendTemplate({ role: "marketing", use_case: ["manage_team"] }), ).toBe("planning"); }); }); describe("single-template roles", () => { - it.each(ALL_USE_CASES)("writer × %s → writing", (use_case) => { - expect(recommendTemplate({ role: "writer", use_case })).toBe("writing"); - }); - it.each(ALL_USE_CASES)("designer × %s → assistant", (use_case) => { - expect(recommendTemplate({ role: "designer", use_case })).toBe( - "assistant", + it.each(ALL_USE_CASES)("writer × [%s] → writing", (use_case) => { + expect(recommendTemplate({ role: "writer", use_case: [use_case] })).toBe( + "writing", ); }); - it.each(ALL_USE_CASES)("research × %s → planning", (use_case) => { - expect(recommendTemplate({ role: "research", use_case })).toBe( - "planning", - ); + it.each(ALL_USE_CASES)("designer × [%s] → assistant", (use_case) => { + expect( + recommendTemplate({ role: "designer", use_case: [use_case] }), + ).toBe("assistant"); + }); + it.each(ALL_USE_CASES)("research × [%s] → planning", (use_case) => { + expect( + recommendTemplate({ role: "research", use_case: [use_case] }), + ).toBe("planning"); }); it.each(["founder", "ops", "student", "other"])( "%s → assistant", (role) => { - expect(recommendTemplate({ role, use_case: null })).toBe("assistant"); + expect(recommendTemplate({ role, use_case: [] })).toBe("assistant"); }, ); }); describe("role skipped — use_case fallback", () => { - it("null × ship_code → coding", () => { - expect(recommendTemplate({ role: null, use_case: "ship_code" })).toBe( + it("null × [ship_code] → coding", () => { + expect(recommendTemplate({ role: null, use_case: ["ship_code"] })).toBe( "coding", ); }); - it("null × write_publish → writing", () => { + it("null × [write_publish] → writing", () => { expect( - recommendTemplate({ role: null, use_case: "write_publish" }), + recommendTemplate({ role: null, use_case: ["write_publish"] }), ).toBe("writing"); }); it.each(["manage_team", "plan_research"])( - "null × %s → planning", + "null × [%s] → planning", (use_case) => { - expect(recommendTemplate({ role: null, use_case })).toBe("planning"); + expect(recommendTemplate({ role: null, use_case: [use_case] })).toBe( + "planning", + ); }, ); - it("both null → assistant", () => { - expect(recommendTemplate({ role: null, use_case: null })).toBe( - "assistant", - ); + it("both empty → assistant", () => { + expect(recommendTemplate({ role: null, use_case: [] })).toBe("assistant"); }); }); describe("exhaustive role coverage", () => { it.each(ALL_ROLES)("role=%s returns a valid template id", (role) => { - const result = recommendTemplate({ role, use_case: null }); + const result = recommendTemplate({ role, use_case: [] }); expect(["coding", "planning", "writing", "assistant"]).toContain(result); }); }); diff --git a/packages/core/onboarding/recommend-template.ts b/packages/core/onboarding/recommend-template.ts index c0f40512e..63357300d 100644 --- a/packages/core/onboarding/recommend-template.ts +++ b/packages/core/onboarding/recommend-template.ts @@ -8,13 +8,21 @@ import type { QuestionnaireAnswers, Role, UseCase } from "./types"; export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant"; /** - * Pick a recommended agent template based on the v2 questionnaire + * Pick a recommended agent template based on the questionnaire * (role × use_case). Role is the primary signal; use_case is a * tiebreaker for roles that legitimately split between templates * (engineer / product / marketing). * + * `use_case` is multi-select — when a user picks several, the rules + * below use `.includes(...)` against the set. Order of evaluation + * inside each role's switch is the implicit priority (first match + * wins). For ambiguous overlaps (e.g. engineer who picks both + * `manage_team` and `write_publish`) the earlier branch wins, which + * matches the prior single-select behavior when only one of those + * was selectable. + * * Fallback chain when role is skipped or null: - * 1. Derive from use_case alone. + * 1. Derive from use_case alone (same priority order). * 2. Both unknown → `assistant` (the generic default). * * Pure / deterministic — safe to call on every render. @@ -23,25 +31,25 @@ export function recommendTemplate( answers: Pick, ): AgentTemplateId { const role: Role | null = answers.role; - const useCase: UseCase | null = answers.use_case; + const useCases: readonly UseCase[] = answers.use_case ?? []; - if (role === null) return fallbackFromUseCase(useCase); + if (role === null) return fallbackFromUseCase(useCases); switch (role) { case "engineer": - if (useCase === "manage_team" || useCase === "plan_research") + if (useCases.includes("manage_team") || useCases.includes("plan_research")) return "planning"; - if (useCase === "write_publish") return "writing"; + if (useCases.includes("write_publish")) return "writing"; return "coding"; case "product": - if (useCase === "ship_code") return "coding"; + if (useCases.includes("ship_code")) return "coding"; return "planning"; case "designer": return "assistant"; case "writer": return "writing"; case "marketing": - if (useCase === "write_publish" || useCase === "plan_research") + if (useCases.includes("write_publish") || useCases.includes("plan_research")) return "writing"; return "planning"; case "research": @@ -54,16 +62,10 @@ export function recommendTemplate( } } -function fallbackFromUseCase(useCase: UseCase | null): AgentTemplateId { - switch (useCase) { - case "ship_code": - return "coding"; - case "write_publish": - return "writing"; - case "manage_team": - case "plan_research": - return "planning"; - default: - return "assistant"; - } +function fallbackFromUseCase(useCases: readonly UseCase[]): AgentTemplateId { + if (useCases.includes("ship_code")) return "coding"; + if (useCases.includes("write_publish")) return "writing"; + if (useCases.includes("manage_team") || useCases.includes("plan_research")) + return "planning"; + return "assistant"; } 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..694655098 100644 --- a/packages/core/onboarding/store.ts +++ b/packages/core/onboarding/store.ts @@ -22,20 +22,32 @@ export async function saveQuestionnaire( useAuthStore.getState().setUser(user); // Mirror the three cohort signals into person properties so every // PostHog event on this user can be broken down by source / role / - // use_case without re-joining the DB. - if (answers.source || answers.role || answers.use_case) { + // use_case without re-joining the DB. source / use_case are arrays + // (multi-select); PostHog accepts array property values, and + // breakdowns split each element into its own group. + const sourceList = answers.source ?? []; + const useCaseList = answers.use_case ?? []; + if (sourceList.length > 0 || answers.role || useCaseList.length > 0) { setPersonProperties({ - ...(answers.source ? { source: answers.source } : {}), + ...(sourceList.length > 0 ? { source: sourceList } : {}), ...(answers.role ? { role: answers.role } : {}), - ...(answers.use_case ? { use_case: answers.use_case } : {}), + ...(useCaseList.length > 0 ? { use_case: useCaseList } : {}), }); } } /** * Finalize onboarding. POST /complete marks `onboarded_at` atomically - * (COALESCE-guarded for idempotency). We then refresh the auth store - * so every gate sees the updated user. + * (COALESCE-guarded for idempotency) and emits the `onboarding_completed` + * analytics event exactly once. We then refresh the auth store so every + * gate sees the updated user — most importantly the workspace layout + * hard gate that redirects un-onboarded users back to /onboarding. + * + * v3 contract: this is the ONLY mechanism that flips `onboarded_at` + * from the frontend. All Helper-agent / starter-issue creation is now + * done by the welcome hook in the workspace shell using generic + * `createAgent` / `createIssue` calls, AFTER this call has returned + * and the user has been navigated into the workspace. * * `completionPath` is the client's view of which Step-3 exit the user * took; the server funnel-splits `onboarding_completed` on this value. @@ -53,37 +65,6 @@ export async function completeOnboarding( await useAuthStore.getState().refreshMe(); } -/** - * Runtime-connected onboarding path. The server creates or reuses the - * default Multica Helper agent and the single onboarding issue, then - * marks onboarding complete. - */ -export async function bootstrapRuntimeOnboarding( - workspaceId: string, - runtimeId: string, -): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> { - const result = await api.bootstrapOnboardingRuntime({ - workspace_id: workspaceId, - runtime_id: runtimeId, - }); - await useAuthStore.getState().refreshMe(); - return result; -} - -/** - * Runtime-skipped onboarding path. The server creates or reuses one - * install-runtime onboarding issue and marks onboarding complete. - */ -export async function bootstrapNoRuntimeOnboarding( - workspaceId: string, -): Promise<{ workspace_id: string; issue_id: string }> { - const result = await api.bootstrapOnboardingNoRuntime({ - workspace_id: workspaceId, - }); - await useAuthStore.getState().refreshMe(); - return result; -} - /** * Records interest in cloud runtimes. Pure side effect — does NOT * complete onboarding; the user still has to pick a real Step 3 diff --git a/packages/core/onboarding/types.ts b/packages/core/onboarding/types.ts index 2028f6337..175d677d4 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"; @@ -59,20 +58,32 @@ export type UseCase = | "other"; /** - * v2 questionnaire shape. `*_skipped: true` distinguishes an explicit - * Skip click from a slot the user never reached. Both states are - * "unknown" for recommendation purposes; the skip marker exists for - * analytics and so future re-prompts can avoid nagging users who - * already declined. + * Questionnaire shape. `source` and `use_case` allow multiple values + * (users hear about us through several channels and use Multica for + * several things); `role` stays single-select since the agent template + * recommendation wants a primary identity. + * + * `*_skipped: true` distinguishes an explicit Skip click from a slot + * the user never reached. Both states are "unknown" for recommendation + * purposes; the skip marker exists for analytics and so future + * re-prompts can avoid nagging users who already declined. + * + * Backward compat: prior versions of this app wrote `source` and + * `use_case` as a single string. `mergeQuestionnaire` in + * `onboarding-flow.tsx` upgrades those rows to single-element arrays + * on read; the server's `questionnaireAnswers.UnmarshalJSON` does the + * same. `version` stays at 2 — the JSONB column is schema-less so a + * mechanical bump would only show up in analytics, not in storage, + * and we keep one funnel cohort. */ export interface QuestionnaireAnswers { - source: Source | null; + source: Source[]; source_other: string | null; source_skipped: boolean; role: Role | null; role_other: string | null; role_skipped: boolean; - use_case: UseCase | null; + use_case: UseCase[]; use_case_other: string | null; use_case_skipped: boolean; version: 2; diff --git a/packages/core/onboarding/welcome-store.test.ts b/packages/core/onboarding/welcome-store.test.ts new file mode 100644 index 000000000..28e196c5f --- /dev/null +++ b/packages/core/onboarding/welcome-store.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useWelcomeStore, type WelcomeSignal } from "./welcome-store"; + +const sample: WelcomeSignal = { + workspaceId: "ws-1", + choice: "runtime", + runtimeId: "rt-1", +}; + +describe("welcome-store", () => { + beforeEach(() => { + useWelcomeStore.getState().reset(); + }); + + it("starts empty and not dismissed", () => { + expect(useWelcomeStore.getState().signal).toBeNull(); + expect(useWelcomeStore.getState().dismissed).toBe(false); + }); + + it("set() stores the signal and clears any prior dismissed flag", () => { + useWelcomeStore.getState().dismiss(); + expect(useWelcomeStore.getState().dismissed).toBe(true); + useWelcomeStore.getState().set(sample); + expect(useWelcomeStore.getState().signal).toEqual(sample); + // A fresh set() means a fresh Welcome session — dismissed resets so + // the next mount renders the new signal. + expect(useWelcomeStore.getState().dismissed).toBe(false); + }); + + it("dismiss() marks dismissed without clearing the signal", () => { + useWelcomeStore.getState().set(sample); + useWelcomeStore.getState().dismiss(); + expect(useWelcomeStore.getState().signal).toEqual(sample); + expect(useWelcomeStore.getState().dismissed).toBe(true); + }); + + it("reset() clears signal AND dismissed (used on logout)", () => { + useWelcomeStore.getState().set(sample); + useWelcomeStore.getState().dismiss(); + useWelcomeStore.getState().reset(); + expect(useWelcomeStore.getState().signal).toBeNull(); + expect(useWelcomeStore.getState().dismissed).toBe(false); + }); + + it("skip-path signals omit runtimeId", () => { + const skip: WelcomeSignal = { workspaceId: "ws-2", choice: "skip" }; + useWelcomeStore.getState().set(skip); + expect(useWelcomeStore.getState().signal).toEqual(skip); + expect(useWelcomeStore.getState().signal?.runtimeId).toBeUndefined(); + }); +}); diff --git a/packages/core/onboarding/welcome-store.ts b/packages/core/onboarding/welcome-store.ts new file mode 100644 index 000000000..d58bf7476 --- /dev/null +++ b/packages/core/onboarding/welcome-store.ts @@ -0,0 +1,74 @@ +import { create } from "zustand"; + +/** + * Welcome signal — written by Step 3 of the onboarding flow when the user + * clicks "Start exploring" or "Skip", consumed exactly once by the + * `` component mounted inside the workspace + * shell. + * + * The signal is the bridge between the onboarding screen and the workspace + * shell that succeeds it. `onboarded_at` is already true by the time we + * land in the workspace — this carries the *transient* "I just finished + * Step 3, here is what I chose" intent without persisting it on the user + * row (the v2 design persisted it as fields and leaked complexity + * everywhere). + * + * `choice` is "runtime" or "skip": + * - "runtime": user picked a runtime in Step 3. `runtimeId` is set. The + * workspace welcome hook creates a Multica Helper agent on that runtime + * and presents starter cards. + * - "skip": user explicitly skipped. `runtimeId` is undefined. The hook + * seeds two issues (install-runtime guide + create-agent guide) and + * shows them in a Modal. + */ +export interface WelcomeSignal { + workspaceId: string; + choice: "runtime" | "skip"; + runtimeId?: string; +} + +interface WelcomeStoreState { + signal: WelcomeSignal | null; + /** + * True after the user has explicitly engaged with the Welcome surface — + * picked a starter card, opened a seeded issue, closed the Skip modal. + * Components MUST treat `signal && !dismissed` as the render condition; + * `dismiss()` is what removes the modal from view. + */ + dismissed: boolean; + set: (signal: WelcomeSignal) => void; + /** + * Mark the Welcome surface as engaged-with. Idempotent. Keeps the + * signal in the store as a record but components will stop rendering. + * We don't fully clear the store on dismiss so a debug peek (or a + * future analytics hook) can still see which path the user took. + */ + dismiss: () => void; + /** + * Full reset — clear signal AND dismissed. Used by tests and by the + * user logout flow (so a different user logging into the same browser + * doesn't inherit a stale signal). + */ + reset: () => void; +} + +/** + * Zustand store, deliberately NOT persisted. Reloading the page drops the + * signal — that's the correct behavior: the user already finished + * onboarding (onboarded_at is set), and Welcome was a one-shot UI. We + * never want a refresh to re-trigger Helper agent creation. + * + * Why subscription model instead of read-once-consume: React 18+ StrictMode + * dev mode mounts components twice (mount → unmount → mount). If the + * component consumed the store in a `useState` initializer, the second + * mount would see an already-empty store and render null — Welcome would + * never appear in dev. Subscribing to a stable store + tracking dismissal + * in the store sidesteps both mounts seeing the same state. + */ +export const useWelcomeStore = create((set) => ({ + signal: null, + dismissed: false, + set: (signal) => set({ signal, dismissed: false }), + dismiss: () => set({ dismissed: true }), + reset: () => set({ signal: null, dismissed: false }), +})); diff --git a/packages/core/paths/resolve.test.ts b/packages/core/paths/resolve.test.ts index 33072dbb4..b0d3c1d76 100644 --- a/packages/core/paths/resolve.test.ts +++ b/packages/core/paths/resolve.test.ts @@ -19,26 +19,28 @@ 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. + it("!onboarded → /onboarding (even with a workspace)", () => { + // V3 invariant: onboarded_at is the single source of truth for + // workspace access. A user holding workspaces but flagged !onboarded + // (rare mid-flow state: closed app between Step 2 and Step 3) gets + // routed to /onboarding so they can finish; the layout hard gate + // would redirect them anyway. + const ws = [makeWs("acme")]; + expect(resolvePostAuthDestination(ws, false)).toBe(paths.onboarding()); expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding()); - expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe( - paths.onboarding(), - ); }); - it("onboarded + has workspace → //issues", () => { + it("onboarded + workspace[0] → //issues", () => { const ws = [makeWs("acme"), makeWs("beta")]; expect(resolvePostAuthDestination(ws, true)).toBe( paths.workspace("acme").issues(), ); }); - it("onboarded + zero workspaces → /workspaces/new", () => { + it("onboarded + no workspace → /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..9f3606509 100644 --- a/packages/core/paths/resolve.ts +++ b/packages/core/paths/resolve.ts @@ -3,20 +3,27 @@ import { useAuthStore } from "../auth"; import { paths } from "./paths"; /** - * Priority: - * !hasOnboarded → /onboarding - * hasOnboarded && has workspace → //issues - * hasOnboarded && zero workspaces → /workspaces/new + * Priority (onboarded-first): + * !hasOnboarded → /onboarding + * hasOnboarded + workspace[0] → //issues + * hasOnboarded + no workspace → /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. + * V3 invariant: `onboarded_at != null` is the single source of truth for + * "may access //*". The web workspace layout and the desktop App.tsx + * overlay decision both gate on this — sending an un-onboarded user + * straight to /issues would just be redirected back to /onboarding by + * the layout gate, costing a navigation round-trip. Check onboarded + * first. * - * Callers that need invitation-aware routing (callback / login) handle the - * "un-onboarded with pending invites" branch themselves before calling + * In v3 "has workspace but !onboarded" is physically rare (a user can + * only land in that state by closing the app between Step 2 and Step 3 + * — both questionnaire and runtime picker steps run after workspace + * creation but before CompleteOnboarding). OnboardingFlow's Step 2 + * already recognizes existing workspaces and offers "Continue with + * {name}", so the recovery is seamless. + * + * Callers that need invitation-aware routing (callback / login) handle + * the "un-onboarded with pending invites" branch themselves before calling * this resolver — this resolver only deals with the post-invite-check * destination. */ diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index 29f103b92..b309f4b2a 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -25,19 +25,40 @@ animation: entrance-spin 0.6s ease-out forwards; } -/* Onboarding: step / phase entry — 400ms fade + 4px rise. - * Matches the design prototype's .fade-in. Applied on mount so every new - * step (and intra-step phase switch, via key=phase remount) plays once. - * `both` fill-mode commits the `from` styles pre-animation to avoid a - * single-frame flash at natural state before the animation grabs. */ +/* Onboarding: step / phase entry — 400ms fade. + * Applied on mount so every new step (and intra-step phase switch, via + * key=phase remount) plays once. `both` fill-mode commits the `from` + * styles pre-animation to avoid a single-frame flash at natural state + * before the animation grabs. + * + * Earlier iteration also included a 4px translateY rise. Removed because + * the transform on h-full step roots was getting counted into the + * parent's scrollable overflow (web onboarding page + desktop + * WindowOverlay both wrap with overflow-y-auto), producing a brief + * scrollbar flash on each step entry. Pure opacity has no such side + * effect. */ @keyframes onboarding-enter { - from { opacity: 0; transform: translateY(4px); } + from { opacity: 0; } } .animate-onboarding-enter { animation: onboarding-enter 0.4s ease both; } +/* Welcome-after-onboarding Modal: emoji pops in with two quick scale + * bounces so the celebration registers visually without being + * disruptive. ~700ms total. */ +@keyframes welcome-emoji-pop { + 0% { transform: scale(0.4); opacity: 0; } + 35% { transform: scale(1.25); opacity: 1; } + 55% { transform: scale(0.92); } + 75% { transform: scale(1.12); } + 100% { transform: scale(1); } +} +.animate-welcome-emoji-pop { + animation: welcome-emoji-pop 0.7s cubic-bezier(0.4, 0, 0.2, 1) both; +} + /* Onboarding completion: success badge spring-pop. * Lands with a subtle overshoot (scale 1.12 → 1) so the circle feels * physical rather than linearly interpolated. Paired with the drawn diff --git a/packages/views/editor/attachment-preview-modal.tsx b/packages/views/editor/attachment-preview-modal.tsx index a5166d951..f1a18e7cc 100644 --- a/packages/views/editor/attachment-preview-modal.tsx +++ b/packages/views/editor/attachment-preview-modal.tsx @@ -56,7 +56,7 @@ import { } from "./utils/preview"; import { useDownloadAttachment } from "./use-download-attachment"; import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text"; -import { withFragmentNavShim } from "./utils/iframe-fragment-nav"; +import { HtmlPreviewBody } from "./html-preview-body"; import { CodeBlockStatic } from "./code-block-static"; // --------------------------------------------------------------------------- @@ -400,16 +400,11 @@ function PreviewContent({ attachmentId={state.attachmentId!} onDownload={onDownload} render={(text) => ( -