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 ( - { - eventDetails.cancel(); - }} - > - - - - {t(($) => $.starter_content.title)} - - - {t(($) => $.starter_content.description_prefix)} - - {t(($) => $.starter_content.description_term)} - - {t(($) => $.starter_content.description_suffix)} - - - - - - - - - - ); -} - -// 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;