refactor(onboarding): remove starter-content kit; unify install-runtime issue across mark-onboarded paths (MUL-2438) (#2884)

* refactor(onboarding): remove starter-content kit, unify install-runtime issue across mark-onboarded paths (MUL-2438)

Drops the post-onboarding ImportStarterContent / DismissStarterContent
flow (handler + routes + StarterContentPrompt + templates + locale
strings + analytics event). The bug — web onboarding seeding 6+ starter
issues without a runtime — only existed through that path; with it gone
the source disappears.

The "install a runtime" issue from BootstrapOnboardingNoRuntime is now
the canonical no-runtime onboarding seed. The title/description and a
LockAndFindActiveDuplicate-deduped seeder move to
handler/no_runtime_issue.go, and CompleteOnboarding / CreateWorkspace /
AcceptInvitation seed it whenever the workspace has no runtime yet, so
every mark-onboarded entry point lands the user on a concrete next
step.

starter_content_state column is kept and continues to be claimed as
'imported' in all five entry points so older desktop builds (which
still render the legacy dialog on NULL) don't surface it to accounts
created after this change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): backfill starter_content_state for in-window NULL users (MUL-2438)

054 only covered pre-feature users. Anyone onboarded between then and the
starter-content kit removal could still sit at NULL, and old desktop
clients gate the legacy StarterContentPrompt on `starter_content_state
IS NULL`. The import/dismiss routes are gone, so leaving these rows NULL
would surface a dialog whose buttons 404. Mark them 'imported' to match
the new helper's claim semantics.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiayuan Zhang
2026-05-19 18:37:48 +02:00
committed by GitHub
parent cd37b4e3d6
commit 591e47842d
28 changed files with 394 additions and 2327 deletions

View File

