* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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
- `<WelcomeAfterOnboarding />` 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 `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
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 条核心原则
users.onboarded_at是唯一 onboarding 状态字段(删 v2 加的两字段)- Onboarding 阶段(Step 1-3)纯收集,Step 3 按钮 = mark + welcome-store.set + navigate
- 后端 onboarding 业务复杂度归零 — 删
BootstrapOnboarding*/OnboardingService/WorkspaceContentService/EnsureOnboardingContent全套;后端 onboarding 只剩 mark + 问卷 - 进工作区 hook 看 welcome-store,有信号 → 按 runtime/skip 分两路调通用
createAgent/createIssue - 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(/<slug>/issues)
workspace layout gate: onboarded_at != NULL ✓ 放行
<WelcomeAfterOnboarding /> 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(/<slug>/issues)
workspace layout gate ✓ 放行
<WelcomeAfterOnboarding /> 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(/<slug>/issues) 不带 welcome-store 信号
workspace layout gate ✓ 放行
<WelcomeAfterOnboarding /> 无信号 → 渲染 null
3.4 老用户回访
resolvePostAuthDestination: hasOnboarded ✓ + workspace[0] → /<slug>/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 多次进入。前端用:
useRef一次性锁 prevents 同 mount 内重复触发- Welcome 信号
consume()一读即清,即使 React 多次 mount 也只第一次拿到信号 - 进
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.goserver/internal/service/workspace_content.goserver/internal/handler/onboarding.go:BootstrapOnboardingRuntime、BootstrapOnboardingNoRuntimehandler 整体 + 相关常量(onboardingAssistantInstructions94 行 markdown、onboardingAssistantName、onboardingIssueTitle、onboardingIssueDescription、onboardingAssistantAvatarURL、onboardingAssistantDescription、onboardingAgentTemplate)server/internal/handler/workspace.go:EnsureOnboardingContenthandlerserver/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/bootstrapNoRuntimeOnboardingpackages/core/api/client.ts:bootstrapOnboardingRuntime/bootstrapOnboardingNoRuntime/ensureOnboardingContent+ Response interfaces + EMPTY constspackages/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回退到只接 questionnaireserver/internal/handler/onboarding.go:CompleteOnboarding简化为直接qtx.MarkUserOnboarded(无 service 包装)+PatchOnboarding只接 questionnaireserver/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 而非 OnboardingHelperModalapps/web/app/[workspaceSlug]/layout.tsx:加 hard gate effect + 改挂<WelcomeAfterOnboarding />apps/desktop/src/renderer/src/App.tsx:overlay 决策加规则 —!hasOnboarded一律开 onboarding overlay 并先setCurrentWorkspace(null,null)apps/desktop/src/renderer/src/components/workspace-route-layout.tsx:<WorkspaceOnboardingInit />→<WelcomeAfterOnboarding />packages/core/onboarding/index.ts:精简 exports + 加 welcome-store exportspackages/core/paths/resolve.ts:回到 onboarded-first 优先级packages/core/paths/resolve.test.ts:断言同步翻新packages/views/onboarding/onboarding-flow.tsx:handleRuntimeNext改为 awaitcompleteOnboarding+welcomeStore.set+ navigateapps/web/test/helpers.tsx:mockUser 删两字段packages/views/package.jsonexports:删 v2 两个 workspace 入口,加./workspace/welcome-after-onboarding
加
packages/core/onboarding/welcome-store.ts+ 测试:Zustand transient storepackages/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 或单元测试机械验证:
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- v3 前端永不调旧 bootstrap endpoint:
grep -rn "bootstrapOnboardingRuntime\|bootstrapOnboardingNoRuntime\|ensureOnboardingContent" packages/ apps/→ 0 命中 - v2 字段全删:
grep -rn "onboarding_runtime_id\|onboarding_runtime_skipped" packages/ apps/→ 0 命中 - Welcome Modal 不再自查环境:
grep -rn "runtimeListOptions" packages/views/workspace/→ 0 命中 - welcome-store 信号只来自 onboarding-flow,只被 welcome-after-onboarding 消费(
grep -rln "useWelcomeStore"应该只命中 4 个文件:store、welcome 组件、onboarding-flow、各自的测试) - 老用户登录 → 直接进工作区 → 不弹 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 的复杂度,已经死了) - 文案常量完整保留(
onboardingAssistantInstructions94 行 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_waitlistenum 值实际无人发送 — 可清理apps/web/app/[workspaceSlug]/layout.test.tsx(新增)+apps/desktop/src/renderer/src/App.test.tsx(新增用例)覆盖两端 hard gate 的 redirect 行为 — 当前由手动 E2E 覆盖