diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 7873085bd..2aa891379 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -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
diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
index 54896fe72..acf31c091 100644
--- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx
+++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
@@ -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() {
{slug && }
{slug && }
- {slug && }
diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx
index 43769cc98..d2b3e0175 100644
--- a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx
+++ b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx
@@ -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 }) {
-
>
}
>
diff --git a/docs/analytics.md b/docs/analytics.md
index 9a415bbad..15748877f 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -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
diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts
index 4850c9f58..3328d6ab3 100644
--- a/packages/core/api/client.ts
+++ b/packages/core/api/client.ts
@@ -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 {
- return this.fetch("/api/me/starter-content/import", {
- method: "POST",
- body: JSON.stringify(payload),
- });
- }
-
- async dismissStarterContent(payload?: {
- workspace_id?: string;
- }): Promise {
- return this.fetch("/api/me/starter-content/dismiss", {
- method: "POST",
- body: payload ? JSON.stringify(payload) : undefined,
- });
- }
-
async updateMe(data: UpdateMeRequest): Promise {
return this.fetch("/api/me", {
method: "PATCH",
diff --git a/packages/core/api/index.ts b/packages/core/api/index.ts
index 2c33295a4..49e06bc03 100644
--- a/packages/core/api/index.ts
+++ b/packages/core/api/index.ts
@@ -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";
diff --git a/packages/core/onboarding/store.ts b/packages/core/onboarding/store.ts
index f6181850d..abc4a9dc9 100644
--- a/packages/core/onboarding/store.ts
+++ b/packages/core/onboarding/store.ts
@@ -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,
diff --git a/packages/core/types/workspace.ts b/packages/core/types/workspace.ts
index a8ac88418..66e373d86 100644
--- a/packages/core/types/workspace.ts
+++ b/packages/core/types/workspace.ts
@@ -39,14 +39,11 @@ export interface User {
*/
onboarding_questionnaire: Record;
/**
- * 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". */
diff --git a/packages/views/locales/en/onboarding.json b/packages/views/locales/en/onboarding.json
index 51a64f3a1..11b356b01 100644
--- a/packages/views/locales/en/onboarding.json
+++ b/packages/views/locales/en/onboarding.json
@@ -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.",
diff --git a/packages/views/locales/zh-Hans/onboarding.json b/packages/views/locales/zh-Hans/onboarding.json
index 9467194a6..3a3593471 100644
--- a/packages/views/locales/zh-Hans/onboarding.json
+++ b/packages/views/locales/zh-Hans/onboarding.json
@@ -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 —— 如果现在跳过,工作区会是只读状态,直到你回来安装一个。",
diff --git a/packages/views/onboarding/components/starter-content-prompt.tsx b/packages/views/onboarding/components/starter-content-prompt.tsx
deleted file mode 100644
index 2c872ef2b..000000000
--- a/packages/views/onboarding/components/starter-content-prompt.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
-
-// 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,
-): 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) };
-}
diff --git a/packages/views/onboarding/index.ts b/packages/views/onboarding/index.ts
index c8611c7a6..6be25cd86 100644
--- a/packages/views/onboarding/index.ts
+++ b/packages/views/onboarding/index.ts
@@ -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";
diff --git a/packages/views/onboarding/onboarding-flow.tsx b/packages/views/onboarding/onboarding-flow.tsx
index 111e89b41..3c19f1fb8 100644
--- a/packages/views/onboarding/onboarding-flow.tsx
+++ b/packages/views/onboarding/onboarding-flow.tsx
@@ -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) });
diff --git a/packages/views/onboarding/steps/step-first-issue.tsx b/packages/views/onboarding/steps/step-first-issue.tsx
index 1a5aca572..89240e560 100644
--- a/packages/views/onboarding/steps/step-first-issue.tsx
+++ b/packages/views/onboarding/steps/step-first-issue.tsx
@@ -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`
diff --git a/packages/views/onboarding/steps/step-role.tsx b/packages/views/onboarding/steps/step-role.tsx
index 35987f2da..a67f4b6d1 100644
--- a/packages/views/onboarding/steps/step-role.tsx
+++ b/packages/views/onboarding/steps/step-role.tsx
@@ -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,
diff --git a/packages/views/onboarding/utils/starter-content-content-en.ts b/packages/views/onboarding/utils/starter-content-content-en.ts
deleted file mode 100644
index 70c179c97..000000000
--- a/packages/views/onboarding/utils/starter-content-content-en.ts
+++ /dev/null
@@ -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];
-}
diff --git a/packages/views/onboarding/utils/starter-content-content-zh.ts b/packages/views/onboarding/utils/starter-content-content-zh.ts
deleted file mode 100644
index cfe9c576b..000000000
--- a/packages/views/onboarding/utils/starter-content-content-zh.ts
+++ /dev/null
@@ -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 —— 桌面应用(macOS,Mac 推荐)**:`,
- `1. 去 [github.com/multica-ai/multica/releases/latest](https://github.com/multica-ai/multica/releases/latest) 下载 macOS 的 \`.dmg\``,
- `2. 安装并打开`,
- `3. 用同一个账号登录 —— 守护进程内置,到此结束`,
- ``,
- `**方案 B —— CLI(macOS、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];
-}
diff --git a/packages/views/onboarding/utils/starter-content-templates.ts b/packages/views/onboarding/utils/starter-content-templates.ts
deleted file mode 100644
index bebe6885e..000000000
--- a/packages/views/onboarding/utils/starter-content-templates.ts
+++ /dev/null
@@ -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),
- ),
- };
-}
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index eb8f85017..647d7903b 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -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)
diff --git a/server/internal/analytics/events.go b/server/internal/analytics/events.go
index 49749f751..394aea4f0 100644
--- a/server/internal/analytics/events.go
+++ b/server/internal/analytics/events.go
@@ -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 /
diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go
index 0ba3e25e7..541ad23cc 100644
--- a/server/internal/handler/handler_test.go
+++ b/server/internal/handler/handler_test.go
@@ -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 {
diff --git a/server/internal/handler/invitation.go b/server/internal/handler/invitation.go
index ac1096173..02ccfe013 100644
--- a/server/internal/handler/invitation.go
+++ b/server/internal/handler/invitation.go
@@ -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.
diff --git a/server/internal/handler/no_runtime_issue.go b/server/internal/handler/no_runtime_issue.go
new file mode 100644
index 000000000..9b0a1af49
--- /dev/null
+++ b/server/internal/handler/no_runtime_issue.go
@@ -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
+}
diff --git a/server/internal/handler/onboarding.go b/server/internal/handler/onboarding.go
index 89336545b..0116ed75b 100644
--- a/server/internal/handler/onboarding.go
+++ b/server/internal/handler/onboarding.go
@@ -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 Q1–Q3 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 = ©
- }
-
- // --- 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 {
diff --git a/server/internal/handler/onboarding_test.go b/server/internal/handler/onboarding_test.go
index 728ae9d9f..f54283df2 100644
--- a/server/internal/handler/onboarding_test.go
+++ b/server/internal/handler/onboarding_test.go
@@ -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)
}
diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go
index a1a9541a1..9e4ca8bd2 100644
--- a/server/internal/handler/workspace.go
+++ b/server/internal/handler/workspace.go
@@ -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))
}
diff --git a/server/migrations/095_backfill_starter_content_state.down.sql b/server/migrations/095_backfill_starter_content_state.down.sql
new file mode 100644
index 000000000..ac93dcbcb
--- /dev/null
+++ b/server/migrations/095_backfill_starter_content_state.down.sql
@@ -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;
diff --git a/server/migrations/095_backfill_starter_content_state.up.sql b/server/migrations/095_backfill_starter_content_state.up.sql
new file mode 100644
index 000000000..4e41e2e58
--- /dev/null
+++ b/server/migrations/095_backfill_starter_content_state.up.sql
@@ -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;