@@ -40,7 +40,7 @@ Closes #
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`) and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge

View File

@@ -13,7 +13,6 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
@@ -169,7 +168,6 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>

View File

@@ -4,7 +4,6 @@ import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
@@ -16,7 +15,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<SearchCommand />
<ChatWindow />
<ChatFab />
<StarterContentPrompt />
</>
}
>

View File

@@ -90,7 +90,7 @@ Every event is assigned to one dashboard category:
| Category | Events |
|---|---|
| `core_loop` | `workspace_created`, `runtime_registered`, `runtime_ready`, `runtime_failed`, `runtime_offline`, `agent_created`, `issue_created`, `chat_message_sent`, `agent_task_queued`, `agent_task_dispatched`, `agent_task_started`, `agent_task_completed`, `agent_task_failed`, `agent_task_cancelled`, `autopilot_run_started`, `autopilot_run_completed`, `autopilot_run_failed` |
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected`, `starter_content_decided` |
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected` |
| `acquisition` | `signup`, `download_intent_expressed`, `download_page_viewed`, `download_initiated`, `cloud_waitlist_joined` |
| `ops_feedback` | `feedback_opened`, `feedback_submitted` |
| `system/noise` | `$pageview`, `$set`, `$identify`, `$autocapture`, `$rageclick` |
@@ -470,21 +470,6 @@ in the DB and never broadcast.
the modal's current-workspace context and may be empty when feedback is
sent from a pre-workspace surface.
### `starter_content_decided`
Fires on the atomic NULL → terminal state transition in both
ImportStarterContent and DismissStarterContent. The `branch` property
mirrors what ImportStarterContent would emit for the same workspace,
so import-vs-dismiss rates split cleanly by branch.
| Property | Type | Description |
|---|---|---|
| `decision` | string | `imported` or `dismissed`. |
| `branch` | string | `agent_guided` (workspace had ≥1 agent at decision time) or `self_serve` (no agents). |
`distinct_id` is the user's id; `workspace_id` is attached from the
request payload.
### Frontend-only events
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on

View File

@@ -186,52 +186,6 @@ const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
issue_id: "",
};
// --- Starter content (post-onboarding import) -----------------------------
// Shape mirrors the Go request/response in handler/onboarding.go.
//
// The client sends both branches of sub-issues and an unbound welcome
// issue template (title + description, no `agent_id`). The SERVER picks
// the branch by inspecting the workspace's agent list inside the
// import transaction. This removes the client as a trusted decider —
// even if the client has a stale agent cache or lies, the server uses
// the DB as source of truth.
export interface ImportStarterIssuePayload {
title: string;
description: string;
status: string;
priority: string;
/** Server uses `user_id` (per app-wide AssigneePicker convention)
* as assignee when true. No member_id is threaded through. */
assign_to_self: boolean;
}
export interface ImportStarterWelcomeIssueTemplate {
title: string;
description: string;
/** Defaults to "high" on server when empty. */
priority: string;
}
export interface ImportStarterContentPayload {
workspace_id: string;
project: { title: string; description: string; icon: string };
/** Always sent. Server creates it only when an agent exists in the
* workspace; ignored otherwise. Agent id is picked by the server. */
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
/** Used when the workspace has at least one agent. */
agent_guided_sub_issues: ImportStarterIssuePayload[];
/** Used when the workspace has zero agents. */
self_serve_sub_issues: ImportStarterIssuePayload[];
}
export interface ImportStarterContentResponse {
user: User;
project_id: string;
/** Non-null when server took the agent-guided branch. */
welcome_issue_id: string | null;
}
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
@@ -496,34 +450,6 @@ export class ApiClient {
});
}
/**
* Imports the Getting Started project + optional welcome issue + sub-issues
* in a single server-side transaction. Gated by an atomic
* starter_content_state: NULL → 'imported' claim — a second call returns
* 409 (already decided) and creates nothing new.
*
* The content templates live in TypeScript (see
* @multica/views/onboarding/utils/starter-content-templates) and are
* rendered from the user's questionnaire answers before being sent.
*/
async importStarterContent(
payload: ImportStarterContentPayload,
): Promise<ImportStarterContentResponse> {
return this.fetch("/api/me/starter-content/import", {
method: "POST",
body: JSON.stringify(payload),
});
}
async dismissStarterContent(payload?: {
workspace_id?: string;
}): Promise<User> {
return this.fetch("/api/me/starter-content/dismiss", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}
async updateMe(data: UpdateMeRequest): Promise<User> {
return this.fetch("/api/me", {
method: "PATCH",

View File

@@ -4,13 +4,7 @@ export {
PreviewTooLargeError,
PreviewUnsupportedError,
} from "./client";
export type {
ApiClientOptions,
ImportStarterContentPayload,
ImportStarterContentResponse,
ImportStarterIssuePayload,
ImportStarterWelcomeIssueTemplate,
} from "./client";
export type { ApiClientOptions } from "./client";
export { parseWithFallback, setSchemaLogger } from "./schema";
export type { ParseOptions } from "./schema";
export { DuplicateIssueErrorBodySchema } from "./schemas";

View File

@@ -55,8 +55,8 @@ export async function completeOnboarding(
/**
* Runtime-connected onboarding path. The server creates or reuses the
* default Multica Helper agent and the single onboarding issue, marks
* onboarding complete, and suppresses the older starter-content prompt.
* default Multica Helper agent and the single onboarding issue, then
* marks onboarding complete.
*/
export async function bootstrapRuntimeOnboarding(
workspaceId: string,
@@ -72,8 +72,7 @@ export async function bootstrapRuntimeOnboarding(
/**
* Runtime-skipped onboarding path. The server creates or reuses one
* self-serve onboarding issue, marks onboarding complete, and suppresses
* the older starter-content prompt so the user is not flooded with tasks.
* install-runtime onboarding issue and marks onboarding complete.
*/
export async function bootstrapNoRuntimeOnboarding(
workspaceId: string,

View File

@@ -39,14 +39,11 @@ export interface User {
*/
onboarding_questionnaire: Record<string, unknown>;
/**
* Terminal state for the post-onboarding "import starter content" prompt.
* null → new user, dialog will show on issues-list landing
* 'imported' → accepted, starter project + issues were seeded
* 'dismissed' → declined, never ask again
* 'skipped_legacy' → backfilled for users who finished onboarding
* before this feature shipped
* Kept as a generic `string | null` here so future states (e.g.
* 'retry_after_error') can be added without churning this type.
* Legacy column from the removed starter-content dialog. The column is
* still written to (always 'imported' for new accounts after the
* mark-onboarded paths run) so older desktop builds — which still render
* the dialog on NULL — don't show it to anyone created on a newer server.
* Kept as `string | null` for forward compatibility.
*/
starter_content_state: string | null;
/** Preferred UI language. null means "follow client/system". */

View File

@@ -113,17 +113,6 @@
"step1_label": "Install the Multica CLI",
"step2_label": "Start the daemon"
},
"starter_content": {
"title": "Welcome — add starter tasks?",
"description_prefix": "A ",
"description_term": "Getting Started",
"description_suffix": " project with short tasks that walk through how agents, issues, and context work in Multica.",
"dismiss_action": "Start blank workspace",
"import_action": "Add starter tasks",
"success_toast": "Starter tasks added — check your sidebar",
"import_failed": "Import failed — please retry",
"dismiss_failed": "Could not dismiss — please retry"
},
"cloud_waitlist": {
"intro_main": "Cloud runtimes aren't live yet. Leave your email and we'll reach out when they are.",
"intro_warning": "Heads-up: agents can't execute tasks without a runtime — if you hit Skip now, your workspace is read-only until you come back and install one.",

View File

@@ -113,17 +113,6 @@
"step1_label": "安装 Multica CLI",
"step2_label": "启动守护进程"
},
"starter_content": {
"title": "欢迎 —— 要不要添加入门 task",
"description_prefix": "一个 ",
"description_term": "上手指南",
"description_suffix": " 项目,包含若干简短 task带你了解 Multica 中的智能体、issue 和上下文是怎么工作的。",
"dismiss_action": "从空白工作区开始",
"import_action": "添加入门 task",
"success_toast": "入门 task 已添加,看一下侧边栏",
"import_failed": "导入失败,请重试",
"dismiss_failed": "跳过失败,请重试"
},
"cloud_waitlist": {
"intro_main": "云运行时尚未上线。留下邮箱,上线时通过邮件通知你。",
"intro_warning": "提示:没有运行时,智能体无法执行 task —— 如果现在跳过,工作区会是只读状态,直到你回来安装一个。",

View File

@@ -1,229 +0,0 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useNavigation } from "@multica/views/navigation";
import { useCurrentWorkspace, paths } from "@multica/core/paths";
import type { QuestionnaireAnswers } from "@multica/core/onboarding";
import { pinKeys } from "@multica/core/pins";
import { projectKeys } from "@multica/core/projects";
import { issueKeys } from "@multica/core/issues/queries";
import {
memberListOptions,
workspaceKeys,
} from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
buildImportPayload,
type StarterContentLocale,
} from "../utils/starter-content-templates";
import { useT } from "../../i18n";
/**
* Post-onboarding opt-in dialog.
*
* Shown exactly once per user, on the first workspace landing where
* `user.starter_content_state === null`. The dialog is mandatory —
* Import and Dismiss are the only exits. Both are terminal state
* transitions server-side (NULL → 'imported' or NULL → 'dismissed'),
* so the dialog never reappears on a subsequent visit.
*
* Client-side knowledge of agents is INTENTIONALLY zero here. The
* dialog description is branch-agnostic and the POST payload carries
* both sub-issue template arrays plus a welcome-issue template. The
* SERVER inspects the workspace's agent list and picks the branch —
* no client-side cache timing, no stale decisions, no Unknown bugs.
*/
export function StarterContentPrompt() {
const { t, i18n } = useT("onboarding");
const workspace = useCurrentWorkspace();
const user = useAuthStore((s) => s.user);
const refreshMe = useAuthStore((s) => s.refreshMe);
const { push } = useNavigation();
const qc = useQueryClient();
const [submitting, setSubmitting] = useState<"import" | "dismiss" | null>(
null,
);
// Member-list fetch is the proxy we use to detect "did this user CREATE
// this workspace, or were they invited into it?" An invitee is by definition
// not the only member (the inviter is also there); a fresh self-created
// workspace has exactly one member — the creator. `starter_content_state`
// is a user-level field and can't represent (user, workspace) state directly,
// so we layer this membership check on top until that field is migrated to
// the `member` table. See follow-up issue: starter_content_state per-workspace.
const { data: members = [] } = useQuery({
...memberListOptions(workspace?.id ?? ""),
enabled: !!workspace?.id,
});
const isSoloMember =
members.length === 1 && members[0]?.user_id === user?.id;
const shouldShow =
!!user &&
!!workspace &&
user.onboarded_at != null &&
user.starter_content_state == null &&
isSoloMember;
if (!shouldShow || !workspace || !user) return null;
const onImport = async () => {
if (submitting) return;
setSubmitting("import");
try {
const questionnaire = mergeQuestionnaire(user.onboarding_questionnaire);
const payload = buildImportPayload({
workspaceId: workspace.id,
userName: user.name || user.email,
questionnaire,
locale: resolveLocale(i18n.language),
});
const result = await api.importStarterContent(payload);
// Mirror the `onSettled` pattern used by other mutations
// (useCreatePin / useDeletePin / useReorderPins): the originating
// session invalidates locally so the sidebar + board refresh
// synchronously, independent of the WS round-trip. The server still
// publishes `pin:created` / `project:created` / `issue:created` for
// OTHER sessions; on this session both paths run and the second
// invalidate is a no-op.
//
// Agents are invalidated too: the server picks the welcome issue's
// assignee from its own agent list, and the issue-detail page we
// navigate to immediately resolves that ID through the cached agent
// list. If the cache is stale (or never populated since
// onboarding-flow created the agent without invalidating), the
// assignee renders as "Unknown Agent". Awaiting Promise.all
// guarantees every relevant query is at least marked stale before
// the navigation kicks in, so the next mount refetches.
await Promise.all([
qc.invalidateQueries({ queryKey: pinKeys.all(workspace.id, user.id) }),
qc.invalidateQueries({ queryKey: projectKeys.all(workspace.id) }),
qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }),
qc.invalidateQueries({ queryKey: workspaceKeys.agents(workspace.id) }),
]);
// Sync the new starter_content_state into the auth store so this
// component unmounts cleanly on the next render.
await refreshMe();
toast.success(t(($) => $.starter_content.success_toast));
// If the server took the agent-guided branch, a welcome issue
// exists and we jump to it. Otherwise, stay on the issues list —
// the new Getting Started project appears via realtime events.
if (result.welcome_issue_id) {
push(
paths.workspace(workspace.slug).issueDetail(result.welcome_issue_id),
);
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : t(($) => $.starter_content.import_failed),
);
setSubmitting(null);
}
};
const onDismiss = async () => {
if (submitting) return;
setSubmitting("dismiss");
try {
await api.dismissStarterContent({ workspace_id: workspace.id });
await refreshMe();
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t(($) => $.starter_content.dismiss_failed),
);
setSubmitting(null);
}
};
return (
<Dialog
open
// `disablePointerDismissal` stops outside-click close; the
// `onOpenChange` handler cancels Base UI's ESC-close path via
// `eventDetails.cancel()`. Import / Dismiss are the only exits.
disablePointerDismissal
onOpenChange={(_open, eventDetails) => {
eventDetails.cancel();
}}
>
<DialogContent showCloseButton={false} className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="text-balance font-serif text-[22px] leading-[1.2] font-medium tracking-tight">
{t(($) => $.starter_content.title)}
</DialogTitle>
<DialogDescription className="pt-2 text-[14px] leading-[1.55]">
{t(($) => $.starter_content.description_prefix)}
<span className="font-medium text-foreground">
{t(($) => $.starter_content.description_term)}
</span>
{t(($) => $.starter_content.description_suffix)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2 gap-2 sm:justify-end">
<Button
variant="ghost"
onClick={onDismiss}
disabled={submitting !== null}
>
{submitting === "dismiss" && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{t(($) => $.starter_content.dismiss_action)}
</Button>
<Button onClick={onImport} disabled={submitting !== null}>
{submitting === "import" && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{t(($) => $.starter_content.import_action)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// i18next resolves locale names like "zh-Hans-CN" or "en-US"; we only
// ship en + zh-Hans starter content, so default everything else to en.
function resolveLocale(language: string): StarterContentLocale {
return language.startsWith("zh") ? "zh-Hans" : "en";
}
// Local helper — mirrors the onboarding flow's mergeQuestionnaire.
function mergeQuestionnaire(
raw: Record<string, unknown>,
): QuestionnaireAnswers {
const empty: QuestionnaireAnswers = {
source: null,
source_other: null,
source_skipped: false,
role: null,
role_other: null,
role_skipped: false,
use_case: null,
use_case_other: null,
use_case_skipped: false,
version: 2,
};
return { ...empty, ...(raw as Partial<QuestionnaireAnswers>) };
}

View File

@@ -1,4 +1,3 @@
export { OnboardingFlow, type OnboardingStep } from "./onboarding-flow";
export { CliInstallInstructions } from "./steps/cli-install-instructions";
export { StarterContentPrompt } from "./components/starter-content-prompt";
export { CloudWaitlistExpand } from "./components/cloud-waitlist-expand";

View File

@@ -65,7 +65,7 @@ function mergeQuestionnaire(
* straight into that onboarding issue; otherwise navigate into the
* workspace issues list. Runtime-connected onboarding creates one
* Multica Helper agent plus one issue; runtime-skipped onboarding creates one
* self-serve issue. Both suppress the old starter-content prompt.
* self-serve install-runtime issue.
*/
export function OnboardingFlow({
onComplete,
@@ -172,11 +172,10 @@ export function OnboardingFlow({
// "I've done this before" path — returning user who already has a
// workspace and just wants to land there. Marks onboarding complete
// server-side (idempotent via COALESCE on onboarded_at) and navigates
// to their first workspace. Because starter_content_state is NULL for
// any user reaching this button (it's freshly added), they'll see the
// StarterContentPrompt dialog on arrival — which is correct, since
// they never got a starter project and may want one now.
// server-side (idempotent via COALESCE on onboarded_at); when the
// target workspace has no runtime yet, the server seeds the same
// install-runtime issue as Step 3 Skip so the user lands on a
// concrete next step.
const handleWelcomeSkip = useCallback(async () => {
try {
await completeOnboarding("skip_existing", workspaces[0]?.id);
@@ -203,8 +202,8 @@ export function OnboardingFlow({
if (!workspace) return;
if (!rt) {
// No runtime -> no agent execution yet. Create one focused
// self-serve onboarding issue instead of seeding the older
// multi-issue starter project.
// install-runtime onboarding issue so the user lands on a
// concrete next step.
try {
const result = await bootstrapNoRuntimeOnboarding(workspace.id);
await qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) });

View File

@@ -16,8 +16,7 @@ import { useT } from "../../i18n";
* Runtime-skipped finalizer. The runtime-connected path now bootstraps one
* default assistant plus one onboarding issue server-side and routes there
* directly. This step remains for users who skip runtime connection: it only
* flips `onboarded_at`, then lands them in the workspace where the self-serve
* starter-content prompt can run.
* flips `onboarded_at` and lands them in the workspace.
* Two consequences of that move:
*
* 1. This step can't fail in user-visible ways any more. `completeOnboarding`

View File

@@ -18,7 +18,7 @@ import { useT } from "../../i18n";
/**
* Step 2 — "Which best describes you?" Primary signal for the
* onboarding assistant and starter issue content.
* onboarding assistant.
*/
export function StepRole({
answers,

View File

@@ -1,593 +0,0 @@
import type { QuestionnaireAnswers } from "@multica/core/onboarding";
import type { ImportStarterIssuePayload } from "@multica/core/api";
// =============================================================================
// English starter-content body. Long-form markdown lives here (TypeScript,
// reviewed as UI). The orchestrator in starter-content-templates.ts picks
// between this file and starter-content-content-zh.ts based on the user's
// locale, then hands the result to buildImportPayload.
// =============================================================================
export const PROJECT = {
title: "Getting Started",
description:
"A few things to try in Multica. Work through them at your own pace.",
};
interface WelcomeIssueText {
title: string;
description: string;
}
export function buildWelcomeIssueText(
q: QuestionnaireAnswers,
userName: string,
): WelcomeIssueText {
const name = userName.trim() || "there";
const header = [
`Welcome to Multica! 👋`,
``,
`This is your workspace's first issue. Below, your agent will reply in a moment — that's how work happens here: you write what you want, your agent (or a teammate) picks it up and replies in the comments.`,
``,
`[Learn how Multica works →](https://multica.ai/docs/how-multica-works)`,
``,
`---`,
``,
].join("\n");
const sharedInstructions = [
`In your first reply, please:`,
``,
`1. **Introduce yourself briefly** — your name, your role, what you're good at.`,
`2. **Explain how we work together in Multica**:`,
` - Assigning an issue to you **and** setting its status to **Todo** is what triggers you to start (Backlog pauses you)`,
` - @mentioning you inside a comment is for quick questions`,
` - **Workspace Context** (in Settings → General) is shared background every agent here sees`,
`3. **Point them at the *Getting Started* project** in the sidebar and invite them to assign you a real task when they're ready.`,
``,
`Keep it friendly and under 200 words. End with one short question that invites ${name} to reply.`,
].join("\n");
const exploreInstructions = [
`In your first reply, please:`,
``,
`1. **Introduce yourself briefly** — your name, your role, what you're good at.`,
`2. **Explain how we work together in Multica**:`,
` - Assigning an issue to you **and** setting its status to **Todo** triggers you to start (Backlog pauses you)`,
` - @mentioning you inside a comment is for quick questions`,
` - **Workspace Context** (in Settings → General) is shared background every agent here sees`,
`3. **Point them at the *Getting Started* project** in the sidebar.`,
``,
`Keep it friendly and under 200 words. End with a small, curious question — something like "what's something you've been wondering about lately?" — so ${name} has an easy way to reply without having to come up with a real task yet.`,
].join("\n");
switch (q.use_case) {
case "ship_code":
return {
title: "👋 Welcome to Multica — let's work together",
description: `${header}Hi agent, this is ${name}'s first time using Multica. They plan to use you mostly for **shipping code**.\n\n${sharedInstructions}`,
};
case "manage_team":
case "plan_research":
return {
title: "👋 Welcome to Multica — let's work together",
description: `${header}Hi agent, this is ${name}'s first time using Multica. They want your help with **planning and breaking down work**.\n\n${sharedInstructions}`,
};
case "write_publish":
return {
title: "👋 Welcome to Multica — let's work together",
description: `${header}Hi agent, this is ${name}'s first time using Multica. They'll use you for **writing and publishing** — drafting, editing, summarizing.\n\n${sharedInstructions}`,
};
case "evaluate":
return {
title: "👋 Welcome to Multica — let's work together",
description: `${header}Hi agent, this is ${name}'s first time using Multica. They're **exploring** what Multica can do — no specific goal yet.\n\n${exploreInstructions}`,
};
case "personal_tasks":
case "automate_ops":
return {
title: "👋 Welcome to Multica — let's work together",
description: `${header}Hi agent, this is ${name}'s first time using Multica. They want your help with **day-to-day tasks and automation**.\n\n${sharedInstructions}`,
};
case "other": {
const customUseCase = (q.use_case_other ?? "").trim();
const contextLine = customUseCase
? `They told us they want to use you for: "${customUseCase}".`
: `They haven't narrowed down their use case yet.`;
return {
title: "👋 Welcome to Multica — let's work together",
description: `${header}Hi agent, this is ${name}'s first time using Multica. ${contextLine}\n\n${sharedInstructions}`,
};
}
default:
return {
title: "👋 Welcome to Multica — let's work together",
description: `${header}Hi agent, this is ${name}'s first time using Multica.\n\n${sharedInstructions}`,
};
}
}
export function buildAgentGuidedSubIssues(
q: QuestionnaireAnswers,
): ImportStarterIssuePayload[] {
const tier1: ImportStarterIssuePayload[] = [
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "Learn how to trigger your agent on any issue",
description: [
`**Every issue has a right-side panel** called **Properties**. From there you control who works on what. Agents in Multica are triggered when an issue has:`,
``,
` Assignee = your agent AND Status = Todo (not Backlog)`,
``,
`**Try it now**:`,
`1. In the sidebar, click **New Issue** at the top (or press \`C\`)`,
`2. Give it a title like "Test run: summarize our product in 3 bullets"`,
`3. On the right panel, find **Assignee** → click → pick your agent`,
`4. Find **Status** → click → pick **Todo**`,
`5. Scroll down to Activity — a **Live card** appears as your agent starts working`,
``,
`**⚠️ Gotcha**: new issues default to Backlog. Agents pause on backlog. A hint dialog will pop up the first time — it's telling you "flip to Todo to start".`,
``,
`**You'll know it worked when**: the Live card shows your agent thinking, and the Status flips to **In Progress** automatically.`,
``,
`[Learn about assigning issues →](https://multica.ai/docs/assigning-issues)`,
].join("\n"),
},
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "Chat with your agent — no issue required",
description: [
`Not every question needs a whole issue. For quick back-and-forth, use the **Chat panel**.`,
``,
`**Where to find it**: look at the **bottom-right corner of the screen** — there's a round button with a **💬 speech bubble icon**. If your agent is working, the button pulses. If there are unread replies, a red badge sits on top of it.`,
``,
`**Try it now**:`,
`1. Click the 💬 button → a panel slides in from the right`,
`2. At the **bottom-left of the input box**, click the agent avatar → pick your agent from the dropdown`,
`3. Type a quick question: "What can you help me with in this workspace?"`,
`4. Press **Enter**`,
``,
`**Bonus — @mention an agent inside a comment**: on any issue, scroll to the comment box at the bottom. Type \`@\` and a dropdown appears listing members, agents, and other issues. Pick an agent → write your question → send. The mentioned agent replies in the comments.`,
``,
`**You'll know it worked when**: the agent replies in the chat panel (or comment thread) within a few seconds.`,
``,
`[Learn about chat →](https://multica.ai/docs/chat)`,
].join("\n"),
},
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "Write your Workspace Context",
description: [
`**Workspace Context** is a shared system prompt every agent in this workspace reads before starting any task. It's the single most impactful thing you can do to make agent replies sharper.`,
``,
`**Where to find it**:`,
`1. Open the **sidebar** → scroll to the bottom section labeled **Configure**`,
`2. Click **Settings** (⚙️ gear icon, bottom-most item)`,
`3. In the left-side tab list, under the **[Your Workspace Name]** group, click **General**`,
`4. Scroll down to the **Context** textarea (placeholder says "Provide context to agents...")`,
``,
`**Fill it with 3-5 lines**:`,
`- Who you are (name, role)`,
`- What you're building or working on`,
`- How agents should behave (tone, style, defaults)`,
``,
`**Example**:`,
`> I'm a frontend engineer working on an AI-native task manager. Reply concisely in English. Always explain your reasoning. Prefer TypeScript over JavaScript.`,
``,
`Click **Save**.`,
``,
`**You'll know it worked when**: the next task you assign to an agent picks up details from this context without you explaining again.`,
``,
`[Learn about workspaces →](https://multica.ai/docs/workspaces)`,
].join("\n"),
},
];
const tier2: ImportStarterIssuePayload[] = [];
if (q.use_case === "manage_team") {
tier2.push({
status: "todo",
priority: "medium",
assign_to_self: true,
title: "Invite your teammates",
description: [
`Multica works best when a small team shares agents.`,
``,
`**Where to find it**:`,
`1. Sidebar → **Settings** (⚙️, bottom)`,
`2. Left tab list → under **[Your Workspace]** group → click **Members** (people icon)`,
`3. At the top of the page, click **Add member**`,
`4. Enter their email, pick a role (**Owner / Admin / Member**)`,
`5. Click **Send invite**`,
``,
`They'll receive an email with a join link. Pending invites show in the collapsible "Pending Invitations" section below the member list — you can revoke from there.`,
``,
`[Learn about members and roles →](https://multica.ai/docs/members-roles)`,
].join("\n"),
});
}
if (q.role === "engineer" || q.use_case === "ship_code") {
tier2.push({
status: "todo",
priority: "medium",
assign_to_self: true,
title: "Connect a Git repo",
description: [
`Once connected, any agent can clone, read, and propose changes to your repo when you assign it a task.`,
``,
`**Where to find it**:`,
`1. Sidebar → **Settings** (⚙️)`,
`2. Left tab list → under **[Your Workspace]** group → **Repositories** (folder with Git branch icon)`,
`3. At the bottom of the list, click **+ Add repository**`,
`4. Fill the two inline fields:`,
` - **URL** — e.g. \`https://github.com/you/repo.git\``,
` - **Description** — what this repo is for`,
`5. Click **Save** at the top of the page`,
``,
`Repeat for as many repos as you want to expose.`,
].join("\n"),
});
}
tier2.push({
status: "todo",
priority: "medium",
assign_to_self: true,
title: "Create a second agent with a different role",
description: [
`Running a small team of focused agents beats a single generalist. One for coding, one for planning, one for writing — each with their own instructions.`,
``,
`**Note**: nothing locks a "Coding Agent" to coding. Instructions are just a system prompt, editable anytime. The split is about keeping each one's replies sharp.`,
``,
`**Where to find it**:`,
`1. Sidebar → under **Workspace** group → click **Agents** (🤖 bot icon)`,
`2. In the left list header, click the **+** button (top-right corner of the list)`,
`3. Fill the 4 fields in order:`,
` - **Name** — e.g. "Planning Agent"`,
` - **Description** — "Breaks down loose ideas into scoped work"`,
` - **Visibility** — Workspace (shared) or Private (only you)`,
` - **Runtime** — pick from the dropdown (your connected runtime)`,
`4. Click **Create**`,
``,
`**You'll know it worked when**: the new agent appears in the Assignee dropdown on any issue, and shows up in the left list on the Agents page.`,
``,
`[Learn about creating agents →](https://multica.ai/docs/agents-create)`,
].join("\n"),
});
const tier3: ImportStarterIssuePayload[] = [
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Polish your agent's Instructions",
description: [
`Creating an agent is just the start. The **Instructions tab** is where you shape how it behaves.`,
``,
`**Where to find it**:`,
`1. Sidebar → **Agents** (🤖)`,
`2. In the left list, click an agent you want to refine`,
`3. In the right panel, you'll see tabs at the top including **Instructions / Skills / Tasks / Settings**`,
`4. Click **Instructions**`,
`5. Edit the markdown — changes save automatically`,
``,
`**Good instructions include**:`,
`- The role/persona (e.g. "You're a senior TypeScript engineer")`,
`- House rules (e.g. "Always propose tests alongside code")`,
`- Output format (e.g. "Return a short summary first, details below")`,
``,
`Workspace Context and agent Instructions stack — both are sent on every task. Keep Instructions specific to this agent; keep Context specific to the whole workspace.`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Watch your agent live in action",
description: [
`**Heads-up task** — nothing to do now, just know this exists.`,
``,
`When an agent is working on an issue, a **Live card** appears at the top of the **Activity** section (it sticks to the top of the viewport as you scroll).`,
``,
`The card shows in real time:`,
`- Which tool the agent is calling (e.g. reading a file, web search)`,
`- Streaming thoughts and partial output`,
`- Current status (thinking / tool-running / done / failed)`,
``,
`After the run finishes, the **Task Run History** below the card lists every past run. Click **View transcript** on any row to open the full interactive transcript — a timeline of every message, thinking step, tool call, and result.`,
``,
`**Try it next time you assign an agent**: keep the issue open and watch the Live card appear below the description.`,
``,
`[Learn about tasks →](https://multica.ai/docs/tasks)`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Check your Inbox for @mentions and updates",
description: [
`When someone — or an agent — @mentions you or assigns you an issue, it lands in your **Inbox**.`,
``,
`**Where to find it**:`,
`1. Sidebar → the top section (above the **Workspace** group) → click **Inbox** (📥 icon) — an unread count badge shows on the right if you have new items`,
``,
`**How it works**:`,
`- Left column: notification list, newest first`,
`- Right column: the linked issue opens inline, and the specific comment that mentioned you is **auto-highlighted and scrolled into view**`,
`- Top-right dropdown: **Mark all as read / Archive all / Archive all read / Archive completed** for bulk cleanup`,
``,
`**Tip**: "Archive completed" is the fastest way to clear the noise from issues already finished.`,
``,
`[Learn about the inbox →](https://multica.ai/docs/inbox)`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Set up an Autopilot for recurring work",
description: [
`**Autopilot** turns a prompt into a scheduled task. Every day/week/hour, it auto-creates an issue and assigns it to an agent.`,
``,
`**Where to find it**:`,
`1. Sidebar → under **Workspace** group → click **Autopilot** (⚡ Zap icon)`,
`2. If you have no autopilots yet, a grid of templates shows up — pick any one to pre-fill the dialog, or click **+ New autopilot** for a blank one`,
`3. Fill: **Name** / **Prompt** / **Agent** / **Schedule** (frequency + time + timezone)`,
`4. Click **Create**`,
``,
`**Good first autopilots**: daily digest of GitHub activity, weekly "what's blocked" check, or a Monday-morning triage of any issues still in Backlog.`,
``,
`[Learn about autopilots →](https://multica.ai/docs/autopilots)`,
].join("\n"),
},
];
return [...tier1, ...tier2, ...tier3];
}
export function buildSelfServeSubIssues(
q: QuestionnaireAnswers,
): ImportStarterIssuePayload[] {
const tier1: ImportStarterIssuePayload[] = [
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "Install a runtime (Desktop app or CLI)",
description: [
`**Why this first**: no runtime = no agents can execute. Everything below Tier 1 waits on this.`,
``,
`A **runtime** pairs the daemon (a small background process on your machine) with one AI coding tool — Claude Code, Codex, and so on. If you have several tools installed, you'll see one runtime per tool. The runtime is what executes the tasks your agents pick up.`,
``,
`**Option A — Desktop app (macOS, recommended if you're on a Mac)**:`,
`1. Go to [github.com/multica-ai/multica/releases/latest](https://github.com/multica-ai/multica/releases/latest) and download the \`.dmg\` for macOS`,
`2. Install and open the app`,
`3. Sign in with the same account — the daemon is built in, you're done`,
``,
`**Option B — CLI (macOS, Linux, or Windows via WSL)**:`,
`1. In a terminal, install the CLI:`,
` \`\`\``,
` curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash`,
` \`\`\``,
`2. Then run setup (signs you in and starts a background daemon):`,
` \`\`\``,
` multica setup`,
` \`\`\``,
` The daemon keeps running after you close the terminal — you don't have to leave anything open.`,
``,
`**Verify**: sidebar → bottom **Configure** section → **Runtimes** → you should see at least one connected runtime.`,
``,
`[Learn about runtimes →](https://multica.ai/docs/daemon-runtimes)`,
].join("\n"),
},
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "Create your first agent",
description: [
`**Prerequisite**: task above done (runtime connected).`,
``,
`**Where to find it**:`,
`1. Sidebar → under **Workspace** group → click **Agents** (🤖 bot icon)`,
`2. In the left list header, click the **+** button (top-right corner of the list)`,
`3. Fill the 4 fields in order:`,
` - **Name** — e.g. "My Coding Agent"`,
` - **Description** — one line about what it does`,
` - **Visibility** — Workspace (shared) or Private (only you)`,
` - **Runtime** — pick the one you just installed`,
`4. Click **Create**`,
``,
`**Note**: an agent is just an LLM + instructions + workspace access. Nothing locks a "Coding Agent" to coding — same agent can do research, writing, planning. Keep it flexible.`,
``,
`**You'll know it worked when**: the new agent appears in the Assignee dropdown on any issue.`,
``,
`[Learn about creating agents →](https://multica.ai/docs/agents-create)`,
].join("\n"),
},
];
const tier2: ImportStarterIssuePayload[] = [
{
status: "todo",
priority: "medium",
assign_to_self: true,
title: "Assign your first real task to your agent",
description: [
`**Prerequisite**: you have a runtime + agent from the two tasks above.`,
``,
`**How Multica triggers agents**:`,
`- Assign an issue to an agent`,
`- Set status to **Todo** (not Backlog — backlog pauses agents)`,
`- The agent picks it up automatically`,
``,
`**Try it now**:`,
`1. In the sidebar, click **New Issue** at the top (or press \`C\`)`,
`2. Title: something you actually want done`,
`3. On the right panel, find **Assignee** → click → pick your agent`,
`4. Find **Status** → change from Backlog to **Todo**`,
`5. Watch the agent reply in the comments and a **Live card** appear in Activity`,
``,
`**⚠️ Gotcha**: new issues default to **Backlog**. You must flip to **Todo** to trigger the agent.`,
``,
`[Learn about assigning issues →](https://multica.ai/docs/assigning-issues)`,
].join("\n"),
},
{
status: "todo",
priority: "medium",
assign_to_self: true,
title: "Write your Workspace Context",
description: [
`**Workspace Context** is a shared system prompt every agent in this workspace reads before starting any task. It's the single most impactful thing you can do to make agent replies sharper.`,
``,
`**Where to find it**:`,
`1. Open the **sidebar** → scroll to the bottom section labeled **Configure**`,
`2. Click **Settings** (⚙️ gear icon, bottom-most item)`,
`3. In the left-side tab list, under the **[Your Workspace Name]** group, click **General**`,
`4. Scroll down to the **Context** textarea`,
``,
`**Fill it with 3-5 lines**:`,
`- Who you are (name, role)`,
`- What you're building or working on`,
`- How agents should behave (tone, style, defaults)`,
``,
`Click **Save**.`,
``,
`**You'll know it worked when**: the next task you assign to an agent picks up details from this context without you explaining again.`,
``,
`[Learn about workspaces →](https://multica.ai/docs/workspaces)`,
].join("\n"),
},
];
const tier3: ImportStarterIssuePayload[] = [
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Chat with an agent — once you've created one",
description: [
`**Prerequisite**: you've created at least one agent (Tier 1 #2).`,
``,
`Not every question needs a whole issue. For quick back-and-forth, use the **Chat panel**.`,
``,
`**Where to find it**: the **bottom-right corner of the screen** has a round button with a **💬 speech bubble icon**.`,
``,
`**Try it**:`,
`1. Click the 💬 button → a panel slides in from the right`,
`2. At the bottom-left of the input box, pick an agent from the dropdown`,
`3. Type a question → press **Enter**`,
``,
`**Bonus**: inside any issue's comment box, type \`@\` to mention an agent or member.`,
``,
`[Learn about chat →](https://multica.ai/docs/chat)`,
].join("\n"),
},
];
if (q.role === "engineer" || q.use_case === "ship_code") {
tier3.push({
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Connect a Git repo",
description: [
`Once connected, any agent can clone, read, and propose changes to your repo when you assign it a task.`,
``,
`**Where to find it**:`,
`1. Sidebar → **Settings** (⚙️)`,
`2. Left tab list → **Repositories** (folder with Git branch icon)`,
`3. At the bottom of the list, click **+ Add repository**`,
`4. Fill **URL** (e.g. \`https://github.com/you/repo.git\`) and **Description**`,
`5. Click **Save** at the top of the page`,
].join("\n"),
});
}
if (q.use_case === "manage_team") {
tier3.push({
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Invite your teammates",
description: [
`Multica works best when a small team shares agents.`,
``,
`**Where to find it**:`,
`1. Sidebar → **Settings** (⚙️, bottom)`,
`2. Left tab list → **Members** (people icon)`,
`3. Click **Add member** → enter email → pick role → **Send invite**`,
``,
`[Learn about members and roles →](https://multica.ai/docs/members-roles)`,
].join("\n"),
});
}
tier3.push(
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Shape your agent's Instructions (once it's created)",
description: [
`**Prerequisite**: you have at least one agent.`,
``,
`Creating an agent is just the start. The **Instructions tab** is where you shape how it behaves.`,
``,
`**Where to find it**:`,
`1. Sidebar → **Agents** (🤖)`,
`2. Click an agent in the left list`,
`3. Right panel → click the **Instructions** tab (alongside Skills / Tasks / Settings)`,
`4. Edit the markdown — changes save automatically`,
``,
`Workspace Context and agent Instructions stack — both are sent on every task. Keep Instructions specific to this agent; keep Context specific to the whole workspace.`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Watch an agent work live (once you've assigned one a task)",
description: [
`**Heads-up task** — nothing to do now, just know this exists.`,
``,
`When an agent is working on an issue, a **Live card** appears at the top of the **Activity** section (it sticks to the top of the viewport as you scroll).`,
``,
`It shows in real time which tool the agent is calling, streaming thoughts, and current status. After the run finishes, the **Task Run History** below the card lists every past run — click **View transcript** to open the full timeline.`,
``,
`[Learn about tasks →](https://multica.ai/docs/tasks)`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "Set up an Autopilot (once you have an agent)",
description: [
`**Prerequisite**: you have at least one agent.`,
``,
`**Autopilot** turns a prompt into a scheduled task. Every day/week/hour, it auto-creates an issue and assigns it to an agent.`,
``,
`**Where to find it**:`,
`1. Sidebar → under **Workspace** group → click **Autopilot** (⚡ Zap icon)`,
`2. Pick a template, or click **+ New autopilot** for a blank one`,
`3. Fill: **Name** / **Prompt** / **Agent** / **Schedule** (frequency + time + timezone) → **Create**`,
``,
`[Learn about autopilots →](https://multica.ai/docs/autopilots)`,
].join("\n"),
},
);
return [...tier1, ...tier2, ...tier3];
}

