From a953c5fda6e8423e8e5473ecdffe5664ced15d62 Mon Sep 17 00:00:00 2001 From: Luke The Dev <252071647+iamlukethedev@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:59:44 -0500 Subject: [PATCH] feat: add company builder wizard with AI-powered org generation (#73) * feat: add company builder wizard with AI-powered org generation Adds a new "Build Your Company" step to the onboarding wizard that lets users describe their business and generates a full agent org structure using OpenClaw's AI. Includes company plan generation, role deduplication, agent bootstrap with main-agent reuse, org chart preview, confetti on success, CSS voxel running-avatar loader, amber theme unification, and best-effort SSH workspace cleanup. Made-with: Cursor * fix: resolve lint errors in CompanyBuilderModal Replace setState-in-effect pattern with a direct callback, escape apostrophes in JSX text, and derive org chart hover state without side effects. Made-with: Cursor --------- Co-authored-by: iamlukethedev --- package-lock.json | 19 + package.json | 2 + src/app/office/page.tsx | 11 +- .../components/AgentAvatarPreview3D.tsx | 6 +- .../components/GatewayConnectScreen.tsx | 7 +- .../agents/components/RunningAvatarLoader.tsx | 223 ++++ .../agents/operations/deleteAgentOperation.ts | 20 + .../components/CompanyBuilderModal.tsx | 958 ++++++++++++++++++ .../operations/companyBootstrapOperation.ts | 122 +++ .../operations/companyBuilderGateway.ts | 91 ++ src/features/company-builder/planning.ts | 464 +++++++++ src/features/company-builder/types.ts | 49 + src/features/office/components/HQSidebar.tsx | 11 + .../office/screens/AtmImmersiveScreen.tsx | 7 +- .../office/screens/GithubImmersiveScreen.tsx | 13 +- src/features/office/screens/OfficeScreen.tsx | 434 +++++++- .../onboarding/components/AgentsStep.tsx | 4 +- .../onboarding/components/CompanyStep.tsx | 83 ++ .../onboarding/components/CompleteStep.tsx | 109 +- .../onboarding/components/ConnectStep.tsx | 15 +- .../components/OnboardingWizard.tsx | 41 +- .../components/PrerequisitesStep.tsx | 24 +- .../onboarding/components/WelcomeStep.tsx | 2 +- src/features/onboarding/types.ts | 7 + src/features/retro-office/RetroOffice3D.tsx | 17 + src/features/retro-office/objects/agents.tsx | 106 +- .../components/JukeboxPanel.tsx | 5 +- src/lib/studio/settings.ts | 68 ++ tests/unit/companyBootstrapOperation.test.ts | 310 ++++++ tests/unit/companyBuilderPlanning.test.ts | 197 ++++ tests/unit/onboardingTypes.test.ts | 7 + 31 files changed, 3308 insertions(+), 124 deletions(-) create mode 100644 src/features/agents/components/RunningAvatarLoader.tsx create mode 100644 src/features/company-builder/components/CompanyBuilderModal.tsx create mode 100644 src/features/company-builder/operations/companyBootstrapOperation.ts create mode 100644 src/features/company-builder/operations/companyBuilderGateway.ts create mode 100644 src/features/company-builder/planning.ts create mode 100644 src/features/company-builder/types.ts create mode 100644 src/features/onboarding/components/CompanyStep.tsx create mode 100644 tests/unit/companyBootstrapOperation.test.ts create mode 100644 tests/unit/companyBuilderPlanning.test.ts diff --git a/package-lock.json b/package-lock.json index a8a650d..cb445c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@vercel/otel": "^2.1.0", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -32,6 +33,7 @@ "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -3110,6 +3112,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4561,6 +4570,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", diff --git a/package.json b/package.json index 33c84f1..d45d8dd 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@vercel/otel": "^2.1.0", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -41,6 +42,7 @@ "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/office/page.tsx b/src/app/office/page.tsx index bcb3e78..16dbf26 100644 --- a/src/app/office/page.tsx +++ b/src/app/office/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader"; import { AgentStoreProvider } from "@/features/agents/state/store"; import { OfficeScreen } from "@/features/office/screens/OfficeScreen"; @@ -18,10 +19,12 @@ function OfficeLoadingFallback() { role="status" >
-
-

- Loading… -

+
); diff --git a/src/features/agents/components/AgentAvatarPreview3D.tsx b/src/features/agents/components/AgentAvatarPreview3D.tsx index d575302..3b21c17 100644 --- a/src/features/agents/components/AgentAvatarPreview3D.tsx +++ b/src/features/agents/components/AgentAvatarPreview3D.tsx @@ -8,6 +8,7 @@ import { type AgentAvatarProfile, createDefaultAgentAvatarProfile, } from "@/lib/avatars/profile"; +import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader"; const PreviewFigure = ({ profile, @@ -313,10 +314,7 @@ export const AgentAvatarPreview3D = ({
{!isReady ? (
-
-
- Loading avatar... -
+
) : null} diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx index 0db6d5e..ce75b0f 100644 --- a/src/features/agents/components/GatewayConnectScreen.tsx +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -1,8 +1,9 @@ import { useMemo, useState } from "react"; -import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react"; +import { Check, Copy, Eye, EyeOff } from "lucide-react"; import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; import type { StudioGatewaySettings } from "@/lib/studio/settings"; +import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader"; type GatewayConnectScreenProps = { gatewayUrl: string; @@ -168,7 +169,7 @@ export const GatewayConnectScreen = ({ {status === "connecting" ? (

- + Connecting…

) : null} @@ -192,7 +193,7 @@ export const GatewayConnectScreen = ({
{status === "connecting" ? ( - + ) : ( ({ + position: "absolute", + width: w * S, + height: h * S, + background: color, + borderRadius: 1, + ...extra, +}); + +export function RunningAvatarLoader({ + label, + className = "", + labelClassName = "", + inline = false, +}: RunningAvatarLoaderProps) { + const charW = 14 * S; + const charH = 22 * S; + const totalH = charH + 8; + + return ( +
+
+ {/* Shadow. */} +
+ + {/* Character root — bounces. */} +
+ {/* Left leg. */} +
+ {/* Shorts. */} +
+ {/* Skin. */} +
+ {/* Shoe. */} +
+
+ + {/* Right leg. */} +
+
+
+
+
+ + {/* Torso (yellow shirt). */} +
+ + {/* Left arm. */} +
+
+
+
+ + {/* Right arm. */} +
+
+
+
+ + {/* Neck. */} +
+ + {/* Head. */} +
+ + {/* Eyes. */} +
+
+ + {/* Mouth. */} +
+ + {/* Hair. */} +
+ + {/* Hat brim. */} +
+ + {/* Hat top. */} +
+
+ + +
+ + {label ? ( +

+ {label} +

+ ) : null} +
+ ); +} diff --git a/src/features/agents/operations/deleteAgentOperation.ts b/src/features/agents/operations/deleteAgentOperation.ts index 240debc..3059852 100644 --- a/src/features/agents/operations/deleteAgentOperation.ts +++ b/src/features/agents/operations/deleteAgentOperation.ts @@ -155,3 +155,23 @@ export const deleteAgentRecordViaStudio = async (params: { throw err; } }; + +export const trashAgentStateViaStudio = async (params: { + agentId: string; + fetchJson?: FetchJson; +}): Promise => { + const trimmedAgentId = params.agentId.trim(); + if (!trimmedAgentId) { + throw new Error("Agent id is required."); + } + const fetchJson = params.fetchJson ?? defaultFetchJson; + const { result } = await fetchJson<{ result: TrashAgentStateResult }>( + "/api/gateway/agent-state", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: trimmedAgentId }), + } + ); + return result; +}; diff --git a/src/features/company-builder/components/CompanyBuilderModal.tsx b/src/features/company-builder/components/CompanyBuilderModal.tsx new file mode 100644 index 0000000..142b695 --- /dev/null +++ b/src/features/company-builder/components/CompanyBuilderModal.tsx @@ -0,0 +1,958 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { GitBranch, Plus, Sparkles, Trash2, Wand2, X } from "lucide-react"; +import { AgentAvatarPreview3D } from "@/features/agents/components/AgentAvatarPreview3D"; +import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader"; +import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile"; +import type { + CompanyBuilderInput, + CompanyBuilderPlan, + CompanyBuilderRole, +} from "@/features/company-builder/types"; + +type CompanyBuilderModalProps = { + open: boolean; + connected: boolean; + agentCount: number; + plannerAgentName: string | null; + busy?: boolean; + error?: string | null; + statusLine?: string | null; + initialInput?: CompanyBuilderInput; + initialPlan?: CompanyBuilderPlan | null; + onClose: () => void; + onClear: () => void; + onImproveBrief: (brief: string) => Promise; + onGeneratePlan: (brief: string) => Promise; + onCreateCompany: (params: { + input: CompanyBuilderInput; + plan: CompanyBuilderPlan; + }) => Promise; +}; + +const inputClassName = + "w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none placeholder:text-white/30"; +const textareaClassName = + "min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none placeholder:text-white/30"; + +const createEmptyRole = (index: number): CompanyBuilderRole => ({ + id: `custom-role-${index + 1}`, + title: "", + purpose: "", + soul: "", + responsibilities: [], + collaborators: [], + tools: [], + heartbeat: [], + emoji: "🤖", + creature: "specialist", + vibe: "helpful and focused", + userContext: "", + commandMode: "ask", +}); + +const parseCommaList = (value: string) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + +const joinCommaList = (values: string[]) => values.join(", "); + +const buildRoleAvatarProfile = (role: CompanyBuilderRole) => + createDefaultAgentAvatarProfile( + [ + role.id, + role.title, + role.emoji, + role.creature, + role.vibe, + role.commandMode, + ] + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .join(":") || "company-role" + ); + +const renderRoleFacts = (label: string, values: string[]) => { + if (values.length === 0) return null; + return ( +
+

+ {label} +

+
{values.join(", ")}
+
+ ); +}; + +export function CompanyBuilderModal({ + open, + connected, + agentCount, + plannerAgentName, + busy = false, + error = null, + statusLine = null, + initialInput, + initialPlan, + onClose, + onClear, + onImproveBrief, + onGeneratePlan, + onCreateCompany, +}: CompanyBuilderModalProps) { + const [input, setInput] = useState({ + businessDescription: initialInput?.businessDescription ?? "", + improvedBrief: initialInput?.improvedBrief ?? "", + }); + const [plan, setPlan] = useState(initialPlan ?? null); + const [promptModalOpen, setPromptModalOpen] = useState( + () => + !( + (initialInput?.businessDescription ?? "").trim() || + (initialInput?.improvedBrief ?? "").trim() + ) + ); + const [promptDraft, setPromptDraft] = useState(initialInput?.businessDescription ?? ""); + const [replaceConfirmOpen, setReplaceConfirmOpen] = useState(false); + const [orgChartOpen, setOrgChartOpen] = useState(false); + const [hoveredOrgRoleId, setHoveredOrgRoleId] = useState(null); + const roleListContainerRef = useRef(null); + const pendingRoleScrollRef = useRef(false); + + const effectiveBrief = useMemo( + () => input.improvedBrief.trim() || input.businessDescription.trim(), + [input.businessDescription, input.improvedBrief] + ); + const canUseAi = connected && agentCount > 0; + const canGenerate = canUseAi && effectiveBrief.length > 0 && !busy; + const canPreviewChart = Boolean(plan && plan.roles.length > 0); + const canCreate = Boolean(connected && plan && plan.roles.length > 0 && !busy); + const canClear = Boolean( + !busy && + (input.businessDescription.trim() || + input.improvedBrief.trim() || + promptDraft.trim() || + plan?.roles.length) + ); + const replacesExistingAgents = agentCount > 0; + + useEffect(() => { + if (!plan || !pendingRoleScrollRef.current) return; + pendingRoleScrollRef.current = false; + requestAnimationFrame(() => { + roleListContainerRef.current?.scrollTo({ + top: roleListContainerRef.current.scrollHeight, + behavior: "smooth", + }); + }); + }, [plan]); + + const fireAutoGenerate = useCallback( + (brief: string) => { + void onGeneratePlan(brief) + .then((nextPlan) => { + setPlan(nextPlan); + }) + .catch((error) => { + console.error("Failed to auto-generate company plan.", error); + }); + }, + [onGeneratePlan], + ); + + const orgChartDefaultRoleId = orgChartOpen && plan?.roles.length + ? plan.roles[0]?.id ?? null + : null; + const resolvedHoveredOrgRoleId = + hoveredOrgRoleId && plan?.roles.some((role) => role.id === hoveredOrgRoleId) + ? hoveredOrgRoleId + : orgChartDefaultRoleId; + + const triggerCreateCompany = () => { + if (!plan) return; + void onCreateCompany({ input, plan }).catch((error) => { + console.error("Failed to create company.", error); + }); + }; + + if (!open) return null; + + const hoveredOrgRole = + plan?.roles.find((role) => role.id === resolvedHoveredOrgRoleId) ?? plan?.roles[0] ?? null; + + return ( +
+
+
+
+
+ + Company Builder +
+

Design an AI company from one prompt

+

+ Uses your connected OpenClaw runtime + {plannerAgentName ? ` via ${plannerAgentName}.` : "."} +

+
+
+ + + + + +
+
+ +
+
+
+
+
+
+

+ Source prompt +

+ +
+
+ {input.businessDescription.trim() + ? input.businessDescription + : "Describe what the company should do and Claw3D will immediately turn it into an improved brief."} +
+
+
+ +
+
+
+

+ Improved Brief +

+

+ This is the text used for company generation. +

+
+
+