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) => ( -