View File

@@ -1,596 +0,0 @@
import type { QuestionnaireAnswers } from "@multica/core/onboarding";
import type { ImportStarterIssuePayload } from "@multica/core/api";
// =============================================================================
// Chinese starter-content body. Mirrors starter-content-content-en.ts in
// shape; translated and adapted to the conventions in
// apps/docs/content/docs/developers/conventions.zh.mdx — task / issue /
// skill stay lowercase English; agent / runtime / daemon / workspace are
// translated; product UI labels (Properties, Assignee, Status, Activity,
// Live card, Inbox, Members, Settings, Runtimes, Configure, Workspace,
// Repositories, Instructions, Tasks, Skills, Autopilot, etc.) stay in
// English with English code-style framing matching the actual UI.
// =============================================================================
export const PROJECT = {
title: "上手指南",
description: "几件可以在 Multica 里上手试一试的事,按你的节奏走。",
};
interface WelcomeIssueText {
title: string;
description: string;
}
export function buildWelcomeIssueText(
q: QuestionnaireAnswers,
userName: string,
): WelcomeIssueText {
const name = userName.trim() || "你";
const header = [
`欢迎来到 Multica👋`,
``,
`这是你工作区里的第一个 issue。下面你的智能体马上会回复——这就是 Multica 里工作的方式:你写下你想做的事,智能体(或同事)接手并在评论里回复。`,
``,
`[了解 Multica 是怎么运转的 →](https://multica.ai/docs/zh/how-multica-works)`,
``,
`---`,
``,
].join("\n");
const sharedInstructions = [
`请你在第一条回复里:`,
``,
`1. **简短地自我介绍** —— 名字、定位、擅长的事。`,
`2. **说明我们在 Multica 里怎么协作**`,
` - 把 issue 分配给你 **并** 把状态置为 **Todo** 才会触发你开工Backlog 状态会让你暂停)`,
` - 在评论里 @你 适合丢一个快速问题`,
` - **Workspace Context**(在 Settings → General是这个工作区里每个智能体都会读到的共享背景`,
`3. **把 ${name} 引到侧边栏的 *上手指南* 项目**,邀请 ${name} 准备好后给你分配一个真实的 task。`,
``,
`语气友好、不超过 200 字。结尾抛一个简短的小问题让 ${name} 容易回复。`,
].join("\n");
const exploreInstructions = [
`请你在第一条回复里:`,
``,
`1. **简短地自我介绍** —— 名字、定位、擅长的事。`,
`2. **说明我们在 Multica 里怎么协作**`,
` - 把 issue 分配给你 **并** 把状态置为 **Todo** 才会触发你开工Backlog 状态会让你暂停)`,
` - 在评论里 @你 适合丢一个快速问题`,
` - **Workspace Context**(在 Settings → General是这个工作区里每个智能体都会读到的共享背景`,
`3. **把 ${name} 引到侧边栏的 *上手指南* 项目**。`,
``,
`语气友好、不超过 200 字。结尾抛一个轻松的小问题——比如"最近你在琢磨什么有意思的事?"——让 ${name} 不必先想好一个真实任务也能轻松回复。`,
].join("\n");
switch (q.use_case) {
case "ship_code":
return {
title: "👋 欢迎来到 Multica —— 一起开工",
description: `${header}你好智能体,这是 ${name} 第一次用 Multica。${name} 主要会让你做 **编码相关的工作**。\n\n${sharedInstructions}`,
};
case "manage_team":
case "plan_research":
return {
title: "👋 欢迎来到 Multica —— 一起开工",
description: `${header}你好智能体,这是 ${name} 第一次用 Multica。${name} 希望你帮忙做 **规划与拆解工作**。\n\n${sharedInstructions}`,
};
case "write_publish":
return {
title: "👋 欢迎来到 Multica —— 一起开工",
description: `${header}你好智能体,这是 ${name} 第一次用 Multica。${name} 会让你做 **写作、编辑、发布** —— 起草、摘要、分析。\n\n${sharedInstructions}`,
};
case "evaluate":
return {
title: "👋 欢迎来到 Multica —— 一起开工",
description: `${header}你好智能体,这是 ${name} 第一次用 Multica。${name} 还在 **探索** Multica 能做什么 —— 暂时没有具体目标。\n\n${exploreInstructions}`,
};
case "personal_tasks":
case "automate_ops":
return {
title: "👋 欢迎来到 Multica —— 一起开工",
description: `${header}你好智能体,这是 ${name} 第一次用 Multica。${name} 希望你帮忙做 **日常任务和自动化**。\n\n${sharedInstructions}`,
};
case "other": {
const customUseCase = (q.use_case_other ?? "").trim();
const contextLine = customUseCase
? `${name} 告诉我们想让你做的事是:"${customUseCase}"。`
: `${name} 还没明确具体的使用场景。`;
return {
title: "👋 欢迎来到 Multica —— 一起开工",
description: `${header}你好智能体,这是 ${name} 第一次用 Multica。${contextLine}\n\n${sharedInstructions}`,
};
}
default:
return {
title: "👋 欢迎来到 Multica —— 一起开工",
description: `${header}你好智能体,这是 ${name} 第一次用 Multica。\n\n${sharedInstructions}`,
};
}
}
export function buildAgentGuidedSubIssues(
q: QuestionnaireAnswers,
): ImportStarterIssuePayload[] {
const tier1: ImportStarterIssuePayload[] = [
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "学会怎么在任意 issue 上触发你的智能体",
description: [
`**每个 issue 右侧都有一个 Properties 面板**。从这里控制谁来做什么。Multica 里的智能体被触发的条件是 issue 同时满足:`,
``,
` Assignee = 你的智能体 AND Status = Todo不是 Backlog`,
``,
`**现在就试一下**`,
`1. 在侧边栏顶部点 **New Issue**(或按 \`C\``,
`2. 标题写成类似"试运行:用 3 条 bullet 总结我们的产品"`,
`3. 在右侧面板找到 **Assignee** → 点击 → 选你的智能体`,
`4. 找到 **Status** → 点击 → 选 **Todo**`,
`5. 滚动到 Activity —— 智能体一开工就会出现一张 **Live card**`,
``,
`**⚠️ 容易踩**:新建的 issue 默认是 Backlog 状态,智能体在 Backlog 是暂停的。第一次会弹一个提示——意思就是"翻到 Todo 才会开工"。`,
``,
`**怎么算成功**Live card 里出现智能体在思考的状态Status 自动翻到 **In Progress**。`,
``,
`[关于把 issue 分配给智能体 →](https://multica.ai/docs/zh/assigning-issues)`,
].join("\n"),
},
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "和智能体聊天 —— 不需要建 issue",
description: [
`不是每个问题都值得开一个 issue。要快速来回对话用 **Chat 面板**。`,
``,
`**在哪**:看屏幕 **右下角** —— 有一个圆形按钮,上面是一个 **💬 对话气泡**。智能体在工作时按钮会脉动;有未读回复时会有红色小角标。`,
``,
`**现在就试一下**`,
`1. 点 💬 按钮 → 一个面板从右侧滑入`,
`2. 在 **输入框左下角** 点智能体头像 → 从下拉里选你的智能体`,
`3. 输入一个简短问题:"这个工作区里你能帮我做什么?"`,
`4. 按 **Enter**`,
``,
`**附赠技巧 —— 在评论里 @智能体**:在任何 issue 底部的评论框里输入 \`@\`,会弹出一个下拉,列出成员、智能体和其他 issue。选一个智能体 → 写下问题 → 发送。被 @ 的智能体会在评论里回复。`,
``,
`**怎么算成功**:智能体在几秒内通过 chat 面板(或评论里)回复。`,
``,
`[关于聊天 →](https://multica.ai/docs/zh/chat)`,
].join("\n"),
},
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "写一份 Workspace Context",
description: [
`**Workspace Context** 是一段共享系统提示,这个工作区里每个智能体在执行任何 task 之前都会读它。这是让智能体回复更精准、最有杠杆的一件事。`,
``,
`**在哪**`,
`1. 打开 **侧边栏** → 滚到底部 **Configure** 区`,
`2. 点 **Settings**(⚙️ 齿轮图标,最底部那个)`,
`3. 左侧 tab 列表里,在 **[你的工作区名]** 分组下,点 **General**`,
`4. 滚到 **Context** 文本框(占位符是"Provide context to agents..."`,
``,
`**写 3-5 行**`,
`- 你是谁(名字、定位)`,
`- 你在做什么(产品、项目)`,
`- 智能体应该怎么表现(语气、风格、默认行为)`,
``,
`**例子**`,
`> 我是前端工程师,在做一个 AI-native 任务管理产品。回复用中文、简短。永远解释你的推理。优先选 TypeScript 而不是 JavaScript。`,
``,
`点 **Save**。`,
``,
`**怎么算成功**:你下次分给智能体一个 task它会自动用上 context 里的信息,不需要你再解释一遍。`,
``,
`[关于工作区 →](https://multica.ai/docs/zh/workspaces)`,
].join("\n"),
},
];
const tier2: ImportStarterIssuePayload[] = [];
if (q.use_case === "manage_team") {
tier2.push({
status: "todo",
priority: "medium",
assign_to_self: true,
title: "邀请同事加入",
description: [
`Multica 在小团队共享智能体的场景下最好用。`,
``,
`**在哪**`,
`1. 侧边栏 → **Settings**(⚙️,最底部)`,
`2. 左侧 tab 列表 → 在 **[你的工作区]** 分组下 → 点 **Members**(人形图标)`,
`3. 页面顶部点 **Add member**`,
`4. 填邮箱、选角色(**Owner / Admin / Member**`,
`5. 点 **Send invite**`,
``,
`他们会收到一封带加入链接的邮件。已发出的邀请会出现在成员列表下方"Pending Invitations"折叠区,从那里可以撤销。`,
``,
`[关于成员与角色 →](https://multica.ai/docs/zh/members-roles)`,
].join("\n"),
});
}
if (q.role === "engineer" || q.use_case === "ship_code") {
tier2.push({
status: "todo",
priority: "medium",
assign_to_self: true,
title: "接入一个 Git 仓库",
description: [
`接入后,被分配 task 的智能体可以 clone、读取、提交对你仓库的修改。`,
``,
`**在哪**`,
`1. 侧边栏 → **Settings**(⚙️)`,
`2. 左侧 tab 列表 → 在 **[你的工作区]** 分组下 → **Repositories**(带 Git 分支图标的文件夹)`,
`3. 列表底部点 **+ Add repository**`,
`4. 填两个字段:`,
` - **URL** —— 例如 \`https://github.com/you/repo.git\``,
` - **Description** —— 这个仓库是干嘛的`,
`5. 在页面顶部点 **Save**`,
``,
`想暴露多少个仓库就重复多少次。`,
].join("\n"),
});
}
tier2.push({
status: "todo",
priority: "medium",
assign_to_self: true,
title: "再创建一个不同分工的智能体",
description: [
`跑一个分工明确的小型智能体团队,比一个万能选手更好用。一个写代码、一个做规划、一个写文 —— 各自有各自的指令。`,
``,
`**说明**:没有什么强制把"编码智能体"锁死在编码上。指令本质就是 system prompt随时可改。分开是为了让每个智能体的回复更聚焦。`,
``,
`**在哪**`,
`1. 侧边栏 → 在 **Workspace** 分组下点 **Agents**(🤖 图标)`,
`2. 在左侧列表头部点 **+** 按钮(列表右上角)`,
`3. 按顺序填 4 个字段:`,
` - **Name** —— 例如"规划智能体"`,
` - **Description** —— "把零散想法拆成可执行的任务"`,
` - **Visibility** —— Workspace共享或 Private仅自己`,
` - **Runtime** —— 从下拉里选(你已连接的运行时)`,
`4. 点 **Create**`,
``,
`**怎么算成功**:新智能体出现在任意 issue 的 Assignee 下拉里,也出现在 Agents 页的左侧列表。`,
``,
`[关于创建智能体 →](https://multica.ai/docs/zh/agents-create)`,
].join("\n"),
});
const tier3: ImportStarterIssuePayload[] = [
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "打磨智能体的 Instructions",
description: [
`创建智能体只是开始。**Instructions tab** 才是塑造它行为的地方。`,
``,
`**在哪**`,
`1. 侧边栏 → **Agents**(🤖)`,
`2. 在左侧列表点你想调整的智能体`,
`3. 在右侧面板顶部能看到一组 tab包括 **Instructions / Skills / Tasks / Settings**`,
`4. 点 **Instructions**`,
`5. 编辑 markdown —— 自动保存`,
``,
`**好的指令包含**`,
`- 角色/人设(例如"你是一名资深 TypeScript 工程师"`,
`- 内部规则(例如"代码改动一定要附带测试"`,
`- 输出格式(例如"先一句话总结,再展开细节"`,
``,
`Workspace Context 和智能体 Instructions 是叠加的——每个 task 都会同时带上。Instructions 写这个智能体特有的Context 写整个工作区都适用的。`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "实时观看智能体工作",
description: [
`**了解性 task** —— 现在不用做什么,知道有这个东西就行。`,
``,
`当智能体在某个 issue 上工作时,**Activity** 区顶部会出现一张 **Live card**(滚动时会粘在视口顶部)。`,
``,
`Live card 实时展示:`,
`- 智能体正在调用哪个工具(例如读文件、网页搜索)`,
`- 流式输出的思考与中间结果`,
`- 当前状态thinking / tool-running / done / failed`,
``,
`执行结束后Live card 下方的 **Task Run History** 列出每一次运行。任意一行点 **View transcript** 可以打开完整的可交互转录 —— 从消息、思考、工具调用到结果的完整时间线。`,
``,
`**下次分配 task 时试一下**:保持 issue 打开,观察 Live card 在描述下方出现。`,
``,
`[关于执行任务 →](https://multica.ai/docs/zh/tasks)`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "在 Inbox 里看 @提及与更新",
description: [
`当有人——或者智能体—— @你 或者把 issue 分给你时,事件会落到你的 **Inbox**。`,
``,
`**在哪**`,
`1. 侧边栏 → 顶部分区(**Workspace** 分组上方)→ 点 **Inbox**(📥 图标)—— 有新消息时右侧会显示未读角标`,
``,
`**怎么用**`,
`- 左栏:通知列表,最新在上`,
`- 右栏:关联的 issue 内嵌打开,**自动高亮并滚动到** @你的那条具体评论`,
`- 右上下拉:**Mark all as read / Archive all / Archive all read / Archive completed** 用于批量整理`,
``,
`**小技巧**"Archive completed" 是清掉已经完成 issue 噪音最快的方式。`,
``,
`[关于收件箱 →](https://multica.ai/docs/zh/inbox)`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "用 Autopilot 处理周期性工作",
description: [
`**Autopilot** 把一段 prompt 变成定时 task。每天/每周/每小时自动建一个 issue 并分给智能体。`,
``,
`**在哪**`,
`1. 侧边栏 → 在 **Workspace** 分组下点 **Autopilot**(⚡ 闪电图标)`,
`2. 还没有 autopilot 时,会出现一组模板——任选一个会预填弹窗,或者点 **+ New autopilot** 从空白开始`,
`3. 填:**Name** / **Prompt** / **Agent** / **Schedule**(频率 + 时间 + 时区)`,
`4. 点 **Create**`,
``,
`**第一个 autopilot 可以试什么**:每日 GitHub 活动摘要、每周"哪些 issue 被卡住"巡检、每周一早上整理还停在 Backlog 的 issue。`,
``,
`[关于自动化 →](https://multica.ai/docs/zh/autopilots)`,
].join("\n"),
},
];
return [...tier1, ...tier2, ...tier3];
}
export function buildSelfServeSubIssues(
q: QuestionnaireAnswers,
): ImportStarterIssuePayload[] {
const tier1: ImportStarterIssuePayload[] = [
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "装一个运行时(桌面应用 或 CLI",
description: [
`**为什么先做这个**:没有运行时 = 智能体跑不了任何 task。Tier 1 之下的所有事情都等这个。`,
``,
`**运行时**是守护进程(一个跑在你机器上的小后台进程)和一款 AI 编程工具——Claude Code、Codex 等等——的组合。装了多款工具就会出现多个运行时。运行时是真正执行智能体接到的 task 的那一层。`,
``,
`**方案 A —— 桌面应用macOSMac 推荐)**`,
`1. 去 [github.com/multica-ai/multica/releases/latest](https://github.com/multica-ai/multica/releases/latest) 下载 macOS 的 \`.dmg\``,
`2. 安装并打开`,
`3. 用同一个账号登录 —— 守护进程内置,到此结束`,
``,
`**方案 B —— CLImacOS、Linux 或 Windows + WSL**`,
`1. 在终端装 CLI`,
` \`\`\``,
` curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash`,
` \`\`\``,
`2. 跑 setup登录并启动后台守护进程`,
` \`\`\``,
` multica setup`,
` \`\`\``,
` 守护进程会在终端关闭后继续运行 —— 不需要保留终端窗口。`,
``,
`**验证**:侧边栏 → 底部 **Configure** 区 → **Runtimes** → 应该至少看到一个已连接的运行时。`,
``,
`[关于守护进程与运行时 →](https://multica.ai/docs/zh/daemon-runtimes)`,
].join("\n"),
},
{
status: "todo",
priority: "high",
assign_to_self: true,
title: "创建你的第一个智能体",
description: [
`**前置条件**:上面那条 task 完成(运行时已连接)。`,
``,
`**在哪**`,
`1. 侧边栏 → 在 **Workspace** 分组下点 **Agents**(🤖 图标)`,
`2. 在左侧列表头部点 **+** 按钮(列表右上角)`,
`3. 按顺序填 4 个字段:`,
` - **Name** —— 例如"我的编码智能体"`,
` - **Description** —— 一句话说它做什么`,
` - **Visibility** —— Workspace共享或 Private仅自己`,
` - **Runtime** —— 选你刚才装的那个`,
`4. 点 **Create**`,
``,
`**说明**:智能体本质上就是 LLM + 指令 + 工作区访问权限。没有什么强制把"编码智能体"锁死在编码上 —— 同一个智能体可以做调研、写作、规划。保持灵活。`,
``,
`**怎么算成功**:新智能体出现在任意 issue 的 Assignee 下拉里。`,
``,
`[关于创建智能体 →](https://multica.ai/docs/zh/agents-create)`,
].join("\n"),
},
];
const tier2: ImportStarterIssuePayload[] = [
{
status: "todo",
priority: "medium",
assign_to_self: true,
title: "把第一个真实 task 分给智能体",
description: [
`**前置条件**:上面两条 task 都做完,你已经有运行时 + 智能体。`,
``,
`**Multica 怎么触发智能体**`,
`- 把 issue 分给智能体`,
`- 状态置为 **Todo**(不是 Backlog —— Backlog 会让智能体暂停)`,
`- 智能体自动接手`,
``,
`**现在就试一下**`,
`1. 在侧边栏顶部点 **New Issue**(或按 \`C\``,
`2. 标题:你真正想做的事`,
`3. 在右侧面板找到 **Assignee** → 点击 → 选你的智能体`,
`4. 找到 **Status** → 从 Backlog 改为 **Todo**`,
`5. 看智能体在评论里回复Activity 里出现 **Live card**`,
``,
`**⚠️ 容易踩**:新 issue 默认是 **Backlog**。必须翻到 **Todo** 才会触发智能体。`,
``,
`[关于把 issue 分配给智能体 →](https://multica.ai/docs/zh/assigning-issues)`,
].join("\n"),
},
{
status: "todo",
priority: "medium",
assign_to_self: true,
title: "写一份 Workspace Context",
description: [
`**Workspace Context** 是一段共享系统提示,这个工作区里每个智能体在执行任何 task 之前都会读它。这是让智能体回复更精准、最有杠杆的一件事。`,
``,
`**在哪**`,
`1. 打开 **侧边栏** → 滚到底部 **Configure** 区`,
`2. 点 **Settings**(⚙️ 齿轮图标,最底部那个)`,
`3. 左侧 tab 列表里,在 **[你的工作区名]** 分组下,点 **General**`,
`4. 滚到 **Context** 文本框`,
``,
`**写 3-5 行**`,
`- 你是谁(名字、定位)`,
`- 你在做什么(产品、项目)`,
`- 智能体应该怎么表现(语气、风格、默认行为)`,
``,
`点 **Save**。`,
``,
`**怎么算成功**:你下次分给智能体一个 task它会自动用上 context 里的信息,不需要你再解释一遍。`,
``,
`[关于工作区 →](https://multica.ai/docs/zh/workspaces)`,
].join("\n"),
},
];
const tier3: ImportStarterIssuePayload[] = [
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "和智能体聊天 —— 创建之后再做",
description: [
`**前置条件**你至少创建了一个智能体Tier 1 #2`,
``,
`不是每个问题都值得开一个 issue。要快速来回对话用 **Chat 面板**。`,
``,
`**在哪**:屏幕 **右下角** 有一个圆形按钮,上面是 **💬 对话气泡**。`,
``,
`**试一下**`,
`1. 点 💬 按钮 → 一个面板从右侧滑入`,
`2. 在输入框左下角,从下拉里选一个智能体`,
`3. 输入问题 → 按 **Enter**`,
``,
`**附赠技巧**:在任意 issue 的评论框里输入 \`@\` 可以提及智能体或成员。`,
``,
`[关于聊天 →](https://multica.ai/docs/zh/chat)`,
].join("\n"),
},
];
if (q.role === "engineer" || q.use_case === "ship_code") {
tier3.push({
status: "backlog",
priority: "low",
assign_to_self: true,
title: "接入一个 Git 仓库",
description: [
`接入后,被分配 task 的智能体可以 clone、读取、提交对你仓库的修改。`,
``,
`**在哪**`,
`1. 侧边栏 → **Settings**(⚙️)`,
`2. 左侧 tab 列表 → **Repositories**(带 Git 分支图标的文件夹)`,
`3. 列表底部点 **+ Add repository**`,
`4. 填 **URL**(例如 \`https://github.com/you/repo.git\`)和 **Description**`,
`5. 在页面顶部点 **Save**`,
].join("\n"),
});
}
if (q.use_case === "manage_team") {
tier3.push({
status: "backlog",
priority: "low",
assign_to_self: true,
title: "邀请同事加入",
description: [
`Multica 在小团队共享智能体的场景下最好用。`,
``,
`**在哪**`,
`1. 侧边栏 → **Settings**(⚙️,最底部)`,
`2. 左侧 tab 列表 → **Members**(人形图标)`,
`3. 点 **Add member** → 填邮箱 → 选角色 → **Send invite**`,
``,
`[关于成员与角色 →](https://multica.ai/docs/zh/members-roles)`,
].join("\n"),
});
}
tier3.push(
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "塑造智能体的 Instructions创建之后再做",
description: [
`**前置条件**:你至少有一个智能体。`,
``,
`创建智能体只是开始。**Instructions tab** 才是塑造它行为的地方。`,
``,
`**在哪**`,
`1. 侧边栏 → **Agents**(🤖)`,
`2. 在左侧列表点一个智能体`,
`3. 右侧面板 → 点 **Instructions** tab与 Skills / Tasks / Settings 并列)`,
`4. 编辑 markdown —— 自动保存`,
``,
`Workspace Context 和智能体 Instructions 是叠加的——每个 task 都会同时带上。Instructions 写这个智能体特有的Context 写整个工作区都适用的。`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "实时观看智能体工作(分配 task 之后再做)",
description: [
`**了解性 task** —— 现在不用做什么,知道有这个东西就行。`,
``,
`当智能体在某个 issue 上工作时,**Activity** 区顶部会出现一张 **Live card**(滚动时会粘在视口顶部)。`,
``,
`Live card 实时展示智能体正在调用哪个工具、流式思考、当前状态。执行结束后,下方的 **Task Run History** 列出每一次运行 —— 点 **View transcript** 可以打开完整时间线。`,
``,
`[关于执行任务 →](https://multica.ai/docs/zh/tasks)`,
].join("\n"),
},
{
status: "backlog",
priority: "low",
assign_to_self: true,
title: "搭一个 Autopilot有了智能体之后再做",
description: [
`**前置条件**:你至少有一个智能体。`,
``,
`**Autopilot** 把一段 prompt 变成定时 task。每天/每周/每小时自动建一个 issue 并分给智能体。`,
``,
`**在哪**`,
`1. 侧边栏 → 在 **Workspace** 分组下点 **Autopilot**(⚡ 闪电图标)`,
`2. 选一个模板,或者点 **+ New autopilot** 从空白开始`,
`3. 填:**Name** / **Prompt** / **Agent** / **Schedule**(频率 + 时间 + 时区)→ **Create**`,
``,
`[关于自动化 →](https://multica.ai/docs/zh/autopilots)`,
].join("\n"),
},
);
return [...tier1, ...tier2, ...tier3];
}

