diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 36925540c..ea0c54c35 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -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 ( router.push(nextUrl)} + onSuccess={handleSuccess} google={ googleClientId ? { diff --git a/apps/web/app/(auth)/onboarding/page.tsx b/apps/web/app/(auth)/onboarding/page.tsx new file mode 100644 index 000000000..6afcfecc1 --- /dev/null +++ b/apps/web/app/(auth)/onboarding/page.tsx @@ -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 ( + router.push("/issues")} /> + ); +} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index a2ecebf9e..5c6fecdc0 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -11,6 +11,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { loadingIndicator={} searchSlot={} extra={<>} + onboardingPath="/onboarding" > {children} diff --git a/apps/web/app/auth/callback/page.tsx b/apps/web/app/auth/callback/page.tsx index 5dfd168ce..41993d083 100644 --- a/apps/web/app/auth/callback/page.tsx +++ b/apps/web/app/auth/callback/page.tsx @@ -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"); diff --git a/packages/core/modals/store.ts b/packages/core/modals/store.ts index f720be433..c434a0963 100644 --- a/packages/core/modals/store.ts +++ b/packages/core/modals/store.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; -type ModalType = "create-workspace" | "create-issue" | null; +type ModalType = "create-issue" | null; interface ModalStore { modal: ModalType; diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 4e6fd53cf..e797cf45e 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -279,7 +279,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } ))} useModalStore.getState().open("create-workspace")} + onClick={() => push("/onboarding")} > Create workspace diff --git a/packages/views/layout/dashboard-guard.tsx b/packages/views/layout/dashboard-guard.tsx index 25f5aa1e5..5d71ebecd 100644 --- a/packages/views/layout/dashboard-guard.tsx +++ b/packages/views/layout/dashboard-guard.tsx @@ -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; diff --git a/packages/views/layout/dashboard-layout.tsx b/packages/views/layout/dashboard-layout.tsx index 3d4604b63..a18e6875f 100644 --- a/packages/views/layout/dashboard-layout.tsx +++ b/packages/views/layout/dashboard-layout.tsx @@ -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 ( {loadingIndicator} diff --git a/packages/views/layout/use-dashboard-guard.ts b/packages/views/layout/use-dashboard-guard.ts index 79c8fe7e9..97d39fa04 100644 --- a/packages/views/layout/use-dashboard-guard.ts +++ b/packages/views/layout/use-dashboard-guard.ts @@ -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); diff --git a/packages/views/modals/create-workspace.tsx b/packages/views/modals/create-workspace.tsx deleted file mode 100644 index d41a7564b..000000000 --- a/packages/views/modals/create-workspace.tsx +++ /dev/null @@ -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 ( - { if (!v) onClose(); }}> - - - -
-
- - Create a new workspace - - - Workspaces are shared environments where teams can work on - projects and issues. - -
- - - -
- - handleNameChange(e.target.value)} - placeholder="My Workspace" - /> -
-
- -
- - multica.app/ - - setSlug(e.target.value)} - placeholder="my-workspace" - className="border-0 shadow-none focus-visible:ring-0" - /> -
- {slugError && ( -

{slugError}

- )} -
-
-
- - -
-
-
- ); -} diff --git a/packages/views/modals/registry.tsx b/packages/views/modals/registry.tsx index d97709bbc..62b5f2a8a 100644 --- a/packages/views/modals/registry.tsx +++ b/packages/views/modals/registry.tsx @@ -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 ; case "create-issue": return ; default: diff --git a/packages/views/onboarding/index.ts b/packages/views/onboarding/index.ts new file mode 100644 index 000000000..01e25ef28 --- /dev/null +++ b/packages/views/onboarding/index.ts @@ -0,0 +1,2 @@ +export { OnboardingWizard } from "./onboarding-wizard"; +export type { OnboardingWizardProps } from "./onboarding-wizard"; diff --git a/packages/views/onboarding/onboarding-wizard.tsx b/packages/views/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..2cec495b8 --- /dev/null +++ b/packages/views/onboarding/onboarding-wizard.tsx @@ -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(null); + + const wsId = useWorkspaceStore((s) => s.workspace?.id) ?? null; + + const next = useCallback( + () => setStep((s) => Math.min(s + 1, STEPS.length - 1)), + [], + ); + + return ( +
+ {/* Progress bar */} +
+ {STEPS.map((s, i) => ( +
+
+
+ {i < step ? ( + + + + ) : ( + i + 1 + )} +
+ + {s.label} + +
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* Step content */} +
+ {step === 0 && } + {step === 1 && wsId && ( + + )} + {step === 2 && wsId && ( + + )} + {step === 3 && wsId && ( + + )} +
+
+ ); +} diff --git a/packages/views/onboarding/step-agent.tsx b/packages/views/onboarding/step-agent.tsx new file mode 100644 index 000000000..3b79f278c --- /dev/null +++ b/packages/views/onboarding/step-agent.tsx @@ -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( + null, + ); + + // Form state — populated from template, editable + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedRuntimeId, setSelectedRuntimeId] = useState(""); + const [visibility, setVisibility] = useState("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 ( +
+
+

+ Create Your First Agent +

+

+ Choose a template to get started, then customize your agent. +

+
+ + {/* No runtime warning */} + {!hasRuntime && ( +
+ +

+ No runtime connected. Go back to connect a runtime, or skip and set + one up later. +

+
+ )} + + {/* Template cards */} + {!showForm && ( +
+ {AGENT_TEMPLATES.map((template) => { + const Icon = template.icon; + return ( + 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" + > +
+ +
+

{template.name}

+

+ {template.description} +

+
+ ); + })} +
+ )} + + {/* Agent configuration form */} + {showForm && ( + + {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g. Coding Agent" + /> +
+ + {/* Description */} +
+ + setDescription(e.target.value)} + placeholder="What does this agent do?" + /> +
+ + {/* Runtime selector */} +
+ + + + {selectedRuntime ? ( + + ) : ( +
+ )} +
+
+ + {selectedRuntime?.name ?? "No runtime available"} + + {selectedRuntime?.runtime_mode === "cloud" && ( + + Cloud + + )} +
+
+ {selectedRuntime + ? `${selectedRuntime.provider} · ${selectedRuntime.device_info}` + : "Connect a runtime first"} +
+
+ + + + {runtimes.map((rt) => ( + + ))} + + +
+ + {/* Visibility */} +
+ +
+ + +
+
+ + )} + + {/* Actions */} +
+ {showForm ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} diff --git a/packages/views/onboarding/step-complete.tsx b/packages/views/onboarding/step-complete.tsx new file mode 100644 index 000000000..927d81c02 --- /dev/null +++ b/packages/views/onboarding/step-complete.tsx @@ -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([]); + 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 ( +
+ {/* Success icon */} +
+ +
+ +
+

+ You're all set! +

+

+ {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."} +

+
+ + {/* Created issues */} + {loading ? ( +
+ + Setting up your workspace... +
+ ) : ( + createdIssues.length > 0 && ( + + {createdIssues.map((issue) => ( +
+
+
+ {issue.identifier} {issue.title} +
+
+ {issue.assignee_id && agent + ? `Assigned to ${agent.name}` + : issue.status === "todo" + ? "To do" + : "Backlog"} +
+
+ {issue.assignee_id && agent && ( +
+ +
+ )} +
+ ))} +
+ ) + )} + + +
+ ); +} diff --git a/packages/views/onboarding/step-runtime.tsx b/packages/views/onboarding/step-runtime.tsx new file mode 100644 index 000000000..fed88486a --- /dev/null +++ b/packages/views/onboarding/step-runtime.tsx @@ -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 ( + + ); +} + +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 ( +
+
+

+ Connect a Runtime +

+

+ Install the CLI and run{" "} + + multica setup + {" "} + to connect your machine. The daemon auto-detects agent CLIs (Claude + Code, Codex, etc.) on your PATH. +

+
+ + {/* Commands */} + + + {SETUP_STEPS.map((step, i) => ( +
+

+ {i + 1}. {step.label} +

+
+ + + {step.cmd} + + +
+
+ ))} +

+ + multica setup + {" "} + handles authentication, configuration, and daemon startup. It + auto-detects local servers on your network. +

+
+
+ + {/* Connected runtimes */} +
+
+ {hasRuntimes ? ( + <> +
+ + {runtimes.length} runtime{runtimes.length > 1 ? "s" : ""}{" "} + connected + + + ) : ( + <> + + + Waiting for connection... + + + )} +
+ + {hasRuntimes && ( + + + {runtimes.map((rt) => ( +
+ +
+
+ + {rt.name} + + {rt.runtime_mode === "cloud" && ( + + Cloud + + )} +
+
+ {rt.provider} · {rt.device_info} +
+
+ +
+ ))} +
+
+ )} +
+ + {/* Actions */} + +
+ ); +} diff --git a/packages/views/onboarding/step-workspace.tsx b/packages/views/onboarding/step-workspace.tsx new file mode 100644 index 000000000..13038b87a --- /dev/null +++ b/packages/views/onboarding/step-workspace.tsx @@ -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 ( +
+
+

+ Welcome to Multica +

+

+ Create your workspace to start building with AI agents. +

+
+ + + +
+ + setName(e.target.value)} + placeholder="My Team" + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + /> +
+
+
+ + +
+ ); +} diff --git a/packages/views/package.json b/packages/views/package.json index f7ffc9bd1..b35b25c26 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -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", diff --git a/server/cmd/server/integration_test.go b/server/cmd/server/integration_test.go index 5686f41dc..042393a13 100644 --- a/server/cmd/server/integration_test.go +++ b/server/cmd/server/integration_test.go @@ -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)) } } diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 46b33bd92..b0405f99a 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -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)...) diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index bf8012bba..ae929c979 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -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)) } }