feat(onboarding): add full-screen onboarding wizard for new workspaces (#852)

* feat(onboarding): add full-screen onboarding wizard for new workspaces

Replace auto-provisioned workspace with an interactive 4-step onboarding
wizard: Create Workspace → Connect Runtime → Create Agent → Get Started.

- Remove server-side ensureUserWorkspace() so new users land in onboarding
- Add onboarding wizard in packages/views/onboarding/ (4 steps)
- Wire login/OAuth callbacks to redirect to /onboarding when no workspace
- Add DashboardGuard onboardingPath fallback for workspace-less users
- Sidebar "Create workspace" navigates to /onboarding instead of modal
- Remove CreateWorkspaceModal (replaced by wizard step 1)
- Auto-generate workspace slug from name (no user-facing URL field)
- Unified CLI install flow: install.sh + multica setup (auto-detects local)
- Create onboarding issues on completion with interactive "Say hello" task

* test(auth): update workspace tests to match onboarding flow

Login no longer auto-creates workspaces — new users start with zero
workspaces and create one through the onboarding wizard. Update both
integration and handler tests to assert 0 workspaces after verify-code.
This commit is contained in:
Jiayuan Zhang
2026-04-13 17:59:51 +08:00
committed by GitHub
parent 12bf7cac34
commit 01232fc2f9
21 changed files with 992 additions and 301 deletions

View File

@@ -3,6 +3,7 @@
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
@@ -31,9 +32,14 @@ function LoginPageContent() {
? localStorage.getItem("multica_workspace_id")
: null;
const handleSuccess = () => {
const ws = useWorkspaceStore.getState().workspace;
router.push(ws ? nextUrl : "/onboarding");
};
return (
<LoginPage
onSuccess={() => router.push(nextUrl)}
onSuccess={handleSuccess}
google={
googleClientId
? {

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { OnboardingWizard } from "@multica/views/onboarding";
export default function OnboardingPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace("/login");
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return (
<OnboardingWizard onComplete={() => router.push("/issues")} />
);
}

View File

@@ -11,6 +11,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
onboardingPath="/onboarding"
>
{children}
</DashboardLayout>

View File

@@ -62,8 +62,8 @@ function CallbackContent() {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push("/issues");
const ws = await hydrateWorkspace(wsList, lastWsId);
router.push(ws ? "/issues" : "/onboarding");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

@@ -2,7 +2,7 @@
import { create } from "zustand";
type ModalType = "create-workspace" | "create-issue" | null;
type ModalType = "create-issue" | null;
interface ModalStore {
modal: ModalType;

View File

@@ -279,7 +279,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</DropdownMenuItem>
))}
<DropdownMenuItem
onClick={() => useModalStore.getState().open("create-workspace")}
onClick={() => push("/onboarding")}
>
<Plus className="h-3.5 w-3.5" />
Create workspace

View File

@@ -8,6 +8,8 @@ interface DashboardGuardProps {
children: ReactNode;
/** Path to redirect to when user is not authenticated */
loginPath?: string;
/** Path to redirect to when user has no workspace (onboarding) */
onboardingPath?: string;
/** Rendered when auth or workspace is loading */
loadingFallback?: ReactNode;
}
@@ -21,9 +23,10 @@ interface DashboardGuardProps {
export function DashboardGuard({
children,
loginPath = "/",
onboardingPath,
loadingFallback = null,
}: DashboardGuardProps) {
const { user, isLoading, workspace } = useDashboardGuard(loginPath);
const { user, isLoading, workspace } = useDashboardGuard(loginPath, onboardingPath);
if (isLoading || !workspace) return <>{loadingFallback}</>;
if (!user) return null;

View File

@@ -14,6 +14,8 @@ interface DashboardLayoutProps {
searchSlot?: ReactNode;
/** Loading indicator */
loadingIndicator?: ReactNode;
/** Path to redirect when user has no workspace */
onboardingPath?: string;
}
export function DashboardLayout({
@@ -21,10 +23,12 @@ export function DashboardLayout({
extra,
searchSlot,
loadingIndicator,
onboardingPath,
}: DashboardLayoutProps) {
return (
<DashboardGuard
loginPath="/"
onboardingPath={onboardingPath}
loadingFallback={
<div className="flex h-svh items-center justify-center">
{loadingIndicator}

View File

@@ -6,15 +6,22 @@ import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useNavigation } from "../navigation";
export function useDashboardGuard(loginPath = "/") {
export function useDashboardGuard(loginPath = "/", onboardingPath?: string) {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (!isLoading && !user) push(loginPath);
}, [user, isLoading, push, loginPath]);
if (isLoading) return;
if (!user) {
push(loginPath);
return;
}
if (!workspace && onboardingPath) {
push(onboardingPath);
}
}, [user, isLoading, workspace, push, loginPath, onboardingPath]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);

View File

@@ -1,132 +0,0 @@
"use client";
import { useState } from "react";
import { useNavigation } from "../navigation";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
const router = useNavigation();
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const slugError =
slug.length > 0 && !SLUG_REGEX.test(slug)
? "Only lowercase letters, numbers, and hyphens"
: null;
const canSubmit = name.trim().length > 0 && slug.trim().length > 0 && !slugError;
const handleNameChange = (value: string) => {
setName(value);
setSlug(
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, ""),
);
};
const handleCreate = () => {
if (!canSubmit) return;
createWorkspace.mutate(
{ name: name.trim(), slug: slug.trim() },
{
onSuccess: () => {
onClose();
router.push("/issues");
},
onError: () => {
toast.error("Failed to create workspace");
},
},
);
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
showCloseButton={false}
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
>
<Button
variant="ghost"
size="sm"
className="absolute top-6 left-6 text-muted-foreground"
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex w-full max-w-md flex-col items-center gap-6">
<div className="text-center">
<DialogTitle className="text-2xl font-semibold">
Create a new workspace
</DialogTitle>
<DialogDescription className="mt-2">
Workspaces are shared environments where teams can work on
projects and issues.
</DialogDescription>
</div>
<Card className="w-full">
<CardContent className="space-y-4 pt-6">
<div className="space-y-1.5">
<Label>Workspace Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
/>
</div>
<div className="space-y-1.5">
<Label>Workspace URL</Label>
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
<span className="pl-3 text-sm text-muted-foreground select-none">
multica.app/
</span>
<Input
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="my-workspace"
className="border-0 shadow-none focus-visible:ring-0"
/>
</div>
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</div>
</CardContent>
</Card>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={createWorkspace.isPending || !canSubmit}
>
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useModalStore } from "@multica/core/modals";
import { CreateWorkspaceModal } from "./create-workspace";
import { CreateIssueModal } from "./create-issue";
export function ModalRegistry() {
@@ -10,8 +9,6 @@ export function ModalRegistry() {
const close = useModalStore((s) => s.close);
switch (modal) {
case "create-workspace":
return <CreateWorkspaceModal onClose={close} />;
case "create-issue":
return <CreateIssueModal onClose={close} data={data} />;
default:

View File

@@ -0,0 +1,2 @@
export { OnboardingWizard } from "./onboarding-wizard";
export type { OnboardingWizardProps } from "./onboarding-wizard";

View File

@@ -0,0 +1,105 @@
"use client";
import { useState, useCallback } from "react";
import { useWorkspaceStore } from "@multica/core/workspace";
import type { Agent } from "@multica/core/types";
import { StepWorkspace } from "./step-workspace";
import { StepRuntime } from "./step-runtime";
import { StepAgent } from "./step-agent";
import { StepComplete } from "./step-complete";
const STEPS = [
{ label: "Workspace" },
{ label: "Runtime" },
{ label: "Agent" },
{ label: "Get Started" },
] as const;
export interface OnboardingWizardProps {
onComplete: () => void;
}
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
const [step, setStep] = useState(0);
const [createdAgent, setCreatedAgent] = useState<Agent | null>(null);
const wsId = useWorkspaceStore((s) => s.workspace?.id) ?? null;
const next = useCallback(
() => setStep((s) => Math.min(s + 1, STEPS.length - 1)),
[],
);
return (
<div className="flex min-h-svh flex-col bg-background">
{/* Progress bar */}
<div className="flex items-center justify-center gap-2 px-6 pt-8">
{STEPS.map((s, i) => (
<div key={s.label} className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium transition-colors ${
i <= step
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{i < step ? (
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
i + 1
)}
</div>
<span
className={`text-sm ${
i <= step
? "text-foreground font-medium"
: "text-muted-foreground"
}`}
>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div
className={`h-px w-8 ${i < step ? "bg-primary" : "bg-border"}`}
/>
)}
</div>
))}
</div>
{/* Step content */}
<div className="flex flex-1 items-center justify-center px-6 py-12">
{step === 0 && <StepWorkspace onNext={next} />}
{step === 1 && wsId && (
<StepRuntime wsId={wsId} onNext={next} />
)}
{step === 2 && wsId && (
<StepAgent
wsId={wsId}
onNext={next}
onAgentCreated={setCreatedAgent}
/>
)}
{step === 3 && wsId && (
<StepComplete
wsId={wsId}
agent={createdAgent}
onEnter={onComplete}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ChevronDown,
Globe,
Lock,
AlertCircle,
Crown,
Code,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { Card } from "@multica/ui/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { api } from "@multica/core/api";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { ProviderLogo } from "../runtimes/components/provider-logo";
import type {
Agent,
AgentVisibility,
CreateAgentRequest,
} from "@multica/core/types";
interface AgentTemplate {
id: string;
name: string;
description: string;
instructions: string;
icon: typeof Crown;
}
const AGENT_TEMPLATES: AgentTemplate[] = [
{
id: "master",
name: "Master Agent",
description: "Manages workspace, assigns tasks, and coordinates work",
instructions:
"You are a Master Agent for this workspace. Your role is to manage and coordinate tasks, triage incoming issues, and ensure work is distributed effectively across the team.",
icon: Crown,
},
{
id: "coding",
name: "Coding Agent",
description: "Checks out code, implements features, and submits PRs",
instructions:
"You are a Coding Agent. Your role is to check out code repositories, implement features and bug fixes based on issue descriptions, write tests, and submit pull requests.",
icon: Code,
},
];
export function StepAgent({
wsId,
onNext,
onAgentCreated,
}: {
wsId: string;
onNext: () => void;
onAgentCreated: (agent: Agent) => void;
}) {
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const hasRuntime = runtimes.length > 0;
// Template selection
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
null,
);
// Form state — populated from template, editable
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedRuntimeId, setSelectedRuntimeId] = useState("");
const [visibility, setVisibility] = useState<AgentVisibility>("workspace");
const [creating, setCreating] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [showForm, setShowForm] = useState(false);
// Auto-select first runtime
useEffect(() => {
if (!selectedRuntimeId && runtimes[0]) {
setSelectedRuntimeId(runtimes[0].id);
}
}, [runtimes, selectedRuntimeId]);
const selectedRuntime =
runtimes.find((r) => r.id === selectedRuntimeId) ?? null;
const handleSelectTemplate = (template: AgentTemplate) => {
setSelectedTemplateId(template.id);
setName(template.name);
setDescription(template.description);
setShowForm(true);
};
const handleCreate = async () => {
if (!name.trim() || !selectedRuntime) return;
const template = AGENT_TEMPLATES.find((t) => t.id === selectedTemplateId);
setCreating(true);
try {
const req: CreateAgentRequest = {
name: name.trim(),
description: description.trim() || undefined,
instructions: template?.instructions,
runtime_id: selectedRuntime.id,
visibility,
};
const agent = await api.createAgent(req);
onAgentCreated(agent);
onNext();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to create agent",
);
setCreating(false);
}
};
return (
<div className="flex w-full max-w-lg flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Create Your First Agent
</h1>
<p className="mt-2 text-muted-foreground">
Choose a template to get started, then customize your agent.
</p>
</div>
{/* No runtime warning */}
{!hasRuntime && (
<div className="flex w-full items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
No runtime connected. Go back to connect a runtime, or skip and set
one up later.
</p>
</div>
)}
{/* Template cards */}
{!showForm && (
<div className="grid w-full grid-cols-2 gap-4">
{AGENT_TEMPLATES.map((template) => {
const Icon = template.icon;
return (
<Card
key={template.id}
role="button"
tabIndex={0}
onClick={() => handleSelectTemplate(template)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelectTemplate(template);
}
}}
className="cursor-pointer p-5 transition-all hover:border-foreground/20"
>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<h3 className="font-semibold">{template.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{template.description}
</p>
</Card>
);
})}
</div>
)}
{/* Agent configuration form */}
{showForm && (
<Card className="w-full p-5 space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Agent Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Coding Agent"
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?"
/>
</div>
{/* Runtime selector */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Runtime</Label>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
<PopoverTrigger
disabled={!hasRuntime}
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{selectedRuntime ? (
<ProviderLogo
provider={selectedRuntime.provider}
className="h-4 w-4 shrink-0"
/>
) : (
<div className="h-4 w-4 shrink-0 rounded-full bg-muted-foreground/30" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{selectedRuntime?.name ?? "No runtime available"}
</span>
{selectedRuntime?.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{selectedRuntime
? `${selectedRuntime.provider} · ${selectedRuntime.device_info}`
: "Connect a runtime first"}
</div>
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--anchor-width)] max-h-60 overflow-y-auto p-1"
>
{runtimes.map((rt) => (
<button
key={rt.id}
onClick={() => {
setSelectedRuntimeId(rt.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
rt.id === selectedRuntimeId
? "bg-accent"
: "hover:bg-accent/50"
}`}
>
<ProviderLogo
provider={rt.provider}
className="h-4 w-4 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{rt.name}</span>
{rt.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{rt.provider} · {rt.device_info}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
rt.status === "online"
? "bg-success"
: "bg-muted-foreground/40"
}`}
/>
</button>
))}
</PopoverContent>
</Popover>
</div>
{/* Visibility */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Visibility</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">
All members can assign
</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">
Only you can assign
</div>
</div>
</button>
</div>
</div>
</Card>
)}
{/* Actions */}
<div className="flex w-full flex-col items-center gap-3">
{showForm ? (
<>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={creating || !name.trim() || !selectedRuntime}
>
{creating ? "Creating..." : "Create Agent"}
</Button>
<button
type="button"
onClick={() => {
setShowForm(false);
setSelectedTemplateId(null);
}}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
Back to templates
</button>
</>
) : (
<button
type="button"
onClick={onNext}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
Skip for now
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ArrowRight, Loader2, Bot } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Card } from "@multica/ui/components/ui/card";
import { api } from "@multica/core/api";
import type { Agent, Issue, CreateIssueRequest } from "@multica/core/types";
interface OnboardingIssueDef {
title: string;
description: string;
/** If true, assigned to the agent with status "todo" so it gets picked up */
assignToAgent: boolean;
status: "todo" | "backlog";
}
function getOnboardingIssues(): OnboardingIssueDef[] {
return [
{
title: "Say hello to the team!",
description: [
"Welcome! This is your first automated task.",
"",
"Please introduce yourself to the team:",
"- What's your name and role in this workspace?",
"- What kinds of tasks can you help with?",
"- Give 23 concrete examples of things the team can ask you to do",
"",
"---",
"",
"**Try it out!** After the agent responds, reply with one of these to see it in action:",
'- "Review this function for bugs: `function add(a, b) { return a - b; }`"',
'- "Draft a short description for a new onboarding feature"',
'- "What are some best practices for writing clean commit messages?"',
"",
"This issue was automatically assigned to verify your agent is working end-to-end.",
].join("\n"),
assignToAgent: true,
status: "todo",
},
{
title: "Set up your repository connection",
description: [
"Connect a code repository so agents can check out code and submit pull requests.",
"",
"**Steps:**",
"1. Go to **Settings** in the sidebar",
"2. Under **Repositories**, add a GitHub repo URL",
"3. The agent daemon will sync the repo locally",
"",
"Once connected, your agents can clone, branch, and push code as part of any task.",
].join("\n"),
assignToAgent: false,
status: "backlog",
},
{
title: "Create a skill for your agent",
description: [
"Skills are reusable instructions that make agents better at recurring tasks — deployments, code reviews, migrations, etc.",
"",
"**Steps:**",
"1. Go to **Skills** in the sidebar",
"2. Click **New Skill**",
"3. Write a description and instructions (e.g., \"Code Review\" with your team's style guide)",
"4. Assign the skill to an agent in the agent's settings",
"",
"Every skill you create compounds your team's capabilities over time.",
].join("\n"),
assignToAgent: false,
status: "backlog",
},
{
title: "Invite a teammate",
description: [
"Multica works best with a team. Invite a colleague to your workspace so you can collaborate on issues and share agents.",
"",
"**Steps:**",
"1. Go to **Settings → Members**",
"2. Click **Invite** and enter their email",
"3. They'll get access to the workspace, all agents, and the issue board",
].join("\n"),
assignToAgent: false,
status: "backlog",
},
];
}
export function StepComplete({
wsId,
agent,
onEnter,
}: {
wsId: string;
agent: Agent | null;
onEnter: () => void;
}) {
const [createdIssues, setCreatedIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
const didCreate = useRef(false);
useEffect(() => {
if (didCreate.current) return;
didCreate.current = true;
async function createOnboardingIssues() {
const defs = getOnboardingIssues();
const issues: Issue[] = [];
for (const def of defs) {
try {
const req: CreateIssueRequest = {
title: def.title,
description: def.description,
status: def.status,
};
if (def.assignToAgent && agent) {
req.assignee_type = "agent";
req.assignee_id = agent.id;
}
const issue = await api.createIssue(req);
issues.push(issue);
} catch {
// Best-effort — continue with remaining issues
}
}
setCreatedIssues(issues);
setLoading(false);
}
createOnboardingIssues();
}, [agent, wsId]);
return (
<div className="flex w-full max-w-md flex-col items-center gap-8">
{/* Success icon */}
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-success/10">
<Check className="h-8 w-8 text-success" />
</div>
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
You're all set!
</h1>
<p className="mt-2 text-muted-foreground">
{agent
? `Your workspace is ready and ${agent.name} is picking up its first task.`
: "Your workspace is ready. Create issues and assign them to agents to get started."}
</p>
</div>
{/* Created issues */}
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Setting up your workspace...</span>
</div>
) : (
createdIssues.length > 0 && (
<Card className="w-full divide-y">
{createdIssues.map((issue) => (
<div
key={issue.id}
className="flex items-center gap-3 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{issue.identifier} {issue.title}
</div>
<div className="truncate text-xs text-muted-foreground">
{issue.assignee_id && agent
? `Assigned to ${agent.name}`
: issue.status === "todo"
? "To do"
: "Backlog"}
</div>
</div>
{issue.assignee_id && agent && (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-900/30">
<Bot className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400" />
</div>
)}
</div>
))}
</Card>
)
)}
<Button
className="w-full"
size="lg"
onClick={onEnter}
disabled={loading}
>
Go to Workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useState, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Terminal, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useWSEvent } from "@multica/core/realtime";
import { ProviderLogo } from "../runtimes/components/provider-logo";
import {
runtimeListOptions,
runtimeKeys,
} from "@multica/core/runtimes/queries";
const SETUP_STEPS = [
{
label: "Install the Multica CLI",
cmd: "curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash",
},
{
label: "Set up and start the daemon",
cmd: "multica setup",
},
];
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={handleCopy}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-success" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
);
}
export function StepRuntime({
wsId,
onNext,
}: {
wsId: string;
onNext: () => void;
}) {
const qc = useQueryClient();
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const handleDaemonEvent = useCallback(() => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
}, [qc, wsId]);
useWSEvent("daemon:register", handleDaemonEvent);
const hasRuntimes = runtimes.length > 0;
return (
<div className="flex w-full max-w-xl flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Connect a Runtime
</h1>
<p className="mt-2 text-muted-foreground">
Install the CLI and run{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">
multica setup
</code>{" "}
to connect your machine. The daemon auto-detects agent CLIs (Claude
Code, Codex, etc.) on your PATH.
</p>
</div>
{/* Commands */}
<Card className="w-full">
<CardContent className="space-y-3 pt-4">
{SETUP_STEPS.map((step, i) => (
<div key={i}>
<p className="mb-1.5 text-xs text-muted-foreground">
{i + 1}. {step.label}
</p>
<div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2.5 font-mono text-sm">
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 break-all whitespace-pre-wrap">
{step.cmd}
</code>
<CopyButton text={step.cmd} />
</div>
</div>
))}
<p className="pt-1 text-xs text-muted-foreground">
<code className="rounded bg-background px-1 py-0.5 font-mono">
multica setup
</code>{" "}
handles authentication, configuration, and daemon startup. It
auto-detects local servers on your network.
</p>
</CardContent>
</Card>
{/* Connected runtimes */}
<div className="w-full space-y-3">
<div className="flex items-center gap-2 text-sm">
{hasRuntimes ? (
<>
<div className="h-2 w-2 rounded-full bg-success" />
<span className="font-medium">
{runtimes.length} runtime{runtimes.length > 1 ? "s" : ""}{" "}
connected
</span>
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">
Waiting for connection...
</span>
</>
)}
</div>
{hasRuntimes && (
<Card className="w-full">
<CardContent className="divide-y pt-0">
{runtimes.map((rt) => (
<div
key={rt.id}
className="flex items-center gap-3 py-3 first:pt-4 last:pb-4"
>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
rt.status === "online"
? "bg-success"
: "bg-muted-foreground/40"
}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{rt.name}
</span>
{rt.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{rt.provider} · {rt.device_info}
</div>
</div>
<ProviderLogo
provider={rt.provider}
className="h-5 w-5 shrink-0"
/>
</div>
))}
</CardContent>
</Card>
)}
</div>
{/* Actions */}
<Button className="w-full" size="lg" onClick={onNext}>
{hasRuntimes ? "Continue" : "Skip for now"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
function nameToSlug(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "workspace"
);
}
export function StepWorkspace({ onNext }: { onNext: () => void }) {
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
const canSubmit = name.trim().length > 0;
const handleCreate = () => {
if (!canSubmit) return;
createWorkspace.mutate(
{ name: name.trim(), slug: nameToSlug(name.trim()) },
{
onSuccess: () => onNext(),
onError: () => toast.error("Failed to create workspace"),
},
);
};
return (
<div className="flex w-full max-w-md flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Welcome to Multica
</h1>
<p className="mt-2 text-muted-foreground">
Create your workspace to start building with AI agents.
</p>
</div>
<Card className="w-full">
<CardContent className="space-y-4 pt-6">
<div className="space-y-1.5">
<Label>Workspace Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Team"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
</CardContent>
</Card>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={createWorkspace.isPending || !canSubmit}
>
{createWorkspace.isPending ? "Creating..." : "Create Workspace"}
</Button>
</div>
);
}

View File

@@ -21,7 +21,6 @@
"./projects/components": "./projects/components/index.ts",
"./modals/registry": "./modals/registry.tsx",
"./modals/create-issue": "./modals/create-issue.tsx",
"./modals/create-workspace": "./modals/create-workspace.tsx",
"./my-issues": "./my-issues/index.ts",
"./skills": "./skills/index.ts",
"./agents": "./agents/index.ts",
@@ -32,7 +31,8 @@
"./auth": "./auth/index.ts",
"./search": "./search/index.ts",
"./chat": "./chat/index.ts",
"./settings": "./settings/index.ts"
"./settings": "./settings/index.ts",
"./onboarding": "./onboarding/index.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",

View File

@@ -306,28 +306,12 @@ func TestSendCodeAndVerify(t *testing.T) {
meResp.Body.Close()
}
func TestVerifyCodeCreatesWorkspaceForNewUser(t *testing.T) {
func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
const email = "new-integration-verify@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
var userID string
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
if err == nil {
rows, queryErr := testPool.Query(ctx, `
SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1
`, userID)
if queryErr == nil {
defer rows.Close()
for rows.Next() {
var wsID string
if rows.Scan(&wsID) == nil {
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID)
}
}
}
}
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
@@ -363,7 +347,7 @@ func TestVerifyCodeCreatesWorkspaceForNewUser(t *testing.T) {
}
readJSON(t, resp, &loginResp)
// Check workspace was created
// New users should have no workspaces (onboarding creates one)
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
workspacesResp, err := http.DefaultClient.Do(req)
@@ -382,11 +366,8 @@ func TestVerifyCodeCreatesWorkspaceForNewUser(t *testing.T) {
}
readJSON(t, workspacesResp, &workspaces)
if len(workspaces) != 1 {
t.Fatalf("expected 1 workspace, got %d", len(workspaces))
}
if !strings.Contains(workspaces[0].Name, "Workspace") {
t.Fatalf("expected workspace name containing 'Workspace', got %q", workspaces[0].Name)
if len(workspaces) != 0 {
t.Fatalf("expected 0 workspaces for new user, got %d", len(workspaces))
}
}

View File

@@ -56,113 +56,6 @@ type VerifyCodeRequest struct {
Code string `json:"code"`
}
func defaultWorkspaceName(user db.User) string {
name := strings.TrimSpace(user.Name)
if name == "" {
email := strings.TrimSpace(user.Email)
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
}
}
if name == "" {
name = "Personal"
}
return name + "'s Workspace"
}
func slugifyWorkspacePart(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastWasDash := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastWasDash = false
case b.Len() > 0 && !lastWasDash:
b.WriteByte('-')
lastWasDash = true
}
}
return strings.Trim(b.String(), "-")
}
func defaultWorkspaceSlug(user db.User) string {
candidates := []string{
slugifyWorkspacePart(user.Name),
slugifyWorkspacePart(strings.Split(strings.TrimSpace(user.Email), "@")[0]),
"workspace",
}
base := "workspace"
for _, candidate := range candidates {
if candidate != "" {
base = candidate
break
}
}
userID := uuidToString(user.ID)
if len(userID) >= 8 {
return base + "-" + userID[:8]
}
return base
}
func (h *Handler) ensureUserWorkspace(ctx context.Context, user db.User) error {
workspaces, err := h.Queries.ListWorkspaces(ctx, user.ID)
if err != nil {
return err
}
if len(workspaces) > 0 {
return nil
}
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
workspaces, err = qtx.ListWorkspaces(ctx, user.ID)
if err != nil {
return err
}
if len(workspaces) > 0 {
return nil
}
wsName := defaultWorkspaceName(user)
workspace, err := qtx.CreateWorkspace(ctx, db.CreateWorkspaceParams{
Name: wsName,
Slug: defaultWorkspaceSlug(user),
Description: pgtype.Text{},
IssuePrefix: generateIssuePrefix(wsName),
})
if err != nil {
if isUniqueViolation(err) {
workspaces, lookupErr := h.Queries.ListWorkspaces(ctx, user.ID)
if lookupErr == nil && len(workspaces) > 0 {
return nil
}
}
return err
}
if _, err := qtx.CreateMember(ctx, db.CreateMemberParams{
WorkspaceID: workspace.ID,
UserID: user.ID,
Role: "owner",
}); err != nil {
return err
}
return tx.Commit(ctx)
}
func generateCode() (string, error) {
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
@@ -291,11 +184,6 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
return
}
tokenString, err := h.issueJWT(user)
if err != nil {
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
@@ -478,11 +366,6 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
}
}
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
return
}
tokenString, err := h.issueJWT(user)
if err != nil {
slog.Warn("google login failed", append(logger.RequestAttrs(r), "error", err, "email", email)...)

View File

@@ -599,21 +599,12 @@ func TestVerifyCodeBruteForceProtection(t *testing.T) {
}
}
func TestVerifyCodeCreatesWorkspace(t *testing.T) {
func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
const email = "workspace-verify-test@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err == nil {
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if listErr == nil {
for _, workspace := range workspaces {
_ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID)
}
}
}
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
@@ -647,15 +638,13 @@ func TestVerifyCodeCreatesWorkspace(t *testing.T) {
t.Fatalf("GetUserByEmail: %v", err)
}
// New users should have no workspaces (onboarding creates one)
workspaces, err := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 1 {
t.Fatalf("ListWorkspaces: expected 1 workspace, got %d", len(workspaces))
}
if !strings.Contains(workspaces[0].Name, "Workspace") {
t.Fatalf("expected auto-created workspace name, got %q", workspaces[0].Name)
if len(workspaces) != 0 {
t.Fatalf("ListWorkspaces: expected 0 workspaces for new user, got %d", len(workspaces))
}
}