View File

@@ -1,83 +0,0 @@
import type { QuestionnaireAnswers } from "@multica/core/onboarding";
import type {
ImportStarterContentPayload,
ImportStarterIssuePayload,
} from "@multica/core/api";
import * as en from "./starter-content-content-en";
import * as zh from "./starter-content-content-zh";
// =============================================================================
// Starter content orchestrator.
//
// Pure functions that turn the user's questionnaire answers + locale into
// the request payload for POST /api/me/starter-content/import. No side
// effects, no API calls, no DOM — the only consumer is `StarterContentPrompt`,
// which passes the output straight to the server.
//
// Long-form markdown bodies live in sibling files keyed by locale:
// - starter-content-content-en.ts (English)
// - starter-content-content-zh.ts (Simplified Chinese)
//
// JSON locales were considered, but ~600 lines of multi-paragraph markdown
// per language are unreadable as escaped single-line strings; keeping the
// content in TS lets reviewers see the rendered shape and catch markdown
// regressions in code review.
//
// Server-side concerns (batch creation, idempotency, assignee resolution)
// live in Go: handler/onboarding.go → ImportStarterContent.
// =============================================================================
export type StarterContentLocale = "en" | "zh-Hans";
// Prefix titles with 1. 2. 3. … AFTER the full list is assembled so
// conditional items (invite team / connect repo) don't break numbering.
function numberTitles(
issues: ImportStarterIssuePayload[],
): ImportStarterIssuePayload[] {
return issues.map((s, i) => ({ ...s, title: `${i + 1}. ${s.title}` }));
}
function pickContent(locale: StarterContentLocale) {
return locale === "zh-Hans" ? zh : en;
}
/**
* Builds the full import payload. The client does NOT decide between the
* agent-guided and self-serve branches — it always sends both sub-issue
* arrays and a welcome-issue template (no agent_id). The SERVER picks
* inside the import transaction based on whether any agent exists in
* the workspace at that moment. See handler/onboarding.go.
*/
export function buildImportPayload({
workspaceId,
userName,
questionnaire,
locale,
}: {
workspaceId: string;
userName: string;
questionnaire: QuestionnaireAnswers;
locale: StarterContentLocale;
}): ImportStarterContentPayload {
const content = pickContent(locale);
const welcome = content.buildWelcomeIssueText(questionnaire, userName);
return {
workspace_id: workspaceId,
project: {
title: content.PROJECT.title,
description: content.PROJECT.description,
icon: "👋",
},
welcome_issue_template: {
title: welcome.title,
description: welcome.description,
priority: "high",
},
agent_guided_sub_issues: numberTitles(
content.buildAgentGuidedSubIssues(questionnaire),
),
self_serve_sub_issues: numberTitles(
content.buildSelfServeSubIssues(questionnaire),
),
};
}

