mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
4 Commits
codex/agen
...
codex/onbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01e185059f | ||
|
|
3f56459249 | ||
|
|
eb747471af | ||
|
|
dce883e168 |
@@ -62,13 +62,13 @@ function WindowOverlayInner() {
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
onComplete={(ws, issueId) => {
|
||||
close();
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
// Runtime-connected onboarding lands on its single guide
|
||||
// issue. Runtime-less exits still land on the issues list.
|
||||
if (ws && issueId) {
|
||||
push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
* On complete: runtime-connected onboarding may provide a guide issue id;
|
||||
* navigate there. Otherwise land on the workspace issues list, or root if
|
||||
* the flow never produced a workspace.
|
||||
*
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
@@ -34,6 +34,14 @@ export default function OnboardingPage() {
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
// The bootstrap path calls refreshMe() before returning, which flips
|
||||
// hasOnboarded to true while the page is still mounted. Without this
|
||||
// flag the guard below races onComplete: the guard's router.replace
|
||||
// (issues list) can overtake onComplete's router.push (guide issue),
|
||||
// dropping the user on the wrong destination. Marking the page as
|
||||
// "completing" right before onComplete navigates keeps the guard
|
||||
// silent for the in-flight transition.
|
||||
const completingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
@@ -41,6 +49,7 @@ export default function OnboardingPage() {
|
||||
return;
|
||||
}
|
||||
if (!workspacesFetched) return;
|
||||
if (completingRef.current) return;
|
||||
// Bounce out only when onboarding genuinely doesn't apply: the user is
|
||||
// already onboarded. We deliberately don't bounce on `workspaces.length`
|
||||
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
|
||||
@@ -62,12 +71,14 @@ export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
onComplete={(ws, issueId) => {
|
||||
// Runtime-connected onboarding now creates one focused
|
||||
// onboarding issue. Skip/runtime-less exits still land on the
|
||||
// workspace issues list.
|
||||
completingRef.current = true;
|
||||
if (ws && issueId) {
|
||||
router.push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
|
||||
@@ -32,7 +32,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 2. Source step
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 1 of 7`)).toBeVisible();
|
||||
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
|
||||
|
||||
@@ -42,7 +42,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 3. Role step
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 2 of 7`)).toBeVisible();
|
||||
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
|
||||
|
||||
@@ -51,7 +51,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 4. Use case step
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 3 of 7`)).toBeVisible();
|
||||
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ import {
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
@@ -160,6 +162,30 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface OnboardingRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
agent_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export interface OnboardingNoRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingNoRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
@@ -414,6 +440,43 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrapOnboardingRuntime(payload: {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
}): Promise<OnboardingRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrapOnboardingNoRuntime(payload: {
|
||||
workspace_id: string;
|
||||
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/no-runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
|
||||
@@ -198,6 +198,17 @@ export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
export {
|
||||
saveQuestionnaire,
|
||||
completeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
joinCloudWaitlist,
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { QuestionnaireAnswers, Role, UseCase } from "./types";
|
||||
|
||||
/**
|
||||
* Identifier for the four agent templates offered during onboarding
|
||||
* Step 6 (Agent). Keep in sync with the template registry inside
|
||||
* StepAgent in `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
* Identifier for the four legacy onboarding agent templates. Keep in
|
||||
* sync with the template registry inside StepAgent in
|
||||
* `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
*/
|
||||
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
|
||||
|
||||
|
||||
@@ -20,6 +20,5 @@ export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
|
||||
"use_case",
|
||||
"workspace",
|
||||
"runtime",
|
||||
"agent",
|
||||
"first_issue",
|
||||
"teammate",
|
||||
] as const;
|
||||
|
||||
@@ -53,6 +53,38 @@ export async function completeOnboarding(
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-connected onboarding path. The server creates or reuses the
|
||||
* default Multica Helper agent and the single onboarding issue, marks
|
||||
* onboarding complete, and suppresses the older starter-content prompt.
|
||||
*/
|
||||
export async function bootstrapRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingRuntime({
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: runtimeId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-skipped onboarding path. The server creates or reuses one
|
||||
* self-serve onboarding issue, marks onboarding complete, and suppresses
|
||||
* the older starter-content prompt so the user is not flooded with tasks.
|
||||
*/
|
||||
export async function bootstrapNoRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspace_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingNoRuntime({
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records interest in cloud runtimes. Pure side effect — does NOT
|
||||
* complete onboarding; the user still has to pick a real Step 3
|
||||
|
||||
@@ -5,6 +5,7 @@ export type OnboardingStep =
|
||||
| "use_case"
|
||||
| "workspace"
|
||||
| "runtime"
|
||||
| "teammate"
|
||||
| "agent"
|
||||
| "first_issue";
|
||||
|
||||
|
||||
@@ -190,6 +190,20 @@
|
||||
"add_more_hint": "Add more agents anytime. A small team of specialized agents beats one jack-of-all-trades.",
|
||||
"docs_link": "Creating your first agent →"
|
||||
},
|
||||
"step_teammate": {
|
||||
"eyebrow": "First teammate",
|
||||
"headline": "Create your first teammate: Multica Helper",
|
||||
"lede": "Multica Helper is a starter agent connected to {{runtime}}. It will open your first guided issue and help you learn the core workflow.",
|
||||
"avatar_label": "Multica Helper avatar",
|
||||
"name": "Multica Helper",
|
||||
"role": "Onboarding guide",
|
||||
"point_issue": "Starts from one onboarding issue",
|
||||
"point_context": "Uses your workspace context and issue details",
|
||||
"point_customize": "You can rename or replace it later",
|
||||
"footer_hint": "We'll create one agent and one onboarding issue.",
|
||||
"create_action": "Create Multica Helper",
|
||||
"create_failed": "Failed to create Multica Helper"
|
||||
},
|
||||
"step_workspace": {
|
||||
"eyebrow_first": "Your first workspace",
|
||||
"eyebrow_resume": "Pick up or start fresh",
|
||||
@@ -232,8 +246,8 @@
|
||||
"perk_invite": "Invite teammates — they see only this workspace",
|
||||
"perk_switch": "Switch to other workspaces anytime from the top-left",
|
||||
"next_runtime": "Connect a computer so your agents have somewhere to run",
|
||||
"next_agent": "Create your first agent matched to your role",
|
||||
"next_starter": "Watch it pick up a starter task and reply",
|
||||
"next_agent": "Create Multica Helper from your connected runtime",
|
||||
"next_starter": "Open one onboarding issue for your next step",
|
||||
"preview": {
|
||||
"inbox_label": "Inbox",
|
||||
"inbox_meta": "your notifications",
|
||||
|
||||
@@ -190,6 +190,20 @@
|
||||
"add_more_hint": "随时添加更多智能体。一支专长各异的小团队胜过一个万能选手。",
|
||||
"docs_link": "创建第一个智能体 →"
|
||||
},
|
||||
"step_teammate": {
|
||||
"eyebrow": "第一个队友",
|
||||
"headline": "创建你的第一个队友:Multica Helper",
|
||||
"lede": "Multica Helper 是连接到 {{runtime}} 的入门智能体。它会打开一个上手引导 issue,带你走完核心流程。",
|
||||
"avatar_label": "Multica Helper 头像",
|
||||
"name": "Multica Helper",
|
||||
"role": "上手引导",
|
||||
"point_issue": "只从一个上手引导 issue 开始",
|
||||
"point_context": "读取工作区上下文和 issue 详情",
|
||||
"point_customize": "之后可以改名或替换",
|
||||
"footer_hint": "我们会创建一个智能体和一个上手引导 issue。",
|
||||
"create_action": "创建 Multica Helper",
|
||||
"create_failed": "创建 Multica Helper 失败"
|
||||
},
|
||||
"step_workspace": {
|
||||
"eyebrow_first": "你的第一个工作区",
|
||||
"eyebrow_resume": "继续,或重新开始",
|
||||
@@ -232,8 +246,8 @@
|
||||
"perk_invite": "邀请同事 —— 他们只会看到这个工作区",
|
||||
"perk_switch": "随时从左上角切换到其他工作区",
|
||||
"next_runtime": "连接一台电脑,给智能体一个跑的地方",
|
||||
"next_agent": "创建符合你角色的第一个智能体",
|
||||
"next_starter": "看它接下入门 task 并回复",
|
||||
"next_agent": "用你连接的运行时创建 Multica Helper",
|
||||
"next_starter": "打开一个上手引导 issue,继续下一步",
|
||||
"preview": {
|
||||
"inbox_label": "收件箱",
|
||||
"inbox_meta": "你的通知",
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("StepHeader", () => {
|
||||
});
|
||||
|
||||
it("shows 'Step N of M' text matching the current step's position", () => {
|
||||
// workspace is index 3 (after source/role/use_case) → Step 4 of 7
|
||||
// workspace is index 3 (after source/role/use_case) → Step 4.
|
||||
render(<StepHeader currentStep="workspace" />);
|
||||
expect(
|
||||
screen.getByText(`Step 4 of ${ONBOARDING_STEP_ORDER.length}`),
|
||||
@@ -39,9 +39,9 @@ describe("StepHeader", () => {
|
||||
});
|
||||
|
||||
it("sets accessible progressbar attrs", () => {
|
||||
render(<StepHeader currentStep="agent" />);
|
||||
render(<StepHeader currentStep="runtime" />);
|
||||
const bar = screen.getByRole("progressbar");
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "6"); // agent is index 5 → step 6
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "5"); // runtime is index 4 → step 5
|
||||
expect(bar).toHaveAttribute("aria-valuemax", String(ONBOARDING_STEP_ORDER.length));
|
||||
});
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@ import { captureEvent } from "@multica/core/analytics";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
completeOnboarding,
|
||||
ONBOARDING_STEP_ORDER,
|
||||
saveQuestionnaire,
|
||||
type OnboardingCompletionPath,
|
||||
type OnboardingStep,
|
||||
type QuestionnaireAnswers,
|
||||
} from "@multica/core/onboarding";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { workspaceListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import type { Agent, AgentRuntime, Workspace } from "@multica/core/types";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { StepHeader } from "./components/step-header";
|
||||
import type { AgentRuntime, Workspace } from "@multica/core/types";
|
||||
import { StepWelcome } from "./steps/step-welcome";
|
||||
import { StepSource } from "./steps/step-source";
|
||||
import { StepRole } from "./steps/step-role";
|
||||
@@ -25,8 +25,7 @@ import { StepUseCase } from "./steps/step-use-case";
|
||||
import { StepWorkspace } from "./steps/step-workspace";
|
||||
import { StepRuntimeConnect } from "./steps/step-runtime-connect";
|
||||
import { StepPlatformFork } from "./steps/step-platform-fork";
|
||||
import { StepAgent } from "./steps/step-agent";
|
||||
import { StepFirstIssue } from "./steps/step-first-issue";
|
||||
import { StepTeammate } from "./steps/step-teammate";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
|
||||
@@ -62,18 +61,18 @@ function mergeQuestionnaire(
|
||||
|
||||
/**
|
||||
* Shell's onComplete contract:
|
||||
* onComplete(workspace?) — if present, navigate into its issues list;
|
||||
* if omitted, fall back to root. A Starter-content opt-in dialog runs
|
||||
* on the issues page itself (see `StarterContentPrompt`), so the flow
|
||||
* doesn't carry `firstIssueId` any more — there is no welcome issue
|
||||
* created by onboarding.
|
||||
* onComplete(workspace?, issueId?) — if an issue id is present, navigate
|
||||
* 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.
|
||||
*/
|
||||
export function OnboardingFlow({
|
||||
onComplete,
|
||||
runtimeInstructions,
|
||||
onRuntimeRefresh,
|
||||
}: {
|
||||
onComplete: (workspace?: Workspace) => void;
|
||||
onComplete: (workspace?: Workspace, issueId?: string) => void;
|
||||
runtimeInstructions?: React.ReactNode;
|
||||
/** Desktop wires this to restart the bundled daemon so a freshly
|
||||
* installed agent CLI gets picked up on the runtime step. Web omits
|
||||
@@ -99,7 +98,6 @@ export function OnboardingFlow({
|
||||
const [step, setStep] = useState<OnboardingStep>("welcome");
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||
const [runtime, setRuntime] = useState<AgentRuntime | null>(null);
|
||||
const [, setAgent] = useState<Agent | null>(null);
|
||||
|
||||
// Fetched at Step 0 + Step 2. Step 2 uses it to detect a pre-existing
|
||||
// workspace from an earlier abandoned onboarding (so StepWorkspace shows
|
||||
@@ -200,34 +198,51 @@ export function OnboardingFlow({
|
||||
[advanceFrom],
|
||||
);
|
||||
|
||||
const handleRuntimeNext = useCallback((rt: AgentRuntime | null) => {
|
||||
setRuntime(rt);
|
||||
// Custom branch — not derived from ONBOARDING_STEP_ORDER because no
|
||||
// runtime → no agent possible, so we skip Step 4 ("agent") and go
|
||||
// straight to the finalizer. The post-landing StarterContentPrompt
|
||||
// will detect "no agent in this workspace" and offer the self-serve
|
||||
// template.
|
||||
setStep(rt ? "agent" : "first_issue");
|
||||
}, []);
|
||||
|
||||
const handleAgentCreated = useCallback(
|
||||
(created: Agent) => {
|
||||
setAgent(created);
|
||||
// Mark the workspace's agent list stale so the dashboard's first
|
||||
// mount refetches and includes the just-created agent. Without
|
||||
// this, anything resolving an agent ID from the cached list (the
|
||||
// welcome issue's assignee in particular) renders as "Unknown
|
||||
// Agent" until something else triggers a refetch.
|
||||
if (workspace) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: workspaceKeys.agents(workspace.id),
|
||||
});
|
||||
const handleRuntimeNext = useCallback(
|
||||
async (rt: AgentRuntime | null) => {
|
||||
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.
|
||||
try {
|
||||
const result = await bootstrapNoRuntimeOnboarding(workspace.id);
|
||||
await qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) });
|
||||
onComplete(workspace, result.issue_id || undefined);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
advanceFrom("agent");
|
||||
|
||||
setRuntime(rt);
|
||||
advanceFrom("runtime");
|
||||
},
|
||||
[workspace, qc, advanceFrom],
|
||||
[workspace, qc, onComplete, t, advanceFrom],
|
||||
);
|
||||
|
||||
const handleCreateTeammate = useCallback(async () => {
|
||||
if (!workspace || !runtime) return;
|
||||
|
||||
try {
|
||||
const result = await bootstrapRuntimeOnboarding(workspace.id, runtime.id);
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(workspace.id) }),
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }),
|
||||
]);
|
||||
onComplete(workspace, result.issue_id || undefined);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.step_teammate.create_failed),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, [workspace, runtime, qc, onComplete, t]);
|
||||
|
||||
const handleBack = useCallback((from: OnboardingStep) => {
|
||||
const idx = ONBOARDING_STEP_ORDER.indexOf(from);
|
||||
if (idx <= 0) {
|
||||
@@ -239,18 +254,9 @@ export function OnboardingFlow({
|
||||
setStep(prev);
|
||||
}, []);
|
||||
|
||||
// Step 5 fired `completeOnboarding` itself. Here we just route the
|
||||
// user to their workspace — the starter-content decision happens
|
||||
// inside the workspace via the `StarterContentPrompt` dialog.
|
||||
const handleFinished = useCallback(() => {
|
||||
onComplete(workspace ?? undefined);
|
||||
}, [workspace, onComplete]);
|
||||
|
||||
// Welcome, Questionnaire, and Workspace own full-bleed two-column
|
||||
// layouts (hero / side panel) with their own DragStrip + StepHeader.
|
||||
// The remaining steps (runtime / agent / first_issue) still render
|
||||
// inside a narrow legacy single-column shell below — they'll each
|
||||
// move out as they get redesigned.
|
||||
// The runtime step owns its own full-bleed shell.
|
||||
if (step === "welcome") {
|
||||
return (
|
||||
<StepWelcome
|
||||
@@ -334,48 +340,17 @@ export function OnboardingFlow({
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4 owns the same full-bleed editorial shell as Workspace /
|
||||
// Questionnaire. `questionnaire` is threaded through so StepAgent
|
||||
// can recommend a template based on the user's Q1–Q3 answers.
|
||||
// No skip path: reaching Step 4 means a runtime was picked at
|
||||
// Step 3, so creating the agent IS the step's purpose. Users who
|
||||
// want a runtime-less workspace bypass at Step 3 and skip Step 4
|
||||
// entirely (flow routes runtime=null → first_issue directly).
|
||||
if (step === "agent" && runtime) {
|
||||
if (step === "teammate" && workspace && runtime) {
|
||||
return (
|
||||
<StepAgent
|
||||
<StepTeammate
|
||||
runtime={runtime}
|
||||
questionnaire={answers}
|
||||
onCreated={handleAgentCreated}
|
||||
onBack={() => handleBack("agent")}
|
||||
onCreate={handleCreateTeammate}
|
||||
onBack={() => handleBack("teammate")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Derive the completion-path label for Step 5. The cloud-waitlist
|
||||
// exit was removed from Step 3 (replaced with a "Coming soon" badge)
|
||||
// so this is now a binary: runtime → "full", no runtime → "runtime_skipped".
|
||||
const completionPath: OnboardingCompletionPath = runtime
|
||||
? "full"
|
||||
: "runtime_skipped";
|
||||
|
||||
return (
|
||||
<div className="animate-onboarding-enter flex min-h-full flex-col">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col items-center px-6 pb-12">
|
||||
<div className="flex w-full max-w-xl flex-col gap-8">
|
||||
<StepHeader currentStep={step} />
|
||||
{step === "first_issue" && (
|
||||
<StepFirstIssue
|
||||
onFinished={handleFinished}
|
||||
completionPath={completionPath}
|
||||
workspaceId={workspace?.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export type { OnboardingStep };
|
||||
|
||||
@@ -13,10 +13,11 @@ import { useT } from "../../i18n";
|
||||
/**
|
||||
* Step 5 — the final onboarding beat.
|
||||
*
|
||||
* All this step does now is flip `onboarded_at` on the server. The former
|
||||
* in-flight bootstrap (welcome issue + Getting Started project + sub-issues)
|
||||
* moved out of onboarding entirely: it's a post-landing opt-in dialog
|
||||
* (`StarterContentPrompt`) that runs inside the workspace after navigation.
|
||||
* 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.
|
||||
* Two consequences of that move:
|
||||
*
|
||||
* 1. This step can't fail in user-visible ways any more. `completeOnboarding`
|
||||
|
||||
@@ -190,8 +190,8 @@ export function StepPlatformFork({
|
||||
|
||||
{/* Inline action bar — hint on the left, Skip on the right.
|
||||
Advancement for the CLI path is owned by the CLI
|
||||
dialog's own "Connect & continue" button; Skip is the
|
||||
self-serve exit. */}
|
||||
dialog's own "Connect & continue" button; Skip creates
|
||||
the single self-serve onboarding issue. */}
|
||||
<div className="mt-8 flex max-w-[560px] flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<span
|
||||
aria-live="polite"
|
||||
@@ -545,4 +545,3 @@ function CliWaitingStatus({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Step 2 — "Which best describes you?" Primary signal for the
|
||||
* agent template recommendation on Step 6.
|
||||
* onboarding assistant and starter issue content.
|
||||
*/
|
||||
export function StepRole({
|
||||
answers,
|
||||
|
||||
@@ -187,8 +187,8 @@ function FancyView({
|
||||
}, [onRefresh, qc, wsId, refreshing]);
|
||||
|
||||
// Skip is always available — regardless of phase. Hitting Skip routes
|
||||
// the flow through the self-serve branch (agent=null), which still
|
||||
// completes onboarding and seeds a Getting Started project.
|
||||
// through the runtime-less branch, which creates one focused self-serve
|
||||
// onboarding issue instead of seeding the old starter project.
|
||||
const handleSkip = async () => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
|
||||
186
packages/views/onboarding/steps/step-teammate.tsx
Normal file
186
packages/views/onboarding/steps/step-teammate.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ComponentType } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { StepHeader } from "../components/step-header";
|
||||
import { RuntimeAsidePanel } from "../components/runtime-aside-panel";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const MULTICA_HELPER_AVATAR_URL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E";
|
||||
|
||||
export function StepTeammate({
|
||||
runtime,
|
||||
onCreate,
|
||||
onBack,
|
||||
}: {
|
||||
runtime: AgentRuntime;
|
||||
onCreate: () => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const { t } = useT("onboarding");
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const fadeStyle = useScrollFade(mainRef);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate();
|
||||
} catch {
|
||||
// The parent owns the toast. This step only restores the button state.
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-onboarding-enter grid h-full min-h-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_480px]">
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<DragStrip />
|
||||
|
||||
<header className="flex shrink-0 items-center gap-4 bg-background px-6 py-3 sm:px-10 md:px-14 lg:px-16">
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={creating}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t(($) => $.common.back)}
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="w-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<StepHeader currentStep="teammate" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
ref={mainRef}
|
||||
style={fadeStyle}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[660px] px-6 py-10 sm:px-10 md:px-14 lg:px-0 lg:py-14">
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t(($) => $.step_teammate.eyebrow)}
|
||||
</div>
|
||||
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
|
||||
{t(($) => $.step_teammate.headline)}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[580px] text-[15.5px] leading-[1.55] text-muted-foreground">
|
||||
{t(($) => $.step_teammate.lede, { runtime: runtime.name })}
|
||||
</p>
|
||||
|
||||
<section className="mt-10 overflow-hidden rounded-xl border bg-card">
|
||||
<div className="flex flex-col gap-6 p-6 sm:flex-row sm:items-center">
|
||||
<img
|
||||
src={MULTICA_HELPER_AVATAR_URL}
|
||||
alt={t(($) => $.step_teammate.avatar_label)}
|
||||
className="h-24 w-24 shrink-0 rounded-2xl shadow-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{t(($) => $.step_teammate.name)}
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full border bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
{t(($) => $.step_teammate.role)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-2 rounded-md bg-muted/70 px-3 py-2 text-sm text-muted-foreground">
|
||||
<ProviderLogo provider={runtime.provider} className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{runtime.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
runtime.status === "online"
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid border-t bg-muted/20 sm:grid-cols-3">
|
||||
<TeammatePoint
|
||||
icon={MessageSquareText}
|
||||
label={t(($) => $.step_teammate.point_issue)}
|
||||
/>
|
||||
<TeammatePoint
|
||||
icon={Sparkles}
|
||||
label={t(($) => $.step_teammate.point_context)}
|
||||
/>
|
||||
<TeammatePoint
|
||||
icon={Settings2}
|
||||
label={t(($) => $.step_teammate.point_customize)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="mr-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{t(($) => $.step_teammate.footer_hint)}
|
||||
</span>
|
||||
<Button size="lg" disabled={creating} onClick={handleCreate}>
|
||||
{creating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
{t(($) => $.step_teammate.create_action)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside className="hidden min-h-0 border-l bg-muted/40 lg:flex lg:flex-col">
|
||||
<DragStrip />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-12 py-12">
|
||||
<RuntimeAsidePanel />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeammatePoint({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[104px] items-start gap-3 border-t p-5 first:border-t-0 sm:border-l sm:border-t-0 sm:first:border-l-0">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-background text-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm leading-[1.45] text-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -234,13 +234,13 @@ function TroubleshootingDetails() {
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span>{t(($) => $.connect.trouble_check_status)}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground">
|
||||
multica daemon status
|
||||
{"multica daemon status"}
|
||||
</code>
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span>{t(($) => $.connect.trouble_view_logs)}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground">
|
||||
multica daemon logs -f
|
||||
{"multica daemon logs -f"}
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -308,6 +308,8 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Patch("/api/me", h.UpdateMe)
|
||||
r.Patch("/api/me/onboarding", h.PatchOnboarding)
|
||||
r.Post("/api/me/onboarding/complete", h.CompleteOnboarding)
|
||||
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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"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"
|
||||
@@ -29,6 +30,10 @@ const (
|
||||
// letting a malicious user stuff the JSONB column.
|
||||
patchOnboardingBodyLimit = 16 * 1024
|
||||
|
||||
// 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
|
||||
@@ -36,6 +41,132 @@ const (
|
||||
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."
|
||||
|
||||
const onboardingAssistantAvatarURL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E"
|
||||
|
||||
const onboardingAssistantInstructions = `You are Multica Helper, the user's first Multica teammate. Your job is to onboard them inside the first issue.
|
||||
|
||||
When the onboarding issue starts, leave a concise first comment that:
|
||||
1. Explains that issues are where work happens in Multica.
|
||||
2. Tells the user they can reply in the thread or @mention you to continue.
|
||||
3. Asks for one concrete task they want help with.
|
||||
4. Mentions that they can create more agents and connect more runtimes later.
|
||||
|
||||
Keep the tone practical. Do not create extra issues or projects unless the user asks.`
|
||||
|
||||
const onboardingIssueDescription = `Welcome to Multica.
|
||||
|
||||
This is your guided first run. Multica Helper is assigned to this issue and will help you try the core workflow:
|
||||
|
||||
1. Read Multica Helper's first comment.
|
||||
2. Reply with something you want to build, fix, write, or plan.
|
||||
3. @mention Multica Helper when you want it to continue.
|
||||
4. Open Agents and Runtimes later when you want to customize the teammate or the computer it runs on.
|
||||
|
||||
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
|
||||
@@ -117,6 +248,429 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, userToResponse(user))
|
||||
}
|
||||
|
||||
type bootstrapOnboardingRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingRuntimeRequest
|
||||
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
|
||||
}
|
||||
if req.RuntimeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
userBefore, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !userBefore.OnboardedAt.Valid
|
||||
|
||||
member, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
runtime, err := qtx.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
||||
ID: runtimeUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can create agents on it")
|
||||
return
|
||||
}
|
||||
|
||||
agents, err := qtx.ListAgents(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||
return
|
||||
}
|
||||
isFirstAgent := len(agents) == 0
|
||||
|
||||
var assistant db.Agent
|
||||
assistantCreated := false
|
||||
// Only reuse helpers this flow could have created: name match AND
|
||||
// workspace-visible. Skipping private agents is the access-control
|
||||
// gate — a private "Multica Helper" owned by another member must not
|
||||
// be auto-assigned to the bootstrap issue, which would bypass
|
||||
// canAccessPrivateAgent and trigger a task as that private agent.
|
||||
for _, existing := range agents {
|
||||
if existing.Name == onboardingAssistantName && existing.Visibility == "workspace" {
|
||||
assistant = existing
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assistant.ID.Valid {
|
||||
assistant, err = qtx.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: onboardingAssistantName,
|
||||
Description: onboardingAssistantDescription,
|
||||
AvatarUrl: pgtype.Text{String: onboardingAssistantAvatarURL, Valid: true},
|
||||
RuntimeMode: runtime.RuntimeMode,
|
||||
RuntimeConfig: []byte("{}"),
|
||||
RuntimeID: runtime.ID,
|
||||
Visibility: "workspace",
|
||||
MaxConcurrentTasks: 6,
|
||||
OwnerID: parseUUID(userID),
|
||||
Instructions: onboardingAssistantInstructions,
|
||||
CustomEnv: []byte("{}"),
|
||||
CustomArgs: []byte("[]"),
|
||||
McpConfig: nil,
|
||||
Model: pgtype.Text{},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: create assistant failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding assistant")
|
||||
return
|
||||
}
|
||||
assistantCreated = true
|
||||
}
|
||||
|
||||
var emptyUUID pgtype.UUID
|
||||
issue, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
r.Context(),
|
||||
qtx,
|
||||
wsUUID,
|
||||
emptyUUID,
|
||||
emptyUUID,
|
||||
onboardingIssueTitle,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: duplicate issue check 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: onboardingIssueTitle,
|
||||
Description: strOrNullText(onboardingIssueDescription),
|
||||
Status: "todo",
|
||||
Priority: "high",
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
AssigneeID: assistant.ID,
|
||||
CreatorType: "member",
|
||||
CreatorID: parseUUID(userID),
|
||||
ParentIssueID: emptyUUID,
|
||||
Position: 0,
|
||||
Number: issueNumber,
|
||||
ProjectID: emptyUUID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap 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 := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if assistantCreated {
|
||||
resp := agentToResponse(assistant)
|
||||
h.publish(protocol.EventAgentCreated, req.WorkspaceID, "member", userID, map[string]any{"agent": resp})
|
||||
h.Analytics.Capture(analytics.AgentCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(assistant.ID),
|
||||
runtime.Provider,
|
||||
runtime.RuntimeMode,
|
||||
onboardingAgentTemplate,
|
||||
isFirstAgent,
|
||||
))
|
||||
}
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(issue.ID),
|
||||
uuidToString(assistant.ID),
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
if h.shouldEnqueueAgentTask(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
analytics.OnboardingPathFull,
|
||||
onboardedAt,
|
||||
updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
if starterContentClaimed {
|
||||
h.Analytics.Capture(analytics.StarterContentDecided(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
"imported",
|
||||
analytics.StarterContentBranchAgentGuided,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
AgentID: uuidToString(assistant.ID),
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *Handler) BootstrapOnboardingNoRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingNoRuntimeRequest
|
||||
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
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
userBefore, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !userBefore.OnboardedAt.Valid
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var emptyUUID pgtype.UUID
|
||||
issue, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
r.Context(),
|
||||
qtx,
|
||||
wsUUID,
|
||||
emptyUUID,
|
||||
emptyUUID,
|
||||
noRuntimeIssueTitle,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap no-runtime onboarding: duplicate issue check 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 := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(issue.ID),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
analytics.OnboardingPathRuntimeSkipped,
|
||||
onboardedAt,
|
||||
updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
if starterContentClaimed {
|
||||
h.Analytics.Capture(analytics.StarterContentDecided(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
"imported",
|
||||
analytics.StarterContentBranchSelfServe,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingNoRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
type patchOnboardingRequest struct {
|
||||
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
|
||||
}
|
||||
|
||||
@@ -110,9 +110,9 @@ func TestJoinCloudWaitlistMissingEmailReturns400(t *testing.T) {
|
||||
userID := newWaitlistTestUser(t, "waitlist-missing@multica.ai")
|
||||
|
||||
cases := []map[string]string{
|
||||
{}, // empty body
|
||||
{"email": ""}, // blank
|
||||
{"email": " "}, // whitespace only
|
||||
{}, // empty body
|
||||
{"email": ""}, // blank
|
||||
{"email": " "}, // whitespace only
|
||||
{"reason": "no email here"},
|
||||
}
|
||||
|
||||
@@ -189,3 +189,337 @@ func TestJoinCloudWaitlistSecondCallOverwrites(t *testing.T) {
|
||||
t.Fatalf("onboarded_at should stay NULL, got %v", *onboardedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `
|
||||
DELETE FROM agent_task_queue
|
||||
WHERE agent_id IN (
|
||||
SELECT id FROM agent
|
||||
WHERE workspace_id = $1 AND name = $2
|
||||
)
|
||||
`, testWorkspaceID, onboardingAssistantName)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
"runtime_id": testRuntimeID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp bootstrapOnboardingRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.WorkspaceID != testWorkspaceID || resp.AgentID == "" || resp.IssueID == "" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
|
||||
var (
|
||||
agentName string
|
||||
agentRuntime string
|
||||
instructions string
|
||||
avatarURL *string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT name, runtime_id, instructions, avatar_url
|
||||
FROM agent
|
||||
WHERE id = $1
|
||||
`, resp.AgentID).Scan(&agentName, &agentRuntime, &instructions, &avatarURL); err != nil {
|
||||
t.Fatalf("lookup assistant: %v", err)
|
||||
}
|
||||
if agentName != onboardingAssistantName {
|
||||
t.Fatalf("agent name = %q, want %q", agentName, onboardingAssistantName)
|
||||
}
|
||||
if agentRuntime != testRuntimeID {
|
||||
t.Fatalf("agent runtime = %q, want %q", agentRuntime, testRuntimeID)
|
||||
}
|
||||
if !strings.Contains(instructions, "onboard them inside the first issue") {
|
||||
t.Fatalf("assistant instructions were not seeded: %q", instructions)
|
||||
}
|
||||
if avatarURL == nil || *avatarURL != onboardingAssistantAvatarURL {
|
||||
t.Fatalf("agent avatar_url = %v, want seeded Multica Helper avatar", avatarURL)
|
||||
}
|
||||
|
||||
var (
|
||||
issueTitle string
|
||||
assigneeType string
|
||||
assigneeID string
|
||||
issueStatus string
|
||||
issuePriority string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT title, assignee_type, assignee_id, status, priority
|
||||
FROM issue
|
||||
WHERE id = $1
|
||||
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority); err != nil {
|
||||
t.Fatalf("lookup onboarding issue: %v", err)
|
||||
}
|
||||
if issueTitle != onboardingIssueTitle {
|
||||
t.Fatalf("issue title = %q, want %q", issueTitle, onboardingIssueTitle)
|
||||
}
|
||||
if assigneeType != "agent" || assigneeID != resp.AgentID {
|
||||
t.Fatalf("issue assignee = %s/%s, want agent/%s", assigneeType, assigneeID, resp.AgentID)
|
||||
}
|
||||
if issueStatus != "todo" || issuePriority != "high" {
|
||||
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
|
||||
}
|
||||
|
||||
var (
|
||||
onboardedAt *time.Time
|
||||
starterContentState *string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT onboarded_at, starter_content_state
|
||||
FROM "user"
|
||||
WHERE id = $1
|
||||
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
|
||||
t.Fatalf("lookup user onboarding state: %v", err)
|
||||
}
|
||||
if onboardedAt == nil {
|
||||
t.Fatal("expected onboarded_at to be set")
|
||||
}
|
||||
if starterContentState == nil || *starterContentState != "imported" {
|
||||
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
|
||||
}
|
||||
|
||||
var taskCount int
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND agent_id = $2
|
||||
`, resp.IssueID, resp.AgentID).Scan(&taskCount); err != nil {
|
||||
t.Fatalf("count queued tasks: %v", err)
|
||||
}
|
||||
if taskCount == 0 {
|
||||
t.Fatal("expected onboarding issue to enqueue an agent task")
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second BootstrapOnboardingRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var resp2 bootstrapOnboardingRuntimeResponse
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
t.Fatalf("decode second response: %v", err)
|
||||
}
|
||||
if resp2.AgentID != resp.AgentID || resp2.IssueID != resp.IssueID {
|
||||
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingNoRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'en' WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp bootstrapOnboardingNoRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.WorkspaceID != testWorkspaceID || resp.IssueID == "" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
|
||||
var (
|
||||
issueTitle string
|
||||
assigneeType string
|
||||
assigneeID string
|
||||
issueStatus string
|
||||
issuePriority string
|
||||
description string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT title, assignee_type, assignee_id, status, priority, description
|
||||
FROM issue
|
||||
WHERE id = $1
|
||||
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority, &description); err != nil {
|
||||
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
|
||||
}
|
||||
if issueTitle != noRuntimeIssueTitle {
|
||||
t.Fatalf("issue title = %q, want %q", issueTitle, noRuntimeIssueTitle)
|
||||
}
|
||||
if assigneeType != "member" || assigneeID != testUserID {
|
||||
t.Fatalf("issue assignee = %s/%s, want member/%s", assigneeType, assigneeID, testUserID)
|
||||
}
|
||||
if issueStatus != "todo" || issuePriority != "high" {
|
||||
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Try Multica first",
|
||||
"https://multica.ai/docs/install-agent-runtime",
|
||||
"npm i -g @openai/codex",
|
||||
} {
|
||||
if !strings.Contains(description, want) {
|
||||
t.Fatalf("issue description missing %q: %q", want, description)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(description, "Agents need a runtime before they can execute work") {
|
||||
t.Fatalf("issue description was not seeded: %q", description)
|
||||
}
|
||||
|
||||
var (
|
||||
onboardedAt *time.Time
|
||||
starterContentState *string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT onboarded_at, starter_content_state
|
||||
FROM "user"
|
||||
WHERE id = $1
|
||||
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
|
||||
t.Fatalf("lookup user onboarding state: %v", err)
|
||||
}
|
||||
if onboardedAt == nil {
|
||||
t.Fatal("expected onboarded_at to be set")
|
||||
}
|
||||
if starterContentState == nil || *starterContentState != "imported" {
|
||||
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
|
||||
}
|
||||
|
||||
var taskCount int
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM agent_task_queue
|
||||
WHERE issue_id = $1
|
||||
`, resp.IssueID).Scan(&taskCount); err != nil {
|
||||
t.Fatalf("count queued tasks: %v", err)
|
||||
}
|
||||
if taskCount != 0 {
|
||||
t.Fatalf("expected no agent tasks for no-runtime issue, got %d", taskCount)
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingNoRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var resp2 bootstrapOnboardingNoRuntimeResponse
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
t.Fatalf("decode second response: %v", err)
|
||||
}
|
||||
if resp2.IssueID != resp.IssueID {
|
||||
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingNoRuntimeUsesChineseGuideForChineseUsers(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'zh-Hans' WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp bootstrapOnboardingNoRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
var description string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT description
|
||||
FROM issue
|
||||
WHERE id = $1
|
||||
`, resp.IssueID).Scan(&description); err != nil {
|
||||
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"先体验项目管理功能",
|
||||
"https://multica.ai/docs/install-agent-runtime",
|
||||
"中文用户建议先装 Kimi CLI",
|
||||
"kimi --version",
|
||||
} {
|
||||
if !strings.Contains(description, want) {
|
||||
t.Fatalf("Chinese issue description missing %q: %q", want, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user