mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
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:
@@ -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
|
||||
? {
|
||||
|
||||
23
apps/web/app/(auth)/onboarding/page.tsx
Normal file
23
apps/web/app/(auth)/onboarding/page.tsx
Normal 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")} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
type ModalType = "create-workspace" | "create-issue" | null;
|
||||
type ModalType = "create-issue" | null;
|
||||
|
||||
interface ModalStore {
|
||||
modal: ModalType;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
2
packages/views/onboarding/index.ts
Normal file
2
packages/views/onboarding/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { OnboardingWizard } from "./onboarding-wizard";
|
||||
export type { OnboardingWizardProps } from "./onboarding-wizard";
|
||||
105
packages/views/onboarding/onboarding-wizard.tsx
Normal file
105
packages/views/onboarding/onboarding-wizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
packages/views/onboarding/step-agent.tsx
Normal file
367
packages/views/onboarding/step-agent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
packages/views/onboarding/step-complete.tsx
Normal file
201
packages/views/onboarding/step-complete.tsx
Normal 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 2–3 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>
|
||||
);
|
||||
}
|
||||
180
packages/views/onboarding/step-runtime.tsx
Normal file
180
packages/views/onboarding/step-runtime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
packages/views/onboarding/step-workspace.tsx
Normal file
74
packages/views/onboarding/step-workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)...)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user