View File

@@ -311,8 +311,6 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Post("/api/me/onboarding/runtime-bootstrap", h.BootstrapOnboardingRuntime)
r.Post("/api/me/onboarding/no-runtime-bootstrap", h.BootstrapOnboardingNoRuntime)
r.Post("/api/me/onboarding/cloud-waitlist", h.JoinCloudWaitlist)
r.Post("/api/me/starter-content/import", h.ImportStarterContent)
r.Post("/api/me/starter-content/dismiss", h.DismissStarterContent)
r.Post("/api/cli-token", h.IssueCliToken)
r.Post("/api/upload-file", h.UploadFile)
r.Post("/api/feedback", h.CreateFeedback)

View File

@@ -29,7 +29,6 @@ const (
EventAgentCreated = "agent_created"
EventOnboardingCompleted = "onboarding_completed"
EventCloudWaitlistJoined = "cloud_waitlist_joined"
EventStarterContentDecided = "starter_content_decided"
EventFeedbackSubmitted = "feedback_submitted"
)
@@ -73,14 +72,6 @@ const (
OnboardingPathUnknown = "unknown" // fallback when the server can't derive the path
)
// Starter content branches. Matches the server-authoritative decision in
// ImportStarterContent (hasAgent ? agent_guided : self_serve). DismissStarter
// carries the same branch so acceptance rates split cleanly.
const (
StarterContentBranchAgentGuided = "agent_guided"
StarterContentBranchSelfServe = "self_serve"
)
// Platform is used as the "platform" event property so funnels can split by
// web / desktop / cli. Request-path events use PlatformServer as a fallback
// when the caller is a server-originating action (e.g. auto-created user);
@@ -500,27 +491,6 @@ func CloudWaitlistJoined(userID string, hasReason bool) Event {
}
}
// StarterContentDecided fires on the atomic NULL -> terminal state
// transition in both ImportStarterContent and DismissStarterContent.
// branch carries agent_guided / self_serve for BOTH decisions — the
// dismiss handler resolves it from the current ListAgents state so
// acceptance rates split cleanly by branch.
func StarterContentDecided(userID, workspaceID, decision, branch string) Event {
return Event{
Name: EventStarterContentDecided,
DistinctID: userID,
WorkspaceID: workspaceID,
Properties: withCoreProperties(map[string]any{
"decision": decision,
"branch": branch,
}, CoreProperties{
UserID: userID,
WorkspaceID: workspaceID,
Source: SourceOnboarding,
}),
}
}
// FeedbackSubmitted fires after a feedback row is successfully inserted.
// The raw message is stored in the DB and never broadcast — we only emit a
// coarse length bucket, an image-presence flag, and the client platform /

View File

@@ -1616,16 +1616,6 @@ func TestRequestBodyUUIDFieldsRejectMalformed(t *testing.T) {
}),
handle: testHandler.DaemonRegister,
},
{
name: "import starter content workspace_id",
req: newRequest("POST", "/api/onboarding/starter-content/import", map[string]any{
"workspace_id": "not-a-uuid",
"project": map[string]any{
"title": "Getting Started",
},
}),
handle: testHandler.ImportStarterContent,
},
}
for _, tt := range tests {

View File

@@ -438,6 +438,23 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
return
}
// Seed an install-runtime issue if the workspace has no runtime yet, so
// the invitee lands on a concrete next step rather than an empty list.
// claimStarterContentStateIfUnset keeps older desktop builds from showing
// the legacy import dialog (rendered when this column is NULL).
seededIssue, seededIssueCreated, err := ensureNoRuntimeOnboardingIssue(
r.Context(), qtx, accepted.WorkspaceID, user.ID, onboardedUser.Language,
)
if err != nil {
slog.Warn("accept invitation: ensure install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", uuidToString(accepted.WorkspaceID))...)
writeError(w, http.StatusInternalServerError, "failed to seed onboarding issue")
return
}
if err := claimStarterContentStateIfUnset(r.Context(), qtx, user.ID, onboardedUser.StarterContentState); err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
return
@@ -461,6 +478,21 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
"member": memberResp,
})
if seededIssueCreated {
prefix := h.getIssuePrefix(r.Context(), seededIssue.WorkspaceID)
issueResp := issueToResponse(seededIssue, prefix)
h.publish(protocol.EventIssueCreated, wsID, "member", userID, map[string]any{"issue": issueResp})
h.Analytics.Capture(analytics.IssueCreated(
userID,
wsID,
uuidToString(seededIssue.ID),
"",
"",
"",
analytics.SourceOnboarding,
))
}
// days_since_invite rounds down to whole days so the funnel segments
// "accepted same day" cleanly from "accepted later". inv.CreatedAt is
// the invitation row's insertion time so this is safe to compute here.

View File

@@ -0,0 +1,199 @@
package handler
import (
"context"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/issueguard"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// noRuntimeIssueTitle and noRuntimeIssueDescription are the canonical
// "install your first runtime" issue. The text lives here so every mark-
// onboarded entry point (BootstrapOnboardingNoRuntime, CompleteOnboarding,
// CreateWorkspace, AcceptInvitation) seeds the same body.
const noRuntimeIssueTitle = "Connect a runtime to start using agents"
func noRuntimeIssueDescription(language pgtype.Text) string {
if language.Valid && strings.HasPrefix(language.String, "zh") {
return zhNoRuntimeIssueDescription()
}
return enNoRuntimeIssueDescription()
}
func enNoRuntimeIssueDescription() string {
return strings.Join([]string{
"Welcome to Multica.",
"",
"Agents need a runtime before they can execute work. You can still use Multica as a lightweight project-management workspace while you install one.",
"",
"## Try Multica first",
"",
"Before the runtime is ready, you can:",
"",
"1. Create a project for your current work.",
"2. Create a few issues and move them across backlog, todo, in_progress, and done.",
"3. Add priorities, labels, comments, and subscriptions.",
"4. Use Inbox to track assignments and mentions.",
"",
"That gives you the project-management layer first. Once a runtime is connected, agents can start working from the same issues.",
"",
"## Install your first agent runtime",
"",
"Full guide: https://multica.ai/docs/install-agent-runtime",
"",
"For English users, the fastest first path is Codex:",
"",
"1. Make sure Node.js is installed.",
"2. Install Codex:",
" npm i -g @openai/codex",
"3. Sign in:",
" codex",
"4. Confirm your terminal can find it:",
" which codex",
" codex --version",
"5. Restart the Multica daemon:",
" multica daemon restart",
" If you use the desktop app, restarting the app is enough.",
"6. Return to Runtimes and refresh. You should see a Codex runtime online.",
"7. Create your first agent from that runtime, then assign an issue to the agent and set status to todo.",
"",
"Codex reference: https://developers.openai.com/codex/cli",
"",
"When the runtime is connected, you can create Multica Helper for a guided first run.",
}, "\n")
}
func zhNoRuntimeIssueDescription() string {
return strings.Join([]string{
"欢迎来到 Multica。",
"",
"智能体需要先连上运行时才能执行工作。运行时还没准备好时,你也可以先把 Multica 当作轻量项目管理工具体验起来。",
"",
"## 先体验项目管理功能",
"",
"运行时安装前,你可以先做这些事:",
"",
"1. 为当前工作创建一个项目。",
"2. 新建几个 issue并在 backlog、todo、in_progress、done 之间流转。",
"3. 给 issue 加优先级、标签、评论和订阅。",
"4. 用收件箱追踪分配给你的事项和 @mention。",
"",
"这样你先熟悉项目管理层。连上运行时后,智能体会直接在这些 issue 上开始工作。",
"",
"## 安装第一个 Agent 运行时",
"",
"完整文档https://multica.ai/docs/install-agent-runtime",
"",
"中文用户建议先装 Kimi CLI",
"",
"1. 在 macOS / Linux 终端安装 Kimi CLI",
" curl -LsSf https://code.kimi.com/install.sh | bash",
" Windows PowerShell",
" Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression",
"2. 确认终端能找到 Kimi",
" kimi --version",
"3. 在你想让 Kimi 工作的项目目录里启动一次:",
" kimi",
"4. 首次启动后输入 /login按提示完成 Kimi Code 或 API key 配置。",
"5. 重启 Multica 守护进程:",
" multica daemon restart",
" 如果你用桌面端,重启 app 即可。",
"6. 回到 Runtimes 页面刷新。你应该能看到一个在线的 Kimi 运行时。",
"7. 用这个运行时创建第一个智能体,再把一个 issue 分配给它,并把状态切到 todo。",
"",
"Kimi CLI 官方文档https://moonshotai.github.io/kimi-cli/zh/guides/getting-started.html",
"",
"运行时连上后,你就可以创建 Multica Helper开始一次有智能体参与的上手引导。",
}, "\n")
}
// seedInstallRuntimeIssue creates the install-runtime issue, deduping against
// existing active issues with the same title via pg_advisory_xact_lock so
// concurrent callers can't produce two copies. Must run inside a transaction.
func seedInstallRuntimeIssue(
ctx context.Context,
q *db.Queries,
workspaceID pgtype.UUID,
userID pgtype.UUID,
language pgtype.Text,
) (db.Issue, bool, error) {
var emptyUUID pgtype.UUID
existing, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
ctx, q, workspaceID, emptyUUID, emptyUUID, noRuntimeIssueTitle, false,
)
if err != nil {
return db.Issue{}, false, err
}
if foundIssue {
return existing, false, nil
}
issueNumber, err := q.IncrementIssueCounter(ctx, workspaceID)
if err != nil {
return db.Issue{}, false, err
}
issue, err := q.CreateIssue(ctx, db.CreateIssueParams{
WorkspaceID: workspaceID,
Title: noRuntimeIssueTitle,
Description: strOrNullText(noRuntimeIssueDescription(language)),
Status: "todo",
Priority: "high",
AssigneeType: pgtype.Text{String: "member", Valid: true},
AssigneeID: userID,
CreatorType: "member",
CreatorID: userID,
ParentIssueID: emptyUUID,
Position: 0,
Number: issueNumber,
ProjectID: emptyUUID,
})
if err != nil {
return db.Issue{}, false, err
}
return issue, true, nil
}
// ensureNoRuntimeOnboardingIssue is the side-door wrapper used by
// CompleteOnboarding / CreateWorkspace / AcceptInvitation: it only seeds the
// install-runtime issue when the workspace has no agent_runtime yet.
// BootstrapOnboardingNoRuntime is the explicit "I skipped the runtime step"
// signal and bypasses this gate via seedInstallRuntimeIssue directly.
func ensureNoRuntimeOnboardingIssue(
ctx context.Context,
q *db.Queries,
workspaceID pgtype.UUID,
userID pgtype.UUID,
language pgtype.Text,
) (db.Issue, bool, error) {
runtimes, err := q.ListAgentRuntimes(ctx, workspaceID)
if err != nil {
return db.Issue{}, false, err
}
if len(runtimes) > 0 {
return db.Issue{}, false, nil
}
return seedInstallRuntimeIssue(ctx, q, workspaceID, userID, language)
}
// claimStarterContentStateIfUnset transitions starter_content_state from NULL
// to 'imported'. Kept after the starter-kit removal so older desktop builds —
// which still render the legacy import dialog when this column is NULL — skip
// the dialog on accounts created after the removal.
func claimStarterContentStateIfUnset(
ctx context.Context,
q *db.Queries,
userID pgtype.UUID,
current pgtype.Text,
) error {
if current.Valid {
return nil
}
_, err := q.SetStarterContentState(ctx, db.SetStarterContentStateParams{
ID: userID,
StarterContentState: pgtype.Text{String: "imported", Valid: true},
})
return err
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/issueguard"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
@@ -33,19 +32,12 @@ const (
// Runtime bootstrap is just workspace_id + runtime_id, but keep a
// separate small cap so this endpoint cannot be used as bulk storage.
runtimeBootstrapBodyLimit = 8 * 1024
// Import payload contains the full starter-content template. Each
// sub-issue's markdown description is ~2 KiB; with ~8 sub-issues,
// a welcome issue (~3 KiB), and a project description, 64 KiB is
// comfortably above realistic and still bounded.
importStarterContentBodyLimit = 64 * 1024
)
const (
onboardingAssistantName = "Multica Helper"
onboardingIssueTitle = "Start here: learn Multica with Multica Helper"
onboardingAgentTemplate = "multica_helper"
noRuntimeIssueTitle = "Connect a runtime to start using agents"
)
const onboardingAssistantDescription = "Default guide for your first Multica workspace."
@@ -73,100 +65,6 @@ This is your guided first run. Multica Helper is assigned to this issue and will
You can close this issue when the workflow makes sense.`
func noRuntimeIssueDescription(language pgtype.Text) string {
if language.Valid && strings.HasPrefix(language.String, "zh") {
return zhNoRuntimeIssueDescription()
}
return enNoRuntimeIssueDescription()
}
func enNoRuntimeIssueDescription() string {
return strings.Join([]string{
"Welcome to Multica.",
"",
"Agents need a runtime before they can execute work. You can still use Multica as a lightweight project-management workspace while you install one.",
"",
"## Try Multica first",
"",
"Before the runtime is ready, you can:",
"",
"1. Create a project for your current work.",
"2. Create a few issues and move them across backlog, todo, in_progress, and done.",
"3. Add priorities, labels, comments, and subscriptions.",
"4. Use Inbox to track assignments and mentions.",
"",
"That gives you the project-management layer first. Once a runtime is connected, agents can start working from the same issues.",
"",
"## Install your first agent runtime",
"",
"Full guide: https://multica.ai/docs/install-agent-runtime",
"",
"For English users, the fastest first path is Codex:",
"",
"1. Make sure Node.js is installed.",
"2. Install Codex:",
" npm i -g @openai/codex",
"3. Sign in:",
" codex",
"4. Confirm your terminal can find it:",
" which codex",
" codex --version",
"5. Restart the Multica daemon:",
" multica daemon restart",
" If you use the desktop app, restarting the app is enough.",
"6. Return to Runtimes and refresh. You should see a Codex runtime online.",
"7. Create your first agent from that runtime, then assign an issue to the agent and set status to todo.",
"",
"Codex reference: https://developers.openai.com/codex/cli",
"",
"When the runtime is connected, you can create Multica Helper for a guided first run.",
}, "\n")
}
func zhNoRuntimeIssueDescription() string {
return strings.Join([]string{
"欢迎来到 Multica。",
"",
"智能体需要先连上运行时才能执行工作。运行时还没准备好时,你也可以先把 Multica 当作轻量项目管理工具体验起来。",
"",
"## 先体验项目管理功能",
"",
"运行时安装前,你可以先做这些事:",
"",
"1. 为当前工作创建一个项目。",
"2. 新建几个 issue并在 backlog、todo、in_progress、done 之间流转。",
"3. 给 issue 加优先级、标签、评论和订阅。",
"4. 用收件箱追踪分配给你的事项和 @mention。",
"",
"这样你先熟悉项目管理层。连上运行时后,智能体会直接在这些 issue 上开始工作。",
"",
"## 安装第一个 Agent 运行时",
"",
"完整文档https://multica.ai/docs/install-agent-runtime",
"",
"中文用户建议先装 Kimi CLI",
"",
"1. 在 macOS / Linux 终端安装 Kimi CLI",
" curl -LsSf https://code.kimi.com/install.sh | bash",
" Windows PowerShell",
" Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression",
"2. 确认终端能找到 Kimi",
" kimi --version",
"3. 在你想让 Kimi 工作的项目目录里启动一次:",
" kimi",
"4. 首次启动后输入 /login按提示完成 Kimi Code 或 API key 配置。",
"5. 重启 Multica 守护进程:",
" multica daemon restart",
" 如果你用桌面端,重启 app 即可。",
"6. 回到 Runtimes 页面刷新。你应该能看到一个在线的 Kimi 运行时。",
"7. 用这个运行时创建第一个智能体,再把一个 issue 分配给它,并把状态切到 todo。",
"",
"Kimi CLI 官方文档https://moonshotai.github.io/kimi-cli/zh/guides/getting-started.html",
"",
"运行时连上后,你就可以创建 Multica Helper开始一次有智能体参与的上手引导。",
}, "\n")
}
// completeOnboardingRequest carries the client's view of which exit the
// user took from the flow. The client is the only place that knows
// whether Step 3's runtime connect was skipped, whether the cloud
@@ -195,6 +93,10 @@ var validCompletionPaths = map[string]struct{}{
// actually flips `onboarded_at` from NULL. Subsequent calls are still
// 200 OK (for client-side retries) but skip the event so the funnel
// counts honest first-completion.
//
// When the client supplies workspace_id and the workspace has no runtime
// yet, this also seeds the "install a runtime" issue (idempotent), so the
// "I've done this before" / Skip exits land on a concrete next step.
func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -210,23 +112,85 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
}
}
// Resolve workspace_id (if any) up front so a malformed value short-
// circuits with 400 before we touch the DB.
var wsUUID pgtype.UUID
hasWorkspace := false
if req.WorkspaceID != "" {
parsed, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
if !ok {
return
}
wsUUID = parsed
req.WorkspaceID = uuidToString(wsUUID)
hasWorkspace = true
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
// Read the prior state so we can detect "was this call the one that
// actually completed onboarding?" — MarkUserOnboarded uses COALESCE
// and returns the preserved timestamp on repeat calls, which is not
// the signal we need for the funnel.
before, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
before, err := qtx.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
firstCompletion := !before.OnboardedAt.Valid
user, err := h.Queries.MarkUserOnboarded(r.Context(), parseUUID(userID))
user, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
return
}
var seededIssue db.Issue
seeded := false
if hasWorkspace {
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
WorkspaceID: wsUUID,
}); err == nil {
seededIssue, seeded, err = ensureNoRuntimeOnboardingIssue(r.Context(), qtx, wsUUID, parseUUID(userID), before.Language)
if err != nil {
slog.Warn("complete onboarding: ensure install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to seed onboarding issue")
return
}
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), user.StarterContentState); err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
return
}
if seeded {
prefix := h.getIssuePrefix(r.Context(), seededIssue.WorkspaceID)
resp := issueToResponse(seededIssue, prefix)
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
h.Analytics.Capture(analytics.IssueCreated(
userID,
req.WorkspaceID,
uuidToString(seededIssue.ID),
"",
"",
"",
analytics.SourceOnboarding,
))
}
if firstCompletion {
path := req.CompletionPath
if _, ok := validCompletionPaths[path]; !ok {
@@ -270,9 +234,8 @@ type bootstrapOnboardingNoRuntimeResponse struct {
// BootstrapOnboardingRuntime is the runtime-connected onboarding exit:
// create or reuse one default helper agent, create or reuse one onboarding
// issue assigned to it, mark onboarding complete, and suppress the older
// starter-content dialog. The flow is deliberately one issue, not a seeded
// project with many tasks.
// issue assigned to it, and mark onboarding complete. The flow is
// deliberately one issue, not a seeded project with many tasks.
func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -435,17 +398,9 @@ func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Requ
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
return
}
starterContentClaimed := false
if !updatedUser.StarterContentState.Valid {
updatedUser, err = qtx.SetStarterContentState(r.Context(), db.SetStarterContentStateParams{
ID: parseUUID(userID),
StarterContentState: pgtype.Text{String: "imported", Valid: true},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
starterContentClaimed = true
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), updatedUser.StarterContentState); err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
if err := tx.Commit(r.Context()); err != nil {
@@ -496,14 +451,6 @@ func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Requ
updatedUser.CloudWaitlistEmail.Valid,
))
}
if starterContentClaimed {
h.Analytics.Capture(analytics.StarterContentDecided(
userID,
req.WorkspaceID,
"imported",
analytics.StarterContentBranchAgentGuided,
))
}
writeJSON(w, http.StatusOK, bootstrapOnboardingRuntimeResponse{
WorkspaceID: req.WorkspaceID,
@@ -513,10 +460,9 @@ func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Requ
}
// BootstrapOnboardingNoRuntime is the runtime-skipped onboarding exit:
// create or reuse one self-serve onboarding issue, mark onboarding complete,
// and suppress the older starter-content dialog. This keeps the no-runtime
// path focused on the single real blocker instead of seeding a project full
// of follow-up tasks.
// create or reuse one self-serve onboarding issue and mark onboarding
// complete. This keeps the no-runtime path focused on the single real
// blocker instead of seeding a project full of follow-up tasks.
func (h *Handler) BootstrapOnboardingNoRuntime(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -561,67 +507,26 @@ func (h *Handler) BootstrapOnboardingNoRuntime(w http.ResponseWriter, r *http.Re
return
}
var emptyUUID pgtype.UUID
issue, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
r.Context(),
qtx,
wsUUID,
emptyUUID,
emptyUUID,
noRuntimeIssueTitle,
false,
// The user explicitly skipped the runtime step, so seed the install-
// runtime issue regardless of any pre-existing runtime on the workspace
// — the user's intent was "I have nothing to connect right now".
issue, issueCreated, err := seedInstallRuntimeIssue(
r.Context(), qtx, wsUUID, parseUUID(userID), userBefore.Language,
)
if err != nil {
slog.Warn("bootstrap no-runtime onboarding: duplicate issue check failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
slog.Warn("bootstrap no-runtime onboarding: seed install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
return
}
issueCreated := false
if !foundIssue {
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
return
}
issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{
WorkspaceID: wsUUID,
Title: noRuntimeIssueTitle,
Description: strOrNullText(noRuntimeIssueDescription(userBefore.Language)),
Status: "todo",
Priority: "high",
AssigneeType: pgtype.Text{String: "member", Valid: true},
AssigneeID: parseUUID(userID),
CreatorType: "member",
CreatorID: parseUUID(userID),
ParentIssueID: emptyUUID,
Position: 0,
Number: issueNumber,
ProjectID: emptyUUID,
})
if err != nil {
slog.Warn("bootstrap no-runtime onboarding: create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
return
}
issueCreated = true
}
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
return
}
starterContentClaimed := false
if !updatedUser.StarterContentState.Valid {
updatedUser, err = qtx.SetStarterContentState(r.Context(), db.SetStarterContentStateParams{
ID: parseUUID(userID),
StarterContentState: pgtype.Text{String: "imported", Valid: true},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
starterContentClaimed = true
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), updatedUser.StarterContentState); err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
if err := tx.Commit(r.Context()); err != nil {
@@ -656,14 +561,6 @@ func (h *Handler) BootstrapOnboardingNoRuntime(w http.ResponseWriter, r *http.Re
updatedUser.CloudWaitlistEmail.Valid,
))
}
if starterContentClaimed {
h.Analytics.Capture(analytics.StarterContentDecided(
userID,
req.WorkspaceID,
"imported",
analytics.StarterContentBranchSelfServe,
))
}
writeJSON(w, http.StatusOK, bootstrapOnboardingNoRuntimeResponse{
WorkspaceID: req.WorkspaceID,
@@ -840,451 +737,6 @@ func (h *Handler) JoinCloudWaitlist(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, userToResponse(user))
}
// -----------------------------------------------------------------------------
// Starter content (post-onboarding opt-in)
// -----------------------------------------------------------------------------
//
// Users land in their workspace with starter_content_state=NULL and see
// a one-time dialog offering to seed example content. Two terminal
// transitions:
//
// ImportStarterContent NULL -> 'imported' (also creates project, welcome
// issue if agent-based, sub-issues,
// pins — all in one transaction)
// DismissStarterContent NULL -> 'dismissed'
//
// Why state-first, then seeding inside the same transaction:
// - starter_content_state is the "have we asked / done this" bit, so it
// must be set exactly once per user
// - if we set state AFTER creation, a mid-request crash leaves duplicates
// on retry (the original "Not idempotent" bug)
// - if we set state BEFORE creation, a mid-request crash leaves the user
// with 'imported' + no content
// - inside a transaction, both commit together or neither does — and the
// starting state check (must be NULL) guarantees the claim is atomic
//
// Content generation lives in TypeScript (the markdown templates are large
// and depend on the Q1Q3 answers); the client POSTs the fully-rendered
// payload here, and the server's job is to (1) gate on state, (2) do the
// batch insert transactionally, (3) record the transition.
type importIssueSpec struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
// AssignToSelf: true for sub-issues (assigned to the current
// user as a member). Server uses `user_id` per the app-wide
// convention in AssigneePicker / resolveActor.
AssignToSelf bool `json:"assign_to_self"`
}
// welcomeIssueTemplate is a PRE-rendered welcome issue — title +
// description + priority. There is no `agent_id` field on purpose:
// the server picks the target agent itself from ListAgents inside
// the transaction, so a stale or compromised client can't assign
// the welcome issue to an arbitrary agent.
type welcomeIssueTemplate struct {
Title string `json:"title"`
Description string `json:"description"`
// Priority optional; defaults to "high" when empty.
Priority string `json:"priority"`
}
type importStarterContentRequest struct {
WorkspaceID string `json:"workspace_id"`
Project struct {
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
} `json:"project"`
// Welcome issue template — rendered regardless of branch. The
// server creates it only when at least one agent exists in the
// workspace; otherwise it's ignored.
WelcomeIssueTemplate welcomeIssueTemplate `json:"welcome_issue_template"`
// Both branches of sub-issues. The server picks which array to
// seed based on whether the workspace has any agents at the
// moment of the call — the client no longer decides. Sending
// both is ~15 KB extra payload, which stays well under the
// 64 KB MaxBytesReader cap above.
AgentGuidedSubIssues []importIssueSpec `json:"agent_guided_sub_issues"`
SelfServeSubIssues []importIssueSpec `json:"self_serve_sub_issues"`
}
type importStarterContentResponse struct {
User UserResponse `json:"user"`
ProjectID string `json:"project_id"`
WelcomeIssueID *string `json:"welcome_issue_id"`
}
// ImportStarterContent creates the Getting Started project, optional
// welcome issue, sub-issues, and pins — all inside a single transaction
// gated by the atomic NULL -> 'imported' state transition. Idempotent
// at the state level: any second call returns 409 with the already-set
// state, no duplicate content created.
func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, importStarterContentBodyLimit)
var req importStarterContentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.WorkspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
// Reject malformed UUIDs up front and reuse the parsed value for every
// write below so a garbage workspace_id never reaches CreateProject /
// CreateIssue.
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
if !ok {
return
}
req.WorkspaceID = uuidToString(wsUUID)
if req.Project.Title == "" {
writeError(w, http.StatusBadRequest, "project.title is required")
return
}
// Start the transaction early — the state claim lives inside it so
// concurrent imports from another tab can't both pass the check.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to begin transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
// Claim step: user must be NULL (never asked) to proceed. A value
// of 'imported' / 'dismissed' / 'skipped_legacy' all short-circuit
// with 409 Conflict — the caller should close the dialog and
// refresh the user to pick up the already-final state.
user, err := qtx.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if user.StarterContentState.Valid {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "starter content already decided",
"state": user.StarterContentState.String,
})
return
}
// Membership check: user must belong to the target workspace.
// `actorID` below is `parseUUID(userID)` — stored as `creator_id`
// and `assignee_id` for `type="member"` to match the app-wide
// convention (AssigneePicker + resolveActor). Storing `member.id`
// would cause `useActorName.getMemberName` to resolve to "Unknown"
// since members are looked up by `user_id`.
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusForbidden, "not a member of this workspace")
return
}
actorID := parseUUID(userID)
// --- Branch decision (server-authoritative) ---
// Ask the DB — not the client — whether there's an agent in this
// workspace. `ListAgents` orders by created_at ASC, so "agents[0]"
// is deterministically the earliest-created agent. This replaces
// the old client-supplied `welcome_issue.agent_id` trust chain.
agents, err := qtx.ListAgents(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agents")
return
}
hasAgent := len(agents) > 0
var welcomeAgentID pgtype.UUID
if hasAgent {
welcomeAgentID = agents[0].ID
}
subSpecs := req.SelfServeSubIssues
if hasAgent {
subSpecs = req.AgentGuidedSubIssues
}
// --- Create project ---
project, err := qtx.CreateProject(r.Context(), db.CreateProjectParams{
WorkspaceID: wsUUID,
Title: req.Project.Title,
Description: strOrNullText(req.Project.Description),
Icon: strOrNullText(req.Project.Icon),
Status: "planned",
Priority: "none",
})
if err != nil {
slog.Warn("import starter content: create project failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to create project")
return
}
// --- Create welcome issue (only when an agent exists) ---
var welcomeIssueID *string
var welcomeIssueForEvent *db.Issue
if hasAgent && req.WelcomeIssueTemplate.Title != "" {
welcomeNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
return
}
priority := req.WelcomeIssueTemplate.Priority
if priority == "" {
priority = "high"
}
welcome, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
WorkspaceID: wsUUID,
Title: req.WelcomeIssueTemplate.Title,
Description: strOrNullText(req.WelcomeIssueTemplate.Description),
Status: "todo",
Priority: priority,
AssigneeType: pgtype.Text{String: "agent", Valid: true},
AssigneeID: welcomeAgentID,
CreatorType: "member",
CreatorID: actorID,
Number: welcomeNumber,
})
if err != nil {
slog.Warn("import starter content: create welcome issue failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to create welcome issue")
return
}
id := uuidToString(welcome.ID)
welcomeIssueID = &id
copy := welcome
welcomeIssueForEvent = &copy
}
// --- Create sub-issues (branch picked above) ---
subIssuesCreated := make([]db.Issue, 0, len(subSpecs))
for _, sub := range subSpecs {
if sub.Title == "" {
continue
}
number, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
return
}
var assigneeType pgtype.Text
var assigneeID pgtype.UUID
if sub.AssignToSelf {
assigneeType = pgtype.Text{String: "member", Valid: true}
assigneeID = actorID
}
status := sub.Status
if status == "" {
status = "backlog"
}
priority := sub.Priority
if priority == "" {
priority = "none"
}
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
WorkspaceID: wsUUID,
Title: sub.Title,
Description: strOrNullText(sub.Description),
Status: status,
Priority: priority,
AssigneeType: assigneeType,
AssigneeID: assigneeID,
CreatorType: "member",
CreatorID: actorID,
Number: number,
ProjectID: project.ID,
})
if err != nil {
slog.Warn("import starter content: create sub-issue failed", append(logger.RequestAttrs(r), "error", err, "title", sub.Title)...)
writeError(w, http.StatusInternalServerError, "failed to create sub-issues")
return
}
subIssuesCreated = append(subIssuesCreated, issue)
}
// --- Pin project (and welcome issue if present) ---
// Non-fatal: a pin failure shouldn't prevent the onboarding bundle
// from landing. We warn and move on. Pointers to the created rows
// are kept around for post-commit `pin:created` fan-out so the
// sidebar refreshes without a manual reload.
pinnedProjectPos := float64(1)
var pinProjectForEvent *db.PinnedItem
pinProject, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
ItemType: "project",
ItemID: project.ID,
Position: pinnedProjectPos,
})
if err != nil {
slog.Warn("import starter content: pin project failed", append(logger.RequestAttrs(r), "error", err)...)
} else {
pinProjectForEvent = &pinProject
}
var pinWelcomeIssueForEvent *db.PinnedItem
if welcomeIssueForEvent != nil {
pinWelcome, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
ItemType: "issue",
ItemID: welcomeIssueForEvent.ID,
Position: pinnedProjectPos + 1,
})
if err != nil {
slog.Warn("import starter content: pin welcome issue failed", append(logger.RequestAttrs(r), "error", err)...)
} else {
pinWelcomeIssueForEvent = &pinWelcome
}
}
// --- Flip state ---
updatedUser, err := qtx.SetStarterContentState(r.Context(), db.SetStarterContentStateParams{
ID: parseUUID(userID),
StarterContentState: pgtype.Text{String: "imported", Valid: true},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit starter content")
return
}
// --- Post-commit: realtime events + agent task enqueue ---
// Realtime fan-out happens here (not inside the tx) because the DB
// commit must land first — otherwise subscribers could receive an
// event for state that's about to be rolled back.
projectResp := projectToResponse(project)
h.publish(protocol.EventProjectCreated, req.WorkspaceID, "member", userID, map[string]any{"project": projectResp})
workspacePrefix := h.getIssuePrefix(r.Context(), wsUUID)
if welcomeIssueForEvent != nil {
welcomeResp := issueToResponse(*welcomeIssueForEvent, workspacePrefix)
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": welcomeResp})
if h.shouldEnqueueAgentTask(r.Context(), *welcomeIssueForEvent) {
h.TaskService.EnqueueTaskForIssue(r.Context(), *welcomeIssueForEvent)
}
}
for _, sub := range subIssuesCreated {
subResp := issueToResponse(sub, workspacePrefix)
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": subResp})
}
// Pin events. Without these, the sidebar's `pinListOptions` query
// stays cached on the pre-import snapshot — only a hard refresh
// surfaces the new pins. Same payload shape as `POST /pins`.
if pinProjectForEvent != nil {
h.publish(protocol.EventPinCreated, req.WorkspaceID, "member", userID, map[string]any{"pin": pinnedItemToResponse(*pinProjectForEvent)})
}
if pinWelcomeIssueForEvent != nil {
h.publish(protocol.EventPinCreated, req.WorkspaceID, "member", userID, map[string]any{"pin": pinnedItemToResponse(*pinWelcomeIssueForEvent)})
}
starterBranch := analytics.StarterContentBranchSelfServe
if hasAgent {
starterBranch = analytics.StarterContentBranchAgentGuided
}
h.Analytics.Capture(analytics.StarterContentDecided(userID, req.WorkspaceID, "imported", starterBranch))
writeJSON(w, http.StatusOK, importStarterContentResponse{
User: userToResponse(updatedUser),
ProjectID: uuidToString(project.ID),
WelcomeIssueID: welcomeIssueID,
})
}
type dismissStarterContentRequest struct {
// WorkspaceID is optional but strongly preferred — when present the
// server derives the starter branch (agent_guided / self_serve) by
// looking at the workspace's current agent list, so analytics can
// split dismiss rate by branch the same way import is split.
// Without it, branch defaults to self_serve (the zero-agent case).
WorkspaceID string `json:"workspace_id,omitempty"`
}
// DismissStarterContent records the user's decision to skip starter
// content. Like Import, this is a NULL -> terminal transition; a
// second call returns 409 with the current state.
//
// Emits `starter_content_decided` with `decision=dismissed`. The
// `branch` property mirrors what ImportStarterContent would have
// written for the same workspace, so the two-sided funnel (import vs
// dismiss by branch) stays directly comparable.
func (h *Handler) DismissStarterContent(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Body is optional for backward-compat with callers that pre-date
// the workspace-id addition. An empty body is a legal dismiss.
var req dismissStarterContentRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if user.StarterContentState.Valid {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "starter content already decided",
"state": user.StarterContentState.String,
})
return
}
// Resolve branch before the update so the analytics event mirrors
// the import-side logic exactly. An unresolvable workspace (malformed
// UUID, user not a member, or empty body) falls back to self_serve —
// the conservative default that matches what Import would emit when
// ListAgents returns empty.
branch := analytics.StarterContentBranchSelfServe
if req.WorkspaceID != "" {
if wsUUID, err := util.ParseUUID(req.WorkspaceID); err == nil {
req.WorkspaceID = uuidToString(wsUUID)
if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
WorkspaceID: wsUUID,
}); err == nil {
agents, err := h.Queries.ListAgents(r.Context(), wsUUID)
if err == nil && len(agents) > 0 {
branch = analytics.StarterContentBranchAgentGuided
}
}
}
}
updated, err := h.Queries.SetStarterContentState(r.Context(), db.SetStarterContentStateParams{
ID: parseUUID(userID),
StarterContentState: pgtype.Text{String: "dismissed", Valid: true},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to record dismiss")
return
}
h.Analytics.Capture(analytics.StarterContentDecided(userID, req.WorkspaceID, "dismissed", branch))
writeJSON(w, http.StatusOK, userToResponse(updated))
}
// strOrNullText converts an empty-meaning-absent string into a
// nullable pgtype.Text. Empty -> SQL NULL; non-empty -> Valid.
func strOrNullText(s string) pgtype.Text {

View File

@@ -312,6 +312,8 @@ func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
if onboardedAt == nil {
t.Fatal("expected onboarded_at to be set")
}
// starter_content_state is claimed defensively so older desktop builds
// (which still render the legacy import dialog on NULL) don't surface it.
if starterContentState == nil || *starterContentState != "imported" {
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
}

View File

@@ -204,23 +204,59 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
// Becoming a workspace member is the physical event that "completes" onboarding —
// keep this atomic with CreateMember so `member` and `onboarded_at`
// can never disagree. COALESCE in MarkUserOnboarded keeps it idempotent.
if _, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID)); err != nil {
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark user onboarded")
return
}
// Brand-new workspaces never have a runtime yet, so seed the
// "install a runtime" issue so the user lands on a concrete next step.
// claimStarterContentStateIfUnset suppresses the legacy starter-content
// dialog on older desktop builds that still render it when the column
// is NULL.
seededIssue, seededIssueCreated, err := ensureNoRuntimeOnboardingIssue(
r.Context(), qtx, ws.ID, parseUUID(userID), updatedUser.Language,
)
if err != nil {
slog.Warn("create workspace: ensure install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", uuidToString(ws.ID))...)
writeError(w, http.StatusInternalServerError, "failed to seed onboarding issue")
return
}
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), updatedUser.StarterContentState); err != nil {
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace")
return
}
wsID := uuidToString(ws.ID)
// "Is this the user's first workspace?" is derived in PostHog by looking
// at whether they have a prior workspace_created event, not stamped at
// emit time. Stamping here would race under concurrent creates without
// a schema change, and the event stream answers the question exactly.
h.Analytics.Capture(analytics.WorkspaceCreated(userID, uuidToString(ws.ID)))
h.Analytics.Capture(analytics.WorkspaceCreated(userID, wsID))
slog.Info("workspace created", append(logger.RequestAttrs(r), "workspace_id", uuidToString(ws.ID), "name", ws.Name, "slug", ws.Slug)...)
if seededIssueCreated {
prefix := h.getIssuePrefix(r.Context(), seededIssue.WorkspaceID)
issueResp := issueToResponse(seededIssue, prefix)
h.publish(protocol.EventIssueCreated, wsID, "member", userID, map[string]any{"issue": issueResp})
h.Analytics.Capture(analytics.IssueCreated(
userID,
wsID,
uuidToString(seededIssue.ID),
"",
"",
"",
analytics.SourceOnboarding,
))
}
slog.Info("workspace created", append(logger.RequestAttrs(r), "workspace_id", wsID, "name", ws.Name, "slug", ws.Slug)...)
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
}

View File

@@ -0,0 +1,5 @@
-- No-op: we cannot distinguish rows backfilled by this migration from rows
-- that legitimately reached 'imported' through the normal flow, and the
-- column itself is preserved by the MUL-2438 refactor. Reverting would
-- either be lossy or wrong.
SELECT 1;

View File

@@ -0,0 +1,12 @@
-- Backfill `starter_content_state` for users who finished onboarding between
-- the original 054 migration and the removal of the starter-content kit
-- (MUL-2438). 054 only covered pre-feature users; everyone onboarded in the
-- window since then could still be sitting at NULL. Old desktop clients gate
-- the legacy StarterContentPrompt on `starter_content_state IS NULL`, and the
-- /api/me/starter-content/import|dismiss routes no longer exist, so leaving
-- these rows NULL would surface a dialog whose buttons 404. Mark them
-- 'imported' to match the new helper's claim semantics.
UPDATE "user"
SET starter_content_state = 'imported'
WHERE onboarded_at IS NOT NULL
AND starter_content_state IS NULL;