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 <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
Luke The Dev
2026-03-27 12:59:44 -05:00
committed by GitHub
parent 3da1694085
commit a953c5fda6
31 changed files with 3308 additions and 124 deletions
+19
View File
@@ -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",
+2
View File
@@ -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",
+7 -4
View File
@@ -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"
>
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
<p className="font-mono text-[11px] tracking-[0.08em] text-muted-foreground">
Loading
</p>
<RunningAvatarLoader
size={28}
trackWidth={76}
label="Loading..."
labelClassName="text-muted-foreground"
/>
</div>
</div>
);
@@ -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 = ({
<div className={`relative ${className}`}>
{!isReady ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#070b16] text-white/70">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/15 border-t-cyan-300" />
<div className="font-mono text-[11px] tracking-[0.08em] text-white/55">
Loading avatar...
</div>
<RunningAvatarLoader size={26} trackWidth={72} label="Loading avatar..." />
</div>
) : null}
<Canvas key={profileKey} camera={{ position: [0, 0.7, 2.5], fov: 34 }}>
@@ -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" ? (
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<RunningAvatarLoader size={16} trackWidth={32} inline />
Connecting
</p>
) : null}
@@ -192,7 +193,7 @@ export const GatewayConnectScreen = ({
<div className="ui-card px-4 py-2">
<div className="flex items-center gap-2">
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--status-connecting-fg)]" />
<RunningAvatarLoader size={18} trackWidth={36} inline />
) : (
<span
className={`h-2.5 w-2.5 ${statusDotClass}`}
@@ -0,0 +1,223 @@
"use client";
import type { CSSProperties } from "react";
type RunningAvatarLoaderProps = {
size?: number;
trackWidth?: number;
label?: string;
className?: string;
labelClassName?: string;
inline?: boolean;
};
const S = 4;
const box = (
w: number,
h: number,
color: string,
extra?: CSSProperties,
): CSSProperties => ({
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 (
<div
className={`flex ${inline ? "items-center gap-2" : "flex-col items-center gap-3"} ${className}`}
>
<div
className="relative"
style={{ width: charW + 16, height: totalH } as CSSProperties}
>
{/* Shadow. */}
<div
className="ra-shadow absolute"
style={{
left: "50%",
bottom: 0,
width: 10 * S,
height: 2 * S,
marginLeft: -5 * S,
borderRadius: "50%",
background: "rgba(0,0,0,0.2)",
}}
/>
{/* Character root — bounces. */}
<div
className="ra-bounce absolute"
style={{
left: "50%",
bottom: 2 * S,
marginLeft: (-7 * S),
width: charW,
height: charH,
}}
>
{/* Left leg. */}
<div
className="ra-leg-l"
style={{
position: "absolute",
left: 2 * S,
bottom: 0,
width: 3 * S,
transformOrigin: "50% 0",
}}
>
{/* Shorts. */}
<div style={box(3, 3, "#64748b", { top: 0, left: 0 })} />
{/* Skin. */}
<div style={box(2, 2, "#f5c9a5", { top: 3 * S, left: 0.5 * S })} />
{/* Shoe. */}
<div style={box(3, 2, "#f8fafc", { top: 5 * S, left: 0 })} />
</div>
{/* Right leg. */}
<div
className="ra-leg-r"
style={{
position: "absolute",
left: 8 * S,
bottom: 0,
width: 3 * S,
transformOrigin: "50% 0",
}}
>
<div style={box(3, 3, "#64748b", { top: 0, left: 0 })} />
<div style={box(2, 2, "#f5c9a5", { top: 3 * S, left: 0.5 * S })} />
<div style={box(3, 2, "#f8fafc", { top: 5 * S, left: 0 })} />
</div>
{/* Torso (yellow shirt). */}
<div style={box(10, 5, "#eab308", { left: 2 * S, bottom: 7 * S })} />
{/* Left arm. */}
<div
className="ra-arm-l"
style={{
position: "absolute",
left: 0,
bottom: 8 * S,
width: 2 * S,
transformOrigin: "50% 0",
}}
>
<div style={box(2, 4, "#eab308", { top: 0, left: 0 })} />
<div style={box(2, 2, "#f5c9a5", { top: 4 * S, left: 0 })} />
</div>
{/* Right arm. */}
<div
className="ra-arm-r"
style={{
position: "absolute",
left: 12 * S,
bottom: 8 * S,
width: 2 * S,
transformOrigin: "50% 0",
}}
>
<div style={box(2, 4, "#eab308", { top: 0, left: 0 })} />
<div style={box(2, 2, "#f5c9a5", { top: 4 * S, left: 0 })} />
</div>
{/* Neck. */}
<div style={box(3, 1, "#f5c9a5", { left: 5 * S, bottom: 12 * S })} />
{/* Head. */}
<div style={box(7, 5, "#f5c9a5", { left: 3 * S, bottom: 13 * S })} />
{/* Eyes. */}
<div style={box(1, 1, "#1e293b", { left: 4.5 * S, bottom: 15.5 * S })} />
<div style={box(1, 1, "#1e293b", { left: 8 * S, bottom: 15.5 * S })} />
{/* Mouth. */}
<div style={box(2, 0.5, "#ef4444", { left: 5.5 * S, bottom: 14 * S })} />
{/* Hair. */}
<div style={box(7, 1, "#111827", { left: 3 * S, bottom: 18 * S })} />
{/* Hat brim. */}
<div style={box(9, 1.5, "#fcd34d", { left: 2 * S, bottom: 19 * S })} />
{/* Hat top. */}
<div style={box(5, 1.5, "#0f172a", { left: 4 * S, bottom: 20.5 * S })} />
</div>
<style>{`
@keyframes ra-bounce-kf {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-${2 * S}px); }
}
@keyframes ra-shadow-kf {
0%, 100% { transform: scaleX(1); opacity: 0.2; }
50% { transform: scaleX(0.65); opacity: 0.12; }
}
@keyframes ra-leg-l-kf {
0% { transform: rotate(-25deg); }
50% { transform: rotate(25deg); }
100% { transform: rotate(-25deg); }
}
@keyframes ra-leg-r-kf {
0% { transform: rotate(25deg); }
50% { transform: rotate(-25deg); }
100% { transform: rotate(25deg); }
}
@keyframes ra-arm-l-kf {
0% { transform: rotate(30deg); }
50% { transform: rotate(-30deg); }
100% { transform: rotate(30deg); }
}
@keyframes ra-arm-r-kf {
0% { transform: rotate(-30deg); }
50% { transform: rotate(30deg); }
100% { transform: rotate(-30deg); }
}
.ra-bounce {
animation: ra-bounce-kf 0.32s ease-in-out infinite;
}
.ra-shadow {
animation: ra-shadow-kf 0.32s ease-in-out infinite;
}
.ra-leg-l {
animation: ra-leg-l-kf 0.32s ease-in-out infinite;
}
.ra-leg-r {
animation: ra-leg-r-kf 0.32s ease-in-out infinite;
}
.ra-arm-l {
animation: ra-arm-l-kf 0.32s ease-in-out infinite;
}
.ra-arm-r {
animation: ra-arm-r-kf 0.32s ease-in-out infinite;
}
`}</style>
</div>
{label ? (
<p
className={`${inline ? "" : "text-center"} font-mono text-[11px] tracking-[0.08em] text-white/55 ${labelClassName}`}
>
{label}
</p>
) : null}
</div>
);
}
@@ -155,3 +155,23 @@ export const deleteAgentRecordViaStudio = async (params: {
throw err;
}
};
export const trashAgentStateViaStudio = async (params: {
agentId: string;
fetchJson?: FetchJson;
}): Promise<TrashAgentStateResult> => {
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;
};
@@ -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<string>;
onGeneratePlan: (brief: string) => Promise<CompanyBuilderPlan>;
onCreateCompany: (params: {
input: CompanyBuilderInput;
plan: CompanyBuilderPlan;
}) => Promise<void>;
};
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 (
<div className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-cyan-100/65">
{label}
</p>
<div className="text-xs leading-5 text-white/75">{values.join(", ")}</div>
</div>
);
};
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<CompanyBuilderInput>({
businessDescription: initialInput?.businessDescription ?? "",
improvedBrief: initialInput?.improvedBrief ?? "",
});
const [plan, setPlan] = useState<CompanyBuilderPlan | null>(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<string | null>(null);
const roleListContainerRef = useRef<HTMLElement | null>(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 (
<div className="fixed inset-0 z-[100100] flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
<div className="flex h-[min(92vh,920px)] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#090d13] text-white shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-cyan-200/70">
<Sparkles className="h-4 w-4" />
Company Builder
</div>
<h2 className="mt-1 text-lg font-semibold">Design an AI company from one prompt</h2>
<p className="mt-1 text-sm text-white/55">
Uses your connected OpenClaw runtime
{plannerAgentName ? ` via ${plannerAgentName}.` : "."}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-100 transition hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
setInput({ businessDescription: "", improvedBrief: "" });
setPromptDraft("");
setPlan(null);
setPromptModalOpen(true);
setReplaceConfirmOpen(false);
onClear();
}}
disabled={!canClear}
>
Clear
</button>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md bg-amber-500 px-3 py-2 text-xs font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
void onGeneratePlan(effectiveBrief)
.then((nextPlan) => {
setPlan(nextPlan);
})
.catch(() => {});
}}
disabled={!canGenerate}
>
<Sparkles className="h-3.5 w-3.5" />
Generate
</button>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/25 bg-cyan-500/10 px-3 py-2 text-xs font-semibold text-cyan-100 transition hover:bg-cyan-500/20 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
setOrgChartOpen(true);
}}
disabled={!canPreviewChart}
>
<GitBranch className="h-3.5 w-3.5" />
Org Chart
</button>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md bg-emerald-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
if (!plan) return;
if (replacesExistingAgents) {
setReplaceConfirmOpen(true);
return;
}
triggerCreateCompany();
}}
disabled={!canCreate}
>
<Wand2 className="h-3.5 w-3.5" />
Create Company
</button>
<button
type="button"
className="rounded-md border border-white/10 p-2 text-white/60 transition hover:bg-white/5 hover:text-white"
onClick={onClose}
disabled={busy}
aria-label="Close company builder"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[360px_minmax(0,1fr)]">
<section className="overflow-y-auto border-b border-white/10 px-6 py-5 lg:border-b-0 lg:border-r">
<div className="space-y-5">
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-white/60">
Source prompt
</p>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-2.5 py-1.5 text-[11px] font-semibold text-cyan-100 transition hover:bg-cyan-500/20 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
setPromptDraft(input.businessDescription);
setPromptModalOpen(true);
}}
disabled={busy}
>
<Wand2 className="h-3 w-3" />
{input.businessDescription.trim() ? "Edit prompt" : "Describe company"}
</button>
</div>
<div className="text-sm leading-6 text-white/70">
{input.businessDescription.trim()
? input.businessDescription
: "Describe what the company should do and Claw3D will immediately turn it into an improved brief."}
</div>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-white/60">
Improved Brief
</p>
<p className="mt-1 text-[11px] text-white/45">
This is the text used for company generation.
</p>
</div>
</div>
<textarea
className={`${textareaClassName} mt-3 min-h-[340px]`}
placeholder="AI will rewrite the brief here."
value={input.improvedBrief}
onChange={(event) =>
setInput((current) => ({
...current,
improvedBrief: event.target.value,
}))
}
disabled={busy}
/>
</div>
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-white/60">
Company Actions
</p>
<p className="mt-1 text-[11px] text-white/45">
Generate the org, then create it in OpenClaw.
</p>
</div>
{replacesExistingAgents ? (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-100/85">
Your current {agentCount === 1 ? "agent will" : `${agentCount} agents will`} be
deleted and replaced by this company when you create it. This action is
irreversible and will delete the old agents&apos; workspaces.
</div>
) : null}
{!canUseAi ? (
<p className="text-xs text-amber-200/80">
Connect to OpenClaw and keep at least one available planning agent in the fleet
to use AI suggestions.
</p>
) : null}
{statusLine ? <p className="text-xs text-cyan-100/75">{statusLine}</p> : null}
{error ? <p className="text-xs text-red-200">{error}</p> : null}
</div>
</div>
</section>
<section ref={roleListContainerRef} className="min-h-0 overflow-y-auto px-6 py-5">
{plan ? (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-2 text-xs text-white/60">
Company name
<input
className={inputClassName}
value={plan.companyName}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
companyName: event.target.value,
}
: current
)
}
disabled={busy}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-white/60">
Shared rules
<input
className={inputClassName}
value={joinCommaList(plan.sharedRules)}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
sharedRules: parseCommaList(event.target.value),
}
: current
)
}
disabled={busy}
/>
</label>
</div>
<label className="flex flex-col gap-2 text-xs text-white/60">
Company summary
<textarea
className={`${textareaClassName} min-h-[110px]`}
value={plan.summary}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
summary: event.target.value,
}
: current
)
}
disabled={busy}
/>
</label>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">Org structure</p>
<p className="text-xs text-white/55">
Edit the team before creating agents in OpenClaw.
</p>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold text-white transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
pendingRoleScrollRef.current = true;
setPlan((current) =>
current
? {
...current,
roles: [...current.roles, createEmptyRole(current.roles.length)],
}
: current
);
}}
disabled={busy}
>
<Plus className="h-3.5 w-3.5" />
Add role
</button>
</div>
<div className="space-y-4">
{plan.roles.map((role, index) => (
<div
key={role.id || `role-${index}`}
className="rounded-xl border border-white/10 bg-white/[0.03] p-4"
>
<div className="mb-4 flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-4">
<div className="h-28 w-24 overflow-hidden rounded-xl border border-white/10 bg-[#070b16]">
<AgentAvatarPreview3D
profile={buildRoleAvatarProfile(role)}
className="h-full w-full"
/>
</div>
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.14em] text-white/45">
Role {index + 1}
</div>
<div className="mt-2 text-sm font-semibold text-white">
{role.title || "Untitled role"}
</div>
<div className="mt-1 text-xs text-white/45">
3D avatar preview generated for this role.
</div>
</div>
</div>
<button
type="button"
className="rounded-md border border-red-500/20 bg-red-500/10 p-2 text-red-100 transition hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.filter((_, roleIndex) => roleIndex !== index),
}
: current
)
}
disabled={busy || plan.roles.length <= 1}
aria-label={`Remove role ${index + 1}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-2 text-xs text-white/60">
Name
<input
className={inputClassName}
value={role.title}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? { ...entry, title: event.target.value }
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-white/60">
Emoji
<input
className={inputClassName}
value={role.emoji}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? { ...entry, emoji: event.target.value }
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-2 text-xs text-white/60">
Purpose
<textarea
className={textareaClassName}
value={role.purpose}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? { ...entry, purpose: event.target.value }
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-white/60">
Soul
<textarea
className={textareaClassName}
value={role.soul}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? { ...entry, soul: event.target.value }
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-2 text-xs text-white/60">
Responsibilities
<input
className={inputClassName}
value={joinCommaList(role.responsibilities)}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? {
...entry,
responsibilities: parseCommaList(event.target.value),
}
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-white/60">
Collaborators
<input
className={inputClassName}
value={joinCommaList(role.collaborators)}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? {
...entry,
collaborators: parseCommaList(event.target.value),
}
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-white/60">
Tool notes
<input
className={inputClassName}
value={joinCommaList(role.tools)}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? { ...entry, tools: parseCommaList(event.target.value) }
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-white/60">
Heartbeat checklist
<input
className={inputClassName}
value={joinCommaList(role.heartbeat)}
onChange={(event) =>
setPlan((current) =>
current
? {
...current,
roles: current.roles.map((entry, roleIndex) =>
roleIndex === index
? { ...entry, heartbeat: parseCommaList(event.target.value) }
: entry
),
}
: current
)
}
disabled={busy}
/>
</label>
</div>
</div>
))}
</div>
</div>
) : (
<div className="flex h-full items-center justify-center rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-8 text-center">
<div className="max-w-md space-y-3">
<Sparkles className="mx-auto h-8 w-8 text-cyan-300/70" />
<p className="text-lg font-semibold text-white">No company generated yet</p>
<p className="text-sm text-white/55">
Start by describing the company. Claw3D will create the improved brief
automatically, then you can generate and edit the org structure before anything
is created.
</p>
</div>
</div>
)}
</section>
</div>
</div>
{busy ? (
<div className="fixed inset-0 z-[100120] flex items-center justify-center bg-black/80 backdrop-blur-md">
<div className="w-full max-w-md rounded-2xl border border-cyan-500/20 bg-[#08111a] px-6 py-6 text-center shadow-2xl">
<RunningAvatarLoader size={40} trackWidth={104} />
<p className="mt-4 text-sm font-semibold text-white">
{statusLine?.trim() || "Working on your company."}
</p>
<p className="mt-2 text-xs leading-5 text-white/55">
Claw3D is using your OpenClaw runtime right now. Please wait until this finishes.
</p>
<div className="mt-5 flex gap-2">
{Array.from({ length: 4 }, (_, index) => (
<span
key={`company-loading-${index}`}
className="h-1.5 flex-1 rounded-full bg-cyan-400/30 animate-pulse"
style={{ animationDelay: `${index * 120}ms` }}
/>
))}
</div>
</div>
</div>
) : null}
{replaceConfirmOpen ? (
<div className="fixed inset-0 z-[100115] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-2xl border border-white/10 bg-[#0b1119] p-6 shadow-2xl">
<div className="space-y-2">
<p className="text-sm font-semibold text-white">Replace current agents?</p>
<p className="text-sm leading-6 text-white/65">
Your current {agentCount === 1 ? "agent will" : `${agentCount} agents will`} be
deleted and replaced by this new company. This action is irreversible and will
delete the old agents&apos; workspaces. Are you sure you want to continue?
</p>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button
type="button"
className="rounded-md border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/10"
onClick={() => {
setReplaceConfirmOpen(false);
}}
disabled={busy}
>
Cancel
</button>
<button
type="button"
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400"
onClick={() => {
setReplaceConfirmOpen(false);
triggerCreateCompany();
}}
disabled={busy}
>
Create Company
</button>
</div>
</div>
</div>
) : null}
{orgChartOpen && plan ? (
<div className="fixed inset-0 z-[100112] flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
<div className="flex h-[min(88vh,860px)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#0b1119] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200/70">
Org Chart Preview
</p>
<p className="mt-2 text-sm text-white/60">
Hover any avatar to inspect the role brief, responsibilities, and collaborators.
</p>
</div>
<button
type="button"
className="rounded-md border border-white/10 p-2 text-white/60 transition hover:bg-white/5 hover:text-white"
onClick={() => {
setOrgChartOpen(false);
}}
disabled={busy}
aria-label="Close org chart preview"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
<div className="mx-auto grid max-w-5xl gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-h-0 flex-col items-center">
<button
type="button"
className={`flex w-full max-w-xs flex-col items-center rounded-2xl border px-5 py-5 text-center transition ${
resolvedHoveredOrgRoleId === plan.roles[0]?.id
? "border-cyan-400/40 bg-cyan-500/12"
: "border-cyan-500/20 bg-cyan-500/10 hover:border-cyan-300/35"
}`}
onMouseEnter={() => {
setHoveredOrgRoleId(plan.roles[0]?.id ?? null);
}}
onFocus={() => {
setHoveredOrgRoleId(plan.roles[0]?.id ?? null);
}}
>
<div className="h-28 w-24 overflow-hidden rounded-xl border border-white/10 bg-[#070b16]">
<AgentAvatarPreview3D
profile={buildRoleAvatarProfile(plan.roles[0])}
className="h-full w-full"
/>
</div>
<p className="mt-3 text-xs uppercase tracking-[0.14em] text-cyan-100/65">Role 1</p>
<p className="mt-1 text-lg font-semibold text-white">
{plan.roles[0].title || "Untitled role"}
</p>
<p className="mt-1 text-sm text-white/60">
{plan.roles[0].purpose || "No purpose yet."}
</p>
</button>
{plan.roles.length > 1 ? (
<>
<div className="h-10 w-px bg-white/10" />
<div className="mb-8 h-px w-[min(100%,720px)] bg-white/10" />
<div className="grid w-full max-w-4xl gap-5 md:grid-cols-2 xl:grid-cols-3">
{plan.roles.slice(1).map((role, index) => (
<button
key={role.id || `org-chart-role-${index + 2}`}
type="button"
className={`flex flex-col items-center rounded-2xl border px-4 py-5 text-center transition ${
resolvedHoveredOrgRoleId === role.id
? "border-cyan-400/40 bg-cyan-500/10"
: "border-white/10 bg-white/[0.03] hover:border-cyan-300/25"
}`}
onMouseEnter={() => {
setHoveredOrgRoleId(role.id);
}}
onFocus={() => {
setHoveredOrgRoleId(role.id);
}}
>
<div className="h-24 w-20 overflow-hidden rounded-xl border border-white/10 bg-[#070b16]">
<AgentAvatarPreview3D
profile={buildRoleAvatarProfile(role)}
className="h-full w-full"
/>
</div>
<p className="mt-3 text-xs uppercase tracking-[0.14em] text-white/45">
Role {index + 2}
</p>
<p className="mt-1 text-base font-semibold text-white">
{role.title || "Untitled role"}
</p>
<p className="mt-1 text-sm text-white/60">
{role.purpose || "No purpose yet."}
</p>
</button>
))}
</div>
</>
) : null}
</div>
<aside className="rounded-2xl border border-white/10 bg-[#08111a] p-5 lg:sticky lg:top-0 lg:h-fit">
{hoveredOrgRole ? (
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-cyan-100/65">
Active Role
</p>
<p className="mt-2 text-xl font-semibold text-white">
{hoveredOrgRole.title || "Untitled role"}
</p>
<p className="mt-2 text-sm leading-6 text-white/70">
{hoveredOrgRole.soul || "No soul notes yet."}
</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-cyan-100/65">
Purpose
</p>
<p className="mt-2 text-sm leading-6 text-white/75">
{hoveredOrgRole.purpose || "No purpose yet."}
</p>
</div>
{renderRoleFacts("Responsibilities", hoveredOrgRole.responsibilities)}
{renderRoleFacts("Collaborators", hoveredOrgRole.collaborators)}
{renderRoleFacts("Tools", hoveredOrgRole.tools)}
{renderRoleFacts("Heartbeat", hoveredOrgRole.heartbeat)}
</div>
) : (
<p className="text-sm text-white/55">Hover a role to inspect it.</p>
)}
</aside>
</div>
</div>
</div>
</div>
) : null}
{promptModalOpen ? (
<div className="fixed inset-0 z-[100110] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-2xl border border-white/10 bg-[#0b1119] p-6 shadow-2xl">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200/70">
What should the company do?
</p>
<p className="mt-2 text-sm text-white/55">
As soon as you submit this, OpenClaw will improve the brief automatically.
</p>
</div>
<button
type="button"
className="rounded-md border border-white/10 p-2 text-white/60 transition hover:bg-white/5 hover:text-white"
onClick={() => {
if (busy) return;
setPromptModalOpen(false);
}}
disabled={busy}
aria-label="Close prompt modal"
>
<X className="h-4 w-4" />
</button>
</div>
<textarea
className={`${textareaClassName} mt-5 min-h-[220px]`}
placeholder="I run a web design company that builds websites, web apps, mobile apps, SEO campaigns, and social media services..."
value={promptDraft}
onChange={(event) => {
setPromptDraft(event.target.value);
}}
disabled={busy}
/>
<div className="mt-5 flex items-center justify-between gap-3">
<p className="text-xs text-white/45">
The improved brief becomes the main editable input for generation.
</p>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
const trimmedPrompt = promptDraft.trim();
if (!trimmedPrompt) return;
void (async () => {
try {
const improvedBrief = await onImproveBrief(trimmedPrompt);
setInput({
businessDescription: trimmedPrompt,
improvedBrief,
});
setPromptModalOpen(false);
fireAutoGenerate(improvedBrief);
} catch {}
})();
}}
disabled={!canUseAi || promptDraft.trim().length === 0 || busy}
>
<Sparkles className="h-4 w-4" />
Generate Company
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,122 @@
import { buildCompanyAgentBlueprints } from "@/features/company-builder/planning";
import {
buildCompanyRolePermissionsDraft,
} from "@/features/company-builder/operations/companyBuilderGateway";
import type {
CompanyBuilderInput,
CompanyBuilderPlan,
} from "@/features/company-builder/types";
import type { AgentFileName } from "@/lib/agents/agentFiles";
import type { CommandModeId } from "@/features/agents/operations/agentPermissionsOperation";
type CreatedAgentRecord = {
agentId: string;
commandMode: CommandModeId;
};
export async function runCompanyBootstrapOperation(params: {
input: CompanyBuilderInput;
plan: CompanyBuilderPlan;
existingAgentIds?: string[];
deleteExistingAgent?: (agentId: string) => Promise<void>;
clearReusedAgentState?: (agentId: string) => Promise<void>;
renameAgent?: (agentId: string, name: string) => Promise<void>;
onExistingAgentDeleted?: (agentId: string) => void;
createAgent: (name: string) => Promise<{ id: string }>;
writeAgentFiles: (
agentId: string,
files: Record<AgentFileName, string>,
) => Promise<void>;
saveAvatar: (agentId: string) => void;
loadAgents: () => Promise<void>;
findAgentById: (agentId: string) => { agentId: string; sessionKey: string } | null;
resetAgentSession?: (agentId: string, sessionKey: string) => Promise<void>;
applyPermissions: (
agentId: string,
sessionKey: string,
commandMode: CommandModeId,
) => Promise<void>;
persistSnapshot: (input: CompanyBuilderInput, plan: CompanyBuilderPlan) => void;
setOfficeTitle: (title: string) => void;
selectAgent: (agentId: string) => void;
setStatusLine: (value: string | null) => void;
}): Promise<string[]> {
const blueprints = buildCompanyAgentBlueprints(params.plan);
const existingAgentIds = params.existingAgentIds ?? [];
const reusableAgentId = existingAgentIds.includes("main") && blueprints[0] ? "main" : null;
const deletableAgentIds = existingAgentIds.filter((agentId) => agentId !== reusableAgentId);
if (deletableAgentIds.length > 0 && params.deleteExistingAgent) {
params.setStatusLine(
deletableAgentIds.length === 1
? "Replacing your current agent."
: `Replacing your current ${deletableAgentIds.length} agents.`,
);
for (const agentId of deletableAgentIds) {
await params.deleteExistingAgent(agentId);
params.onExistingAgentDeleted?.(agentId);
}
}
const createdAgents: CreatedAgentRecord[] = [];
const remainingBlueprints = [...blueprints];
if (reusableAgentId && remainingBlueprints[0]) {
const firstBlueprint = remainingBlueprints.shift();
if (firstBlueprint) {
if (params.clearReusedAgentState) {
params.setStatusLine("Clearing the previous main agent state.");
await params.clearReusedAgentState(reusableAgentId);
}
params.setStatusLine(`Reconfiguring main as ${firstBlueprint.agentName}.`);
await params.renameAgent?.(reusableAgentId, firstBlueprint.agentName);
await params.writeAgentFiles(reusableAgentId, firstBlueprint.files);
params.saveAvatar(reusableAgentId);
createdAgents.push({
agentId: reusableAgentId,
commandMode: firstBlueprint.role.commandMode,
});
}
}
for (const blueprint of remainingBlueprints) {
params.setStatusLine(`Creating ${blueprint.agentName}.`);
const created = await params.createAgent(blueprint.agentName);
createdAgents.push({
agentId: created.id,
commandMode: blueprint.role.commandMode,
});
await params.writeAgentFiles(created.id, blueprint.files);
params.saveAvatar(created.id);
}
params.setStatusLine("Syncing the new company into the office.");
await params.loadAgents();
for (const createdAgent of createdAgents) {
const liveAgent = params.findAgentById(createdAgent.agentId);
if (!liveAgent?.sessionKey) continue;
await params.applyPermissions(
liveAgent.agentId,
liveAgent.sessionKey,
createdAgent.commandMode,
);
}
if (reusableAgentId && params.resetAgentSession) {
const reusableAgent = params.findAgentById(reusableAgentId);
if (reusableAgent?.sessionKey) {
params.setStatusLine("Refreshing the first role session.");
await params.resetAgentSession(reusableAgentId, reusableAgent.sessionKey);
}
}
params.persistSnapshot(params.input, params.plan);
params.setOfficeTitle(`${params.plan.companyName} HQ`);
if (createdAgents[0]?.agentId) {
params.selectAgent(createdAgents[0].agentId);
}
params.setStatusLine(null);
return createdAgents.map((entry) => entry.agentId);
}
export { buildCompanyRolePermissionsDraft };
@@ -0,0 +1,91 @@
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
import { buildHistoryLines, type ChatHistoryMessage } from "@/features/agents/state/runtimeEventBridge";
import type { AgentState } from "@/features/agents/state/store";
import type { CommandModeId } from "@/features/agents/operations/agentPermissionsOperation";
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
type GatewayClientLike = {
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
};
type DispatchAction =
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta };
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
});
export const resolveCompanyPlanningAgent = (params: {
agents: AgentState[];
preferredAgentId?: string | null;
}) => {
const preferred = params.preferredAgentId?.trim() ?? "";
if (preferred) {
const preferredAgent = params.agents.find((entry) => entry.agentId === preferred) ?? null;
if (preferredAgent) return preferredAgent;
}
return params.agents[0] ?? null;
};
export const buildCompanyRolePermissionsDraft = (
commandMode: CommandModeId
): AgentPermissionsDraft => ({
commandMode,
webAccess: commandMode !== "off",
fileTools: commandMode !== "off",
});
export async function runOpenClawPlanningPrompt(params: {
client: GatewayClientLike;
dispatch: (action: DispatchAction) => void;
agent: AgentState;
getAgent: (agentId: string) => AgentState | null;
clearRunTracking?: (runId: string) => void;
prompt: string;
historyLimit?: number;
timeoutMs?: number;
}): Promise<string> {
const trimmedPrompt = params.prompt.trim();
if (!trimmedPrompt) {
throw new Error("Planning prompt is required.");
}
if (params.agent.status === "running") {
throw new Error(`Wait for ${params.agent.name} to finish the current run first.`);
}
await sendChatMessageViaStudio({
client: params.client,
dispatch: params.dispatch,
getAgent: params.getAgent,
agentId: params.agent.agentId,
sessionKey: params.agent.sessionKey,
message: trimmedPrompt,
clearRunTracking: params.clearRunTracking,
echoUserMessage: false,
});
const timeoutAt = Date.now() + (params.timeoutMs ?? 90_000);
while (Date.now() < timeoutAt) {
const liveAgent = params.getAgent(params.agent.agentId);
if (liveAgent && liveAgent.status !== "running") {
const history = await params.client.call<{
sessionKey: string;
messages: ChatHistoryMessage[];
}>("chat.history", {
sessionKey: params.agent.sessionKey,
limit: params.historyLimit ?? 80,
});
const derived = buildHistoryLines(history.messages ?? []);
if (derived.lastAssistant?.trim()) {
return derived.lastAssistant.trim();
}
throw new Error("The planning agent finished, but no assistant response was available.");
}
await sleep(800);
}
throw new Error("Timed out while waiting for the planning agent response.");
}
+464
View File
@@ -0,0 +1,464 @@
import {
type CommandModeId,
} from "@/features/agents/operations/agentPermissionsOperation";
import {
createEmptyPersonalityDraft,
serializePersonalityFiles,
type PersonalityBuilderDraft,
} from "@/lib/agents/personalityBuilder";
import type { AgentFileName } from "@/lib/agents/agentFiles";
import type {
CompanyAgentBlueprint,
CompanyBuilderPlan,
CompanyBuilderRole,
CompanyBuilderStoredSnapshot,
} from "@/features/company-builder/types";
type ParsedCompanyPlan = {
companyName?: unknown;
summary?: unknown;
sharedRules?: unknown;
plannerNotes?: unknown;
roles?: unknown;
};
type ParsedCompanyRole = {
id?: unknown;
name?: unknown;
title?: unknown;
purpose?: unknown;
soul?: unknown;
responsibilities?: unknown;
collaborators?: unknown;
tools?: unknown;
heartbeat?: unknown;
emoji?: unknown;
creature?: unknown;
vibe?: unknown;
userContext?: unknown;
commandMode?: unknown;
};
const COMPANY_FENCE_RE = /^```(?:json)?\s*|\s*```$/gim;
const MAX_ROLE_COUNT = 8;
const normalizeLine = (value: string) => value.replace(/\r\n/g, "\n").trim();
const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
const coerceStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
};
const uniqueStrings = (values: string[]) => Array.from(new Set(values));
const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const toPascalCaseWord = (value: string) =>
value
.replace(/[^a-zA-Z0-9]+/g, " ")
.split(/\s+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => `${entry.charAt(0).toUpperCase()}${entry.slice(1).toLowerCase()}`)
.join("");
const normalizeRoleName = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return "";
const singleWord = trimmed.replace(/[^a-zA-Z0-9]/g, "");
if (singleWord.length > 0 && !/\s/.test(trimmed)) {
return singleWord.slice(0, 18);
}
const compact = toPascalCaseWord(trimmed);
return compact.slice(0, 18);
};
const dedupeCompactNames = (values: string[], fallbackPrefix: string) => {
const used = new Set<string>();
return values.map((value, index) => {
const fallback = `${fallbackPrefix}${index + 1}`;
const baseName = normalizeRoleName(value) || normalizeRoleName(fallback) || fallback;
let nextName = baseName;
let suffix = 2;
while (used.has(nextName.toLowerCase())) {
const suffixText = String(suffix);
const trimmedBase = baseName.slice(0, Math.max(1, 18 - suffixText.length));
nextName = `${trimmedBase}${suffixText}`;
suffix += 1;
}
used.add(nextName.toLowerCase());
return nextName;
});
};
const toSentenceList = (values: string[]) =>
values
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => (/[.!?]$/.test(entry) ? entry : `${entry}.`));
const resolveCommandMode = (value: unknown, roleText: string): CommandModeId => {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "off" || normalized === "ask" || normalized === "auto") {
return normalized;
}
const lowered = roleText.toLowerCase();
if (/\b(developer|engineer|automation|devops|ops)\b/.test(lowered)) {
return "auto";
}
if (/\b(manager|lead|qa|support|analyst|marketing|social)\b/.test(lowered)) {
return "ask";
}
return "ask";
};
const buildRoleIdentity = (role: CompanyBuilderRole) => ({
emoji: role.emoji || "🤖",
creature: role.creature || "specialist",
vibe: role.vibe || "helpful and focused",
});
const buildRoleAgentsMarkdown = (params: {
plan: CompanyBuilderPlan;
role: CompanyBuilderRole;
}) => {
const collaborators =
params.role.collaborators.length > 0
? params.role.collaborators
: params.plan.roles
.filter((entry) => entry.id !== params.role.id)
.slice(0, 3)
.map((entry) => entry.title);
const responsibilityLines = toSentenceList(params.role.responsibilities);
const sharedRules = toSentenceList(params.plan.sharedRules);
const plannerNotes = toSentenceList(params.plan.plannerNotes);
return [
`# ${params.plan.companyName} Team Operating Guide`,
"",
`You are the ${params.role.title} inside ${params.plan.companyName}.`,
"",
"## Mission",
"",
params.role.purpose || `Own the ${params.role.title.toLowerCase()} function for the company.`,
"",
"## Responsibilities",
"",
...(responsibilityLines.length > 0
? responsibilityLines.map((entry) => `- ${entry}`)
: ["- Keep your area moving and surface blockers quickly."]),
"",
"## Collaborators",
"",
...(collaborators.length > 0
? collaborators.map((entry) => `- Work closely with ${entry}.`)
: ["- Coordinate with the rest of the company when work crosses team boundaries."]),
"",
"## Shared Rules",
"",
...(sharedRules.length > 0
? sharedRules.map((entry) => `- ${entry}`)
: [
"- Keep updates concise, practical, and action-oriented.",
"- Hand off work clearly when another role should take over.",
]),
"",
"## Planning Notes",
"",
...(plannerNotes.length > 0
? plannerNotes.map((entry) => `- ${entry}`)
: ["- Treat the user's company brief as the source of truth."]),
"",
].join("\n");
};
const buildRoleToolsMarkdown = (role: CompanyBuilderRole) =>
[
"# TOOLS.md",
"",
`Preferred operating mode: ${role.commandMode}.`,
"",
"## Tool Preferences",
"",
...(role.tools.length > 0
? role.tools.map((entry) => `- ${entry}.`)
: [
"- Use the tools that best match your role.",
"- Ask for help when another teammate has better context.",
]),
"",
].join("\n");
const buildRoleHeartbeatMarkdown = (role: CompanyBuilderRole) =>
[
"# HEARTBEAT.md",
"",
"When your heartbeat runs:",
"",
...(role.heartbeat.length > 0
? role.heartbeat.map((entry) => `- ${entry}.`)
: [
"- Check your most important queue or active work.",
"- Report blockers before they become expensive.",
"- Coordinate with collaborators if handoffs are waiting.",
]),
"",
].join("\n");
const buildRoleMemoryMarkdown = (params: {
plan: CompanyBuilderPlan;
role: CompanyBuilderRole;
}) =>
[
"# MEMORY.md",
"",
`Company: ${params.plan.companyName}.`,
`Role: ${params.role.title}.`,
"",
"Remember:",
"",
`- ${params.plan.summary || "The company plan should guide your decisions."}`,
`- ${params.role.purpose || "Protect the quality of your function."}`,
"",
].join("\n");
const buildRoleSoulDraft = (params: {
plan: CompanyBuilderPlan;
role: CompanyBuilderRole;
}): PersonalityBuilderDraft => {
const draft = createEmptyPersonalityDraft();
const identity = buildRoleIdentity(params.role);
draft.identity.name = params.role.title;
draft.identity.emoji = identity.emoji;
draft.identity.creature = identity.creature;
draft.identity.vibe = identity.vibe;
draft.user.context = normalizeLine(
[
`Company brief: ${params.plan.summary}`,
params.role.userContext,
]
.filter((entry) => entry.trim().length > 0)
.join("\n\n")
);
draft.soul.coreTruths = normalizeLine(
[
params.role.soul,
`Your job is to help ${params.plan.companyName} succeed as the ${params.role.title}.`,
]
.filter((entry) => entry.trim().length > 0)
.join("\n\n")
);
draft.soul.boundaries = normalizeLine(
[
"Do not invent decisions that should be handed to another specialist.",
"Escalate blockers early and keep handoffs explicit.",
].join("\n")
);
draft.soul.vibe = normalizeLine(
params.role.vibe || `${params.role.title} energy: practical, collaborative, and sharp.`
);
draft.soul.continuity = normalizeLine(
`Keep continuity around ${params.plan.companyName}'s goals, teammates, and operating rules.`
);
draft.agents = buildRoleAgentsMarkdown(params);
draft.tools = buildRoleToolsMarkdown(params.role);
draft.heartbeat = buildRoleHeartbeatMarkdown(params.role);
draft.memory = buildRoleMemoryMarkdown(params);
return draft;
};
const normalizeRole = (value: ParsedCompanyRole, index: number): CompanyBuilderRole | null => {
const title = normalizeRoleName(coerceString(value.name) || coerceString(value.title));
if (!title) return null;
const purpose = coerceString(value.purpose);
const soul = coerceString(value.soul);
const responsibilities = uniqueStrings(coerceStringArray(value.responsibilities)).slice(0, 8);
const collaborators = uniqueStrings(coerceStringArray(value.collaborators)).slice(0, 8);
const tools = uniqueStrings(coerceStringArray(value.tools)).slice(0, 8);
const heartbeat = uniqueStrings(coerceStringArray(value.heartbeat)).slice(0, 8);
const emoji = coerceString(value.emoji);
const creature = coerceString(value.creature);
const vibe = coerceString(value.vibe);
const userContext = coerceString(value.userContext);
const id = coerceString(value.id) || slugify(title) || `role-${index + 1}`;
const roleText = [title, purpose, soul, ...responsibilities, ...tools].join(" ");
return {
id,
title,
purpose,
soul,
responsibilities,
collaborators,
tools,
heartbeat,
emoji,
creature,
vibe,
userContext,
commandMode: resolveCommandMode(value.commandMode, roleText),
};
};
export const buildImproveCompanyBriefPrompt = (businessDescription: string) =>
[
"You are helping a user describe the company they want to build inside Claw3D.",
"Rewrite their brief so another OpenClaw agent can generate a clean org structure from it.",
"Keep the answer short, concrete, and useful.",
"Return markdown with these sections only:",
"## Company",
"## Goals",
"## Constraints",
"## Suggested Roles",
"",
"User brief:",
businessDescription.trim(),
].join("\n");
export const buildGenerateCompanyPlanPrompt = (brief: string) =>
[
"You are designing an AI company org structure for Claw3D.",
"Return only valid JSON with no markdown fence.",
"Each role name must be one concise word only with no spaces.",
"Schema:",
"{",
' "companyName": "string",',
' "summary": "string",',
' "sharedRules": ["string"],',
' "plannerNotes": ["string"],',
' "roles": [',
" {",
' "id": "string",',
' "name": "string",',
' "purpose": "string",',
' "soul": "string",',
' "responsibilities": ["string"],',
' "collaborators": ["string"],',
' "tools": ["string"],',
' "heartbeat": ["string"],',
' "emoji": "string",',
' "creature": "string",',
' "vibe": "string",',
' "userContext": "string",',
' "commandMode": "off|ask|auto"',
" }",
" ]",
"}",
"Create between 2 and 6 roles unless the brief clearly needs more or less.",
"Prefer silly but useful role titles when it helps the brand, but keep the org practical.",
"Role names should be short single words like Builder, Analyst, Closer, Captain, Scout, or Designer.",
"All role names must be unique.",
"Make collaborators reference role names.",
"",
"Company brief:",
brief.trim(),
].join("\n");
export const extractJsonFromAssistantText = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("The planning agent returned an empty response.");
}
const unfenced = trimmed.replace(COMPANY_FENCE_RE, "").trim();
const firstBrace = unfenced.indexOf("{");
const lastBrace = unfenced.lastIndexOf("}");
if (firstBrace < 0 || lastBrace < firstBrace) {
throw new Error("The planning agent did not return valid JSON.");
}
return unfenced.slice(firstBrace, lastBrace + 1);
};
export const parseCompanyPlanFromAssistantText = (value: string): CompanyBuilderPlan => {
let parsed: ParsedCompanyPlan;
try {
parsed = JSON.parse(extractJsonFromAssistantText(value)) as ParsedCompanyPlan;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error("Failed to parse the planning agent response.");
}
const rolesRaw = Array.isArray(parsed.roles) ? parsed.roles : [];
const normalizedRoles = rolesRaw
.map((entry, index) => normalizeRole((entry ?? {}) as ParsedCompanyRole, index))
.filter((entry): entry is CompanyBuilderRole => Boolean(entry))
.slice(0, MAX_ROLE_COUNT);
if (normalizedRoles.length === 0) {
throw new Error("The planning agent did not return any company roles.");
}
const uniqueTitles = dedupeCompactNames(
normalizedRoles.map((entry) => entry.title),
"Agent",
);
const usedIds = new Set<string>();
const roles = normalizedRoles.map((role, index) => {
const title = uniqueTitles[index] ?? role.title;
const baseId = role.id.trim() || slugify(title) || `role-${index + 1}`;
let nextId = baseId;
let suffix = 2;
while (usedIds.has(nextId)) {
nextId = `${baseId}-${suffix}`;
suffix += 1;
}
usedIds.add(nextId);
return {
...role,
id: nextId,
title,
};
});
return {
companyName: coerceString(parsed.companyName) || "New Company",
summary: coerceString(parsed.summary) || "A company plan generated from the user's brief.",
sharedRules: uniqueStrings(coerceStringArray(parsed.sharedRules)).slice(0, 12),
plannerNotes: uniqueStrings(coerceStringArray(parsed.plannerNotes)).slice(0, 12),
roles,
};
};
export const buildCompanyAgentBlueprints = (plan: CompanyBuilderPlan): CompanyAgentBlueprint[] => {
const usedNames = new Set<string>();
return plan.roles.map((role, index) => {
const baseName = role.title.trim() || `Agent ${index + 1}`;
let nextName = baseName;
let dedupe = 2;
while (usedNames.has(nextName.toLowerCase())) {
nextName = `${baseName} ${dedupe}`;
dedupe += 1;
}
usedNames.add(nextName.toLowerCase());
const roleWithName = { ...role, title: nextName };
const draft = buildRoleSoulDraft({ plan, role: roleWithName });
const files = serializePersonalityFiles(draft) as Record<AgentFileName, string>;
return {
agentName: nextName,
role: roleWithName,
draft,
files,
};
});
};
export const buildStoredCompanySnapshot = (params: {
prompt: string;
improvedBrief: string;
plan: CompanyBuilderPlan;
now?: () => string;
}): CompanyBuilderStoredSnapshot => ({
companyName: params.plan.companyName,
prompt: params.prompt.trim(),
improvedBrief: params.improvedBrief.trim(),
summary: params.plan.summary,
generatedAt: (params.now ?? (() => new Date().toISOString()))(),
roleTitles: params.plan.roles.map((entry) => entry.title),
planJson: JSON.stringify(params.plan),
});
+49
View File
@@ -0,0 +1,49 @@
import type { PersonalityBuilderDraft } from "@/lib/agents/personalityBuilder";
import type { AgentFileName } from "@/lib/agents/agentFiles";
import type { CommandModeId } from "@/features/agents/operations/agentPermissionsOperation";
export type CompanyBuilderInput = {
businessDescription: string;
improvedBrief: string;
};
export type CompanyBuilderRole = {
id: string;
title: string;
purpose: string;
soul: string;
responsibilities: string[];
collaborators: string[];
tools: string[];
heartbeat: string[];
emoji: string;
creature: string;
vibe: string;
userContext: string;
commandMode: CommandModeId;
};
export type CompanyBuilderPlan = {
companyName: string;
summary: string;
sharedRules: string[];
plannerNotes: string[];
roles: CompanyBuilderRole[];
};
export type CompanyBuilderStoredSnapshot = {
companyName: string;
prompt: string;
improvedBrief: string;
summary: string;
generatedAt: string;
roleTitles: string[];
planJson: string;
};
export type CompanyAgentBlueprint = {
agentName: string;
role: CompanyBuilderRole;
draft: PersonalityBuilderDraft;
files: Record<AgentFileName, string>;
};
@@ -16,6 +16,7 @@ type HQSidebarProps = {
onTabChange: (tab: HQSidebarTab) => void;
onOpenMarketplace: () => void;
onAddAgent?: () => void;
onOpenCompanyBuilder?: () => void;
inboxPanel: ReactNode;
historyPanel: ReactNode;
playbooksPanel: ReactNode;
@@ -39,6 +40,7 @@ export function HQSidebar({
onTabChange,
onOpenMarketplace,
onAddAgent,
onOpenCompanyBuilder,
inboxPanel,
historyPanel,
playbooksPanel,
@@ -125,6 +127,15 @@ export function HQSidebar({
Add Agent
</button>
) : null}
{!railOnly && onOpenCompanyBuilder ? (
<button
type="button"
onClick={onOpenCompanyBuilder}
className="mt-2 rounded border border-emerald-500/20 bg-emerald-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-200 transition-colors hover:border-emerald-400/40 hover:text-emerald-100"
>
Build Company
</button>
) : null}
{railOnly ? (
<button
type="button"
@@ -2,6 +2,7 @@
import { Check, Landmark, Lock, RefreshCw, Wallet } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
import {
type OfficeUsageAnalyticsParams,
useOfficeUsageAnalyticsViewModel,
@@ -271,7 +272,11 @@ export function AtmImmersiveScreen(props: OfficeUsageAnalyticsParams) {
onClick={() => void usage.refresh()}
className="inline-flex items-center gap-2 rounded-full border border-[#7dfff0]/24 bg-[#072528] px-4 py-2 text-[11px] uppercase tracking-[0.22em] text-[#b7fff8] transition-colors hover:border-[#7dfff0]/40 hover:bg-[#0a3035]"
>
<RefreshCw className={`h-3.5 w-3.5 ${usage.loading ? "animate-spin" : ""}`} />
{usage.loading ? (
<RunningAvatarLoader size={16} trackWidth={32} inline />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Refresh
</button>
}
@@ -10,6 +10,7 @@ import {
ShieldX,
MessageSquare,
} from "lucide-react";
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
import type {
GitHubDashboardResponse,
@@ -432,9 +433,11 @@ export function GithubImmersiveScreen({
}}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[12px] text-white/72 transition-colors hover:border-white/20 hover:text-white"
>
<RefreshCw
className={`h-3.5 w-3.5 ${loading || detailLoading ? "animate-spin" : ""}`}
/>
{loading || detailLoading ? (
<RunningAvatarLoader size={16} trackWidth={32} inline />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Refresh
</button>
{dashboard?.viewerLogin ? (
@@ -465,9 +468,7 @@ export function GithubImmersiveScreen({
{isInitialLoading ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-8 py-10">
<div className="flex max-w-md flex-col items-center rounded-3xl border border-cyan-300/12 bg-[#081122]/78 px-8 py-10 text-center shadow-[0_20px_80px_rgba(0,0,0,0.38)]">
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-cyan-300/18 bg-cyan-300/8">
<RefreshCw className="h-6 w-6 animate-spin text-cyan-100" />
</div>
<RunningAvatarLoader size={40} trackWidth={104} />
<div className="mt-5 text-[11px] uppercase tracking-[0.28em] text-cyan-100/55">
Loading GitHub
</div>
+430 -4
View File
@@ -26,7 +26,10 @@ import {
createStudioSettingsCoordinator,
type StudioSettingsLoadOptions,
} from "@/lib/studio/coordinator";
import { resolveDeskAssignments } from "@/lib/studio/settings";
import {
resolveDeskAssignments,
resolveOfficePreferencePublic,
} from "@/lib/studio/settings";
import {
createGatewayAgent,
renameGatewayAgent,
@@ -69,7 +72,11 @@ import {
applyCreateAgentBootstrapPermissions,
CREATE_AGENT_DEFAULT_PERMISSIONS,
} from "@/features/agents/operations/createAgentBootstrapOperation";
import { deleteAgentRecordViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import {
deleteAgentRecordViaStudio,
deleteAgentViaStudio,
trashAgentStateViaStudio,
} from "@/features/agents/operations/deleteAgentOperation";
import { planAgentSettingsMutation } from "@/features/agents/operations/agentSettingsMutationWorkflow";
import {
executeHistorySyncCommands,
@@ -95,7 +102,10 @@ import {
type GatewayModelChoice,
} from "@/lib/gateway/models";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import {
createDefaultAgentAvatarProfile,
type AgentAvatarProfile,
} from "@/lib/avatars/profile";
import {
createEmptyPersonalityDraft,
serializePersonalityFiles,
@@ -107,6 +117,23 @@ import {
HQSidebar,
type HQSidebarTab,
} from "@/features/office/components/HQSidebar";
import { CompanyBuilderModal } from "@/features/company-builder/components/CompanyBuilderModal";
import {
buildGenerateCompanyPlanPrompt,
buildImproveCompanyBriefPrompt,
buildStoredCompanySnapshot,
parseCompanyPlanFromAssistantText,
} from "@/features/company-builder/planning";
import {
buildCompanyRolePermissionsDraft,
resolveCompanyPlanningAgent,
runOpenClawPlanningPrompt,
} from "@/features/company-builder/operations/companyBuilderGateway";
import { runCompanyBootstrapOperation } from "@/features/company-builder/operations/companyBootstrapOperation";
import type {
CompanyBuilderInput,
CompanyBuilderPlan,
} from "@/features/company-builder/types";
import { AnalyticsPanel } from "@/features/office/components/panels/AnalyticsPanel";
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
@@ -902,6 +929,19 @@ export function OfficeScreen({
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(
null,
);
const [companyBuilderOpen, setCompanyBuilderOpen] = useState(false);
const [companyBuilderNonce, setCompanyBuilderNonce] = useState(0);
const [companyBuilderBusy, setCompanyBuilderBusy] = useState(false);
const [companyBuilderError, setCompanyBuilderError] = useState<string | null>(null);
const [companyBuilderStatusLine, setCompanyBuilderStatusLine] = useState<string | null>(null);
const [companyBuilderInput, setCompanyBuilderInput] = useState<CompanyBuilderInput>({
businessDescription: "",
improvedBrief: "",
});
const [lastCompanyPlan, setLastCompanyPlan] = useState<CompanyBuilderPlan | null>(null);
const [companyCreatedSignal, setCompanyCreatedSignal] = useState(0);
const [createdCompanyName, setCreatedCompanyName] = useState<string | null>(null);
const [officeCameraCenterSignal, setOfficeCameraCenterSignal] = useState(0);
const [createAgentBlock, setCreateAgentBlock] =
useState<CreateAgentBlockState | null>(null);
const [deleteAgentBlock, setDeleteAgentBlock] =
@@ -1044,11 +1084,16 @@ export function OfficeScreen({
const showOnboardingWizard = showOnboarding || forceShowOnboarding;
const handleOpenOnboarding = useCallback(() => {
resetOnboarding();
setCompanyCreatedSignal(0);
setCreatedCompanyName(null);
setForceShowOnboarding(true);
}, [resetOnboarding]);
const handleCompleteOnboarding = useCallback(() => {
completeOnboarding();
setCompanyCreatedSignal(0);
setCreatedCompanyName(null);
setForceShowOnboarding(false);
setOfficeCameraCenterSignal((current) => current + 1);
}, [completeOnboarding]);
const handleAvatarProfileSave = useCallback(
@@ -1212,6 +1257,49 @@ export function OfficeScreen({
};
}, [gatewayUrl, loadStudioSettings]);
useEffect(() => {
let cancelled = false;
const gatewayKey = gatewayUrl.trim();
if (!gatewayKey) {
setCompanyBuilderInput({
businessDescription: "",
improvedBrief: "",
});
setLastCompanyPlan(null);
return;
}
void (async () => {
try {
const settings = await loadStudioSettings({ maxAgeMs: 30_000 });
if (!settings || cancelled) return;
const officePreference = resolveOfficePreferencePublic(settings, gatewayKey);
setCompanyBuilderInput({
businessDescription: officePreference.companyPrompt,
improvedBrief: officePreference.companyImprovedBrief,
});
if (officePreference.companyPlanJson.trim()) {
try {
setLastCompanyPlan(
JSON.parse(officePreference.companyPlanJson) as CompanyBuilderPlan,
);
} catch (error) {
console.error("Failed to parse saved company plan.", error);
setLastCompanyPlan(null);
}
} else {
setLastCompanyPlan(null);
}
} catch (error) {
if (!cancelled) {
console.error("Failed to load company builder preference.", error);
}
}
})();
return () => {
cancelled = true;
};
}, [gatewayUrl, loadStudioSettings]);
const loadAgents = useCallback(async (options?: {
forceSettings?: boolean;
minIntervalMs?: number;
@@ -1384,6 +1472,163 @@ export function OfficeScreen({
setCreateAgentWizardNonce((current) => current + 1);
setCreateAgentWizardOpen(true);
}, []);
const plannerAgent = useMemo(
() =>
resolveCompanyPlanningAgent({
agents: state.agents,
preferredAgentId: selectedChatAgentId ?? state.selectedAgentId,
}),
[selectedChatAgentId, state.agents, state.selectedAgentId],
);
const persistCompanyBuilderSnapshot = useCallback(
(input: CompanyBuilderInput, plan: CompanyBuilderPlan) => {
const gatewayKey = gatewayUrl.trim();
if (!gatewayKey) return;
const snapshot = buildStoredCompanySnapshot({
prompt: input.businessDescription,
improvedBrief: input.improvedBrief,
plan,
});
settingsCoordinator.schedulePatch(
{
office: {
[gatewayKey]: {
companyName: snapshot.companyName,
companyPrompt: snapshot.prompt,
companyImprovedBrief: snapshot.improvedBrief,
companySummary: snapshot.summary,
companyGeneratedAt: snapshot.generatedAt,
companyRoleTitles: snapshot.roleTitles,
companyPlanJson: snapshot.planJson,
},
},
},
0,
);
setCompanyBuilderInput(input);
setLastCompanyPlan(plan);
},
[gatewayUrl, settingsCoordinator],
);
const handleOpenCompanyBuilder = useCallback(() => {
setCompanyBuilderError(null);
setCompanyBuilderStatusLine(null);
setCreatedCompanyName(null);
setCompanyBuilderNonce((current) => current + 1);
setCompanyBuilderOpen(true);
}, []);
const handleCloseCompanyBuilder = useCallback(() => {
if (companyBuilderBusy) return;
setCompanyBuilderOpen(false);
setCompanyBuilderError(null);
setCompanyBuilderStatusLine(null);
}, [companyBuilderBusy]);
const handleClearCompanyBuilder = useCallback(() => {
const gatewayKey = gatewayUrl.trim();
setCompanyBuilderInput({
businessDescription: "",
improvedBrief: "",
});
setLastCompanyPlan(null);
setCompanyBuilderError(null);
setCompanyBuilderStatusLine(null);
if (!gatewayKey) return;
settingsCoordinator.schedulePatch(
{
office: {
[gatewayKey]: {
companyName: "",
companyPrompt: "",
companyImprovedBrief: "",
companySummary: "",
companyGeneratedAt: "",
companyRoleTitles: [],
companyPlanJson: "",
},
},
},
0,
);
}, [gatewayUrl, settingsCoordinator]);
const runCompanyBuilderAiTask = useCallback(
async (prompt: string, statusText: string) => {
if (status !== "connected") {
throw new Error("Connect to OpenClaw before using the company builder.");
}
const livePlannerAgent = resolveCompanyPlanningAgent({
agents: stateRef.current.agents,
preferredAgentId: selectedChatAgentId ?? state.selectedAgentId,
});
if (!livePlannerAgent) {
throw new Error("Create or load at least one agent before using AI suggestions.");
}
setCompanyBuilderStatusLine(statusText);
return runOpenClawPlanningPrompt({
client,
dispatch,
agent: livePlannerAgent,
getAgent: (agentId) =>
stateRef.current.agents.find((entry) => entry.agentId === agentId) ?? null,
prompt,
});
},
[client, dispatch, selectedChatAgentId, state.selectedAgentId, status],
);
const handleImproveCompanyBrief = useCallback(
async (brief: string) => {
setCompanyBuilderBusy(true);
setCompanyBuilderError(null);
try {
const improvedBrief = await runCompanyBuilderAiTask(
buildImproveCompanyBriefPrompt(brief),
"Improving your company brief with OpenClaw.",
);
setCompanyBuilderInput((current) => ({
...current,
businessDescription: brief,
improvedBrief,
}));
return improvedBrief;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to improve the company brief.";
setCompanyBuilderError(message);
throw error;
} finally {
setCompanyBuilderBusy(false);
setCompanyBuilderStatusLine(null);
}
},
[runCompanyBuilderAiTask],
);
const handleGenerateCompanyPlan = useCallback(
async (brief: string) => {
setCompanyBuilderBusy(true);
setCompanyBuilderError(null);
try {
const response = await runCompanyBuilderAiTask(
buildGenerateCompanyPlanPrompt(brief),
"Generating your AI company structure with OpenClaw.",
);
const parsedPlan = parseCompanyPlanFromAssistantText(response);
const nextInput: CompanyBuilderInput = {
businessDescription: companyBuilderInput.businessDescription,
improvedBrief: brief === companyBuilderInput.businessDescription ? "" : brief,
};
persistCompanyBuilderSnapshot(nextInput, parsedPlan);
return parsedPlan;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to generate the company plan.";
setCompanyBuilderError(message);
throw error;
} finally {
setCompanyBuilderBusy(false);
setCompanyBuilderStatusLine(null);
}
},
[companyBuilderInput.businessDescription, persistCompanyBuilderSnapshot, runCompanyBuilderAiTask],
);
const clearDeletedAgentUiState = useCallback((agentId: string) => {
setSelectedChatAgentId((current) => (current === agentId ? null : current));
setAgentEditorAgentId((current) => (current === agentId ? null : current));
@@ -1403,6 +1648,141 @@ export function OfficeScreen({
return next;
});
}, []);
const handleCreateCompanyFromPlan = useCallback(
async (params: { input: CompanyBuilderInput; plan: CompanyBuilderPlan }) => {
if (status !== "connected") {
const message = "Connect to OpenClaw before creating the company.";
setCompanyBuilderError(message);
throw new Error(message);
}
const existingAgentIds = stateRef.current.agents.map((entry) => entry.agentId);
const shouldSkipWorkspaceCleanupError = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Permission denied") ||
message.includes("OPENCLAW_GATEWAY_SSH_TARGET") ||
message.includes("Invalid gateway URL") ||
message.includes("Gateway URL is missing")
);
};
const logDeleteError = (message: string, error: unknown) => {
if (
message.startsWith("Failed to move agent workspace/state into trash.") &&
shouldSkipWorkspaceCleanupError(error)
) {
return;
}
console.error(message, error);
};
setCompanyBuilderBusy(true);
setCompanyBuilderError(null);
try {
await runCompanyBootstrapOperation({
input: params.input,
plan: params.plan,
existingAgentIds,
deleteExistingAgent: async (agentId) => {
try {
await deleteAgentViaStudio({
client,
agentId,
logError: logDeleteError,
});
} catch (error) {
if (!shouldSkipWorkspaceCleanupError(error)) {
throw error;
}
await deleteAgentRecordViaStudio({
client,
agentId,
logError: logDeleteError,
});
}
},
clearReusedAgentState: async (agentId) => {
try {
await trashAgentStateViaStudio({ agentId });
} catch (error) {
if (!shouldSkipWorkspaceCleanupError(error)) {
throw error;
}
}
},
renameAgent: async (agentId, name) => {
await renameGatewayAgent({ client, agentId, name });
dispatch({ type: "updateAgent", agentId, patch: { name } });
},
onExistingAgentDeleted: (agentId) => {
clearDeletedAgentUiState(agentId);
dispatch({ type: "removeAgent", agentId });
},
createAgent: async (name) => createGatewayAgent({ client, name }),
writeAgentFiles: async (agentId, files) => {
await writeGatewayAgentFiles({
client,
agentId,
files,
});
},
saveAvatar: (agentId) => {
handleAvatarProfileSave(
agentId,
createDefaultAgentAvatarProfile(randomUUID()),
);
},
loadAgents: () => loadAgents({ forceSettings: true }),
findAgentById: (agentId) => {
const liveAgent =
stateRef.current.agents.find((entry) => entry.agentId === agentId) ?? null;
if (!liveAgent?.sessionKey) return null;
return {
agentId: liveAgent.agentId,
sessionKey: liveAgent.sessionKey,
};
},
resetAgentSession: async (_agentId, sessionKey) => {
await client.call("sessions.reset", { key: sessionKey });
},
applyPermissions: async (agentId, sessionKey, commandMode) => {
await applyCreateAgentBootstrapPermissions({
client,
agentId,
sessionKey,
draft: buildCompanyRolePermissionsDraft(commandMode),
loadAgents: () => loadAgents({ forceSettings: true }),
});
},
persistSnapshot: persistCompanyBuilderSnapshot,
setOfficeTitle,
selectAgent: (agentId) => {
dispatch({ type: "selectAgent", agentId });
setSelectedChatAgentId(agentId);
},
setStatusLine: setCompanyBuilderStatusLine,
});
setCreatedCompanyName(params.plan.companyName);
setCompanyCreatedSignal((current) => current + 1);
setCompanyBuilderOpen(false);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to create the company.";
setCompanyBuilderError(message);
throw error;
} finally {
setCompanyBuilderBusy(false);
}
},
[
clearDeletedAgentUiState,
client,
dispatch,
handleAvatarProfileSave,
loadAgents,
persistCompanyBuilderSnapshot,
setOfficeTitle,
status,
],
);
const createAgentStatusLine = useMemo(() => {
if (!createAgentBlock) return null;
if (createAgentBlock.phase === "queued") {
@@ -2419,6 +2799,14 @@ export function OfficeScreen({
Boolean(immediateGymHoldByAgentId[agent.agentId]),
]),
);
const prevKeys = Object.keys(previous);
const nextKeys = Object.keys(next);
if (
prevKeys.length === nextKeys.length &&
nextKeys.every((key) => previous[key] === next[key])
) {
return previous;
}
return next;
});
}, [immediateGymHoldByAgentId, state.agents]);
@@ -3714,6 +4102,7 @@ export function OfficeScreen({
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
<RetroOffice3D
agents={allVisibleAgents}
officeCenterSignal={officeCameraCenterSignal}
animationState={officeAnimationState}
deskAssignmentByDeskUid={deskAssignmentByDeskUid}
githubReviewAgentId={githubReviewAgentId}
@@ -3858,6 +4247,15 @@ export function OfficeScreen({
>
Add Agent
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-2 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={() => {
handleOpenCompanyBuilder();
}}
>
Build Company
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-2 text-xs font-semibold tracking-[0.05em] text-foreground"
@@ -3893,6 +4291,7 @@ export function OfficeScreen({
onTabChange={setActiveSidebarTab}
onOpenMarketplace={() => setMarketplaceOpen(true)}
onAddAgent={handleOpenCreateAgentWizard}
onOpenCompanyBuilder={handleOpenCompanyBuilder}
inboxPanel={
<InboxPanel
agents={state.agents}
@@ -3954,6 +4353,7 @@ export function OfficeScreen({
{showOnboardingWizard ? (
<OnboardingWizard
key={companyCreatedSignal > 0 ? `onboarding-company-created-${companyCreatedSignal}` : "onboarding-default"}
gatewayConnected={status === "connected"}
agentCount={state.agents.length}
gatewayUrl={gatewayUrl}
@@ -3964,6 +4364,15 @@ export function OfficeScreen({
void connect();
}}
onComplete={handleCompleteOnboarding}
onOpenCompanyBuilder={handleOpenCompanyBuilder}
initialStep={companyCreatedSignal > 0 ? "complete" : "welcome"}
initialCompletedSteps={
companyCreatedSignal > 0
? ["welcome", "prerequisites", "connect", "agents", "company", "complete"]
: undefined
}
createdCompanyName={createdCompanyName}
companyCreated={companyCreatedSignal > 0}
connectionError={gatewayError}
connecting={status === "connecting"}
/>
@@ -4473,7 +4882,7 @@ export function OfficeScreen({
/>
) : null}
<AgentCreateWizardModal
key={createAgentWizardNonce}
key={`create-agent-${createAgentWizardNonce}`}
open={createAgentWizardOpen}
suggestedName={`Agent ${state.agents.length + 1}`}
busy={createAgentBusy}
@@ -4483,6 +4892,23 @@ export function OfficeScreen({
onCreateAgent={handleCreateAgentFromIdentity}
onFinishWizard={handleFinishCreateAgentAvatar}
/>
<CompanyBuilderModal
key={`company-builder-${companyBuilderNonce}`}
open={companyBuilderOpen}
connected={status === "connected"}
agentCount={state.agents.length}
plannerAgentName={plannerAgent?.name ?? null}
busy={companyBuilderBusy}
error={companyBuilderError}
statusLine={companyBuilderStatusLine}
initialInput={companyBuilderInput}
initialPlan={lastCompanyPlan}
onClose={handleCloseCompanyBuilder}
onClear={handleClearCompanyBuilder}
onImproveBrief={handleImproveCompanyBrief}
onGeneratePlan={handleGenerateCompanyPlan}
onCreateCompany={handleCreateCompanyFromPlan}
/>
</main>
);
}
@@ -50,8 +50,8 @@ export const AgentsStep = ({ agentCount, connected }: AgentsStepProps) => {
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
<Users className="h-5 w-5 text-emerald-400" />
<div className="flex items-center gap-3 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3">
<Users className="h-5 w-5 text-amber-300" />
<div>
<p className="text-sm font-semibold text-white">
{agentCount} agent{agentCount !== 1 ? "s" : ""} discovered
@@ -0,0 +1,83 @@
import { Building2, Sparkles, Users, Wand2 } from "lucide-react";
export type CompanyStepProps = {
connected: boolean;
agentCount: number;
onOpenCompanyBuilder: () => void;
};
export const CompanyStep = ({
connected,
agentCount,
onOpenCompanyBuilder,
}: CompanyStepProps) => {
const canOpenBuilder = connected && agentCount > 0;
return (
<div className="space-y-4">
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/15">
<Building2 className="h-5 w-5 text-amber-300" />
</div>
<div className="space-y-2">
<p className="text-sm font-semibold text-white">Bootstrap your company with AI</p>
<p className="text-xs leading-5 text-white/60">
Describe what your company does and Claw3D can turn that into a full org structure
with specialized agents, working files, and role instructions.
</p>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{[
{
icon: Sparkles,
title: "Improve the brief",
description: "Use your connected OpenClaw runtime to sharpen the company prompt.",
},
{
icon: Users,
title: "Generate the team",
description: "Get a practical org chart with roles, responsibilities, and handoffs.",
},
{
icon: Wand2,
title: "Create everything",
description: "Write agent files and create the team directly in OpenClaw.",
},
].map(({ icon: Icon, title, description }) => (
<div
key={title}
className="rounded-md border border-white/8 bg-white/[0.02] px-3 py-3"
>
<Icon className="h-4 w-4 text-white/70" />
<p className="mt-2 text-[11px] font-semibold text-white">{title}</p>
<p className="mt-1 text-[10px] leading-4 text-white/45">{description}</p>
</div>
))}
</div>
<div className="pt-4">
{canOpenBuilder ? (
<div className="flex justify-center">
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-xs font-semibold text-[#1a1206] transition-colors hover:bg-amber-400"
onClick={onOpenCompanyBuilder}
>
<Sparkles className="h-3.5 w-3.5" />
Open Company Builder
</button>
</div>
) : (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-100/80">
Connect to OpenClaw and keep at least one planning agent available to generate the
company with AI.
</div>
)}
</div>
</div>
);
};
@@ -1,31 +1,83 @@
"use client";
/**
* CompleteStep — Final wizard screen before entering the office.
*/
import { useEffect, useRef } from "react";
import confetti from "canvas-confetti";
import { Building2, Rocket } from "lucide-react";
export const CompleteStep = () => (
<div className="flex flex-col items-center justify-center gap-5 py-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/15">
<Rocket className="h-7 w-7 text-emerald-400" />
export const CompleteStep = ({
companyCreated = false,
companyName = null,
}: {
companyCreated?: boolean;
companyName?: string | null;
}) => {
const hasFiredConfettiRef = useRef(false);
useEffect(() => {
if (!companyCreated || hasFiredConfettiRef.current) return;
hasFiredConfettiRef.current = true;
const defaults = {
spread: 68,
startVelocity: 32,
ticks: 220,
gravity: 1.05,
zIndex: 100130,
colors: ["#67e8f9", "#fbbf24", "#fde047", "#f472b6", "#c4b5fd"],
};
void confetti({
...defaults,
particleCount: 90,
origin: { x: 0.5, y: 0.35 },
});
window.setTimeout(() => {
void confetti({
...defaults,
particleCount: 70,
angle: 60,
origin: { x: 0.15, y: 0.45 },
});
void confetti({
...defaults,
particleCount: 70,
angle: 120,
origin: { x: 0.85, y: 0.45 },
});
}, 180);
}, [companyCreated]);
return (
<div className="relative flex flex-col items-center justify-center gap-5 py-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-400/15">
<Rocket className="h-7 w-7 text-amber-300" />
</div>
<div className="space-y-2 text-center">
<p className="text-base font-semibold text-white">
Welcome to your AI office
{companyCreated
? `${companyName?.trim() || "Your company"} created successfully`
: "Welcome to your AI office"}
</p>
<p className="max-w-sm text-sm text-white/60">
Your gateway is connected and your agents are ready. Step inside and
explore the 3D workspace where your AI team operates.
{companyCreated
? `${companyName?.trim() || "Your company"} is ready. Your new team has been created in OpenClaw and placed into the office.`
: "Your gateway is connected and your agents are ready. Step inside and explore the 3D workspace where your AI team operates."}
</p>
</div>
<div className="w-full max-w-xs space-y-2">
<div className="flex items-center gap-2.5 rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-2.5">
<Building2 className="h-4 w-4 shrink-0 text-emerald-400" />
<Building2 className="h-4 w-4 shrink-0 text-amber-300" />
<div>
<p className="text-xs font-medium text-white">Explore the Office</p>
<p className="text-xs font-medium text-white">
{companyCreated ? "Meet Your New Team" : "Explore the Office"}
</p>
<p className="text-[10px] text-white/45">
Navigate rooms, watch agents, and interact
{companyCreated
? "Walk the office, inspect the new roles, and start delegating work."
: "Navigate rooms, watch agents, and interact"}
</p>
</div>
</div>
@@ -35,4 +87,5 @@ export const CompleteStep = () => (
You can always re-run onboarding from Studio settings.
</p>
</div>
);
);
};
@@ -5,7 +5,8 @@
* layout suited for the wizard modal.
*/
import { useState } from "react";
import { CheckCircle2, Eye, EyeOff, Loader2, Wifi, WifiOff } from "lucide-react";
import { CheckCircle2, Eye, EyeOff, Wifi, WifiOff } from "lucide-react";
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
export type ConnectStepProps = {
gatewayUrl: string;
@@ -33,8 +34,8 @@ export const ConnectStep = ({
if (connected) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20">
<CheckCircle2 className="h-6 w-6 text-emerald-400" />
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/20">
<CheckCircle2 className="h-6 w-6 text-amber-300" />
</div>
<p className="text-sm font-semibold text-white">Connected!</p>
<p className="text-xs text-white/60">
@@ -57,7 +58,7 @@ export const ConnectStep = ({
Gateway URL
</span>
<input
className="h-9 rounded-md border border-white/10 bg-white/5 px-3 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-emerald-500/50"
className="h-9 rounded-md border border-white/10 bg-white/5 px-3 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-amber-400/50"
type="text"
value={gatewayUrl}
onChange={(e) => onGatewayUrlChange(e.target.value)}
@@ -72,7 +73,7 @@ export const ConnectStep = ({
</span>
<div className="relative">
<input
className="h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 pr-9 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-emerald-500/50"
className="h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 pr-9 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-amber-400/50"
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => onTokenChange(e.target.value)}
@@ -96,13 +97,13 @@ export const ConnectStep = ({
<button
type="button"
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-emerald-600 px-4 text-xs font-semibold text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-amber-500 px-4 text-xs font-semibold text-[#1a1206] transition-colors hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onConnect}
disabled={connecting || !gatewayUrl.trim()}
>
{connecting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<RunningAvatarLoader size={16} trackWidth={32} inline />
Connecting
</>
) : (
@@ -19,6 +19,7 @@ import { WelcomeStep } from "@/features/onboarding/components/WelcomeStep";
import { PrerequisitesStep } from "@/features/onboarding/components/PrerequisitesStep";
import { ConnectStep } from "@/features/onboarding/components/ConnectStep";
import { AgentsStep } from "@/features/onboarding/components/AgentsStep";
import { CompanyStep } from "@/features/onboarding/components/CompanyStep";
import { CompleteStep } from "@/features/onboarding/components/CompleteStep";
export type OnboardingWizardProps = {
@@ -36,6 +37,12 @@ export type OnboardingWizardProps = {
onConnect: () => void;
/** Called when the user finishes or dismisses the wizard. */
onComplete: () => void;
/** Opens the reusable company builder. */
onOpenCompanyBuilder: () => void;
initialStep?: OnboardingStepId;
initialCompletedSteps?: OnboardingStepId[];
createdCompanyName?: string | null;
companyCreated?: boolean;
/** Connection error message, if any. */
connectionError: string | null;
/** Whether we're currently connecting. */
@@ -51,12 +58,17 @@ export const OnboardingWizard = ({
onTokenChange,
onConnect,
onComplete,
onOpenCompanyBuilder,
initialStep = "welcome",
initialCompletedSteps,
createdCompanyName = null,
companyCreated = false,
connectionError,
connecting,
}: OnboardingWizardProps) => {
const [currentStep, setCurrentStep] = useState<OnboardingStepId>("welcome");
const [currentStep, setCurrentStep] = useState<OnboardingStepId>(initialStep);
const [completedSteps, setCompletedSteps] = useState<Set<OnboardingStepId>>(
new Set(),
() => new Set(initialCompletedSteps ?? []),
);
const stepIndex = useMemo(() => getStepIndex(currentStep), [currentStep]);
@@ -116,8 +128,21 @@ export const OnboardingWizard = ({
);
case "agents":
return <AgentsStep agentCount={agentCount} connected={gatewayConnected} />;
case "company":
return (
<CompanyStep
connected={gatewayConnected}
agentCount={agentCount}
onOpenCompanyBuilder={onOpenCompanyBuilder}
/>
);
case "complete":
return <CompleteStep />;
return (
<CompleteStep
companyCreated={companyCreated}
companyName={createdCompanyName}
/>
);
default:
return null;
}
@@ -125,7 +150,7 @@ export const OnboardingWizard = ({
return (
<div className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="relative mx-4 flex w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0d1117] shadow-2xl">
<div className="relative mx-4 flex h-[min(92vh,640px)] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0d1117] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
@@ -154,9 +179,9 @@ export const OnboardingWizard = ({
key={step.id}
className={`h-1 flex-1 rounded-full transition-colors ${
idx <= stepIndex
? "bg-emerald-500"
? "bg-amber-400"
: completedSteps.has(step.id)
? "bg-emerald-500/40"
? "bg-amber-400/40"
: "bg-white/10"
}`}
/>
@@ -164,7 +189,7 @@ export const OnboardingWizard = ({
</div>
{/* Step content */}
<div className="min-h-[280px] px-6 py-5">{renderStepContent()}</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">{renderStepContent()}</div>
{/* Footer navigation */}
<div className="flex items-center justify-between border-t border-white/10 px-6 py-4">
@@ -187,7 +212,7 @@ export const OnboardingWizard = ({
{currentStep === "complete" ? (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-emerald-500"
className="inline-flex items-center gap-1.5 rounded-md bg-amber-500 px-4 py-2 text-xs font-semibold text-[#1a1206] transition-colors hover:bg-amber-400"
onClick={onComplete}
>
Enter Office
@@ -28,24 +28,24 @@ const prerequisites = [
] as const;
export const PrerequisitesStep = () => (
<div className="space-y-4">
<p className="text-sm text-white/70">
<div className="space-y-2.5">
<p className="text-[13px] leading-5 text-white/70">
Make sure you have these ready before connecting. If you already have
OpenClaw running, you can skip this step.
</p>
<div className="space-y-2.5">
<div className="space-y-1.5">
{prerequisites.map(({ label, detail, ...rest }) => (
<div
key={label}
className="flex gap-3 rounded-lg border border-white/8 bg-white/[0.02] px-3.5 py-3"
className="flex gap-2.5 rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2"
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-white/30" />
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-white/30" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-white">{label}</p>
<p className="mt-0.5 text-[11px] text-white/55">{detail}</p>
<p className="text-[11px] font-semibold text-white">{label}</p>
<p className="mt-0.5 text-[10px] leading-4 text-white/55">{detail}</p>
{"command" in rest ? (
<code className="mt-1.5 block rounded bg-black/40 px-2 py-1 font-mono text-[11px] text-emerald-400">
<code className="mt-1 block rounded bg-black/40 px-2 py-0.5 font-mono text-[10px] text-amber-300">
{rest.command}
</code>
) : null}
@@ -54,7 +54,7 @@ export const PrerequisitesStep = () => (
href={rest.link}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-[11px] text-emerald-400 hover:text-emerald-300"
className="mt-1 inline-flex items-center gap-1 text-[10px] leading-4 text-amber-300 hover:text-amber-200"
>
{rest.linkLabel ?? "Learn more"}
<ExternalLink className="h-3 w-3" />
@@ -65,13 +65,13 @@ export const PrerequisitesStep = () => (
))}
</div>
<p className="text-[11px] text-white/40">
<p className="text-[10px] leading-4 text-white/40">
Need help? Check{" "}
<a
href="https://docs.openclaw.ai"
target="_blank"
rel="noopener noreferrer"
className="text-emerald-400/70 hover:text-emerald-300"
className="text-amber-300/70 hover:text-amber-200"
>
docs.openclaw.ai
</a>{" "}
@@ -80,7 +80,7 @@ export const PrerequisitesStep = () => (
href="https://discord.com/invite/clawd"
target="_blank"
rel="noopener noreferrer"
className="text-emerald-400/70 hover:text-emerald-300"
className="text-amber-300/70 hover:text-amber-200"
>
join Discord
</a>
@@ -48,7 +48,7 @@ export const WelcomeStep = () => (
className="rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-3"
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 shrink-0 text-emerald-400" />
<Icon className="h-4 w-4 shrink-0 text-amber-300" />
<span className="text-xs font-semibold text-white">{title}</span>
</div>
<p className="mt-1.5 text-[11px] leading-snug text-white/55">
+7
View File
@@ -11,6 +11,7 @@ export type OnboardingStepId =
| "prerequisites"
| "connect"
| "agents"
| "company"
| "complete";
export type OnboardingStep = {
@@ -57,6 +58,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
description: "Meet your AI team",
skippable: true,
},
{
id: "company",
title: "Build Your Company",
description: "Generate your org structure",
skippable: true,
},
{
id: "complete",
title: "You're All Set",
@@ -2295,6 +2295,7 @@ const getAgentInitials = (name: string | null | undefined): string => {
export function RetroOffice3D({
agents,
officeCenterSignal = 0,
animationState = null,
readOnly = false,
storageNamespace = "default",
@@ -2365,6 +2366,7 @@ export function RetroOffice3D({
onJukeboxInteract,
}: {
agents: OfficeAgent[];
officeCenterSignal?: number;
animationState?: Pick<
OfficeAnimationState,
| "cleaningCues"
@@ -4958,6 +4960,21 @@ export function RetroOffice3D({
? DISTRICT_CAMERA_TARGET
: LOCAL_CAMERA_TARGET;
const cameraZoom = remoteOfficeEnabled ? DISTRICT_CAMERA_ZOOM : 56;
const lastOfficeCenterSignalRef = useRef(officeCenterSignal);
useEffect(() => {
cameraPresetRef.current = {
pos: CAM_POS,
target: cameraTarget,
zoom: cameraZoom,
};
}, [CAM_POS, cameraTarget, cameraZoom]);
useEffect(() => {
if (officeCenterSignal === lastOfficeCenterSignalRef.current) return;
lastOfficeCenterSignalRef.current = officeCenterSignal;
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
}, [officeCenterSignal]);
return (
<div className="relative w-full h-full bg-[#1a1008] font-mono text-white overflow-hidden">
+31 -9
View File
@@ -14,6 +14,16 @@ import type {
} from "@/features/retro-office/core/types";
import { AgentModelProps } from "@/features/retro-office/objects/types";
const MAX_NAMEPLATE_TEXT_LENGTH = 10;
const formatAgentNameplateText = (value: string): string => {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) return "";
if (normalized.length <= MAX_NAMEPLATE_TEXT_LENGTH) return normalized;
const [firstName] = normalized.split(" ");
return firstName || normalized;
};
export const AgentModel = memo(function AgentModel({
agentId,
name,
@@ -118,7 +128,9 @@ export const AgentModel = memo(function AgentModel({
agent.state === "walking"
? Math.sin(frameValue * WALK_ANIM_SPEED) * 0.04
: isDancing
? 0.03 + Math.abs(Math.sin(agent.frame * 0.22 + (agent.phaseOffset ?? 0))) * 0.05
? 0.03 +
Math.abs(Math.sin(agent.frame * 0.22 + (agent.phaseOffset ?? 0))) *
0.05
: isWorkout
? workoutStyle === "stretch"
? 0.012 + Math.abs(workoutPhase) * 0.018
@@ -142,8 +154,10 @@ export const AgentModel = memo(function AgentModel({
} else if (agent.state === "walking") {
leftArmRef.current.rotation.x = walkPhase * 0.4;
} else if (isDancing) {
leftArmRef.current.rotation.x = -0.8 + Math.sin(agent.frame * 0.22) * 0.9;
leftArmRef.current.rotation.z = -0.45 + Math.cos(agent.frame * 0.16) * 0.18;
leftArmRef.current.rotation.x =
-0.8 + Math.sin(agent.frame * 0.22) * 0.9;
leftArmRef.current.rotation.z =
-0.45 + Math.cos(agent.frame * 0.16) * 0.18;
leftArmRef.current.rotation.y = -0.08;
groupRef.current.rotation.z = Math.sin(agent.frame * 0.12) * 0.08;
} else if (isWorkout) {
@@ -199,8 +213,10 @@ export const AgentModel = memo(function AgentModel({
} else if (agent.state === "walking") {
rightArmRef.current.rotation.x = -walkPhase * 0.4;
} else if (isDancing) {
rightArmRef.current.rotation.x = -0.8 - Math.sin(agent.frame * 0.22) * 0.9;
rightArmRef.current.rotation.z = 0.45 - Math.cos(agent.frame * 0.16) * 0.18;
rightArmRef.current.rotation.x =
-0.8 - Math.sin(agent.frame * 0.22) * 0.9;
rightArmRef.current.rotation.z =
0.45 - Math.cos(agent.frame * 0.16) * 0.18;
rightArmRef.current.rotation.y = 0.08;
groupRef.current.rotation.z = Math.sin(agent.frame * 0.12) * 0.08;
} else if (isWorkout) {
@@ -287,7 +303,10 @@ export const AgentModel = memo(function AgentModel({
}
const working =
agent.state === "sitting" || isWorkout || isDancing || agent.status === "working";
agent.state === "sitting" ||
isWorkout ||
isDancing ||
agent.status === "working";
const isError = agent.status === "error";
const isAway = agent.state === "away";
@@ -620,6 +639,9 @@ export const AgentModel = memo(function AgentModel({
: "#8dc4ff"
: "transparent";
const speechBubbleBorderInset = activeSpeechBubble ? 0.03 : 0;
const nameplateText = name ? formatAgentNameplateText(name) : "";
const nameplateFontSize =
nameplateText.length > 9 ? 0.118 : nameplateText.length > 7 ? 0.13 : 0.144;
return (
<group
@@ -1045,7 +1067,7 @@ export const AgentModel = memo(function AgentModel({
depthWrite={false}
/>
</mesh>
{!activeSpeechBubble && name ? (
{!activeSpeechBubble && nameplateText ? (
<Billboard position={[0, 1.05, 0]}>
<mesh position={[0, 0, -0.001]}>
<planeGeometry args={[0.82, 0.24]} />
@@ -1061,14 +1083,14 @@ export const AgentModel = memo(function AgentModel({
</mesh>
<Text
position={[-0.02, 0, 0.001]}
fontSize={0.16}
fontSize={nameplateFontSize}
color="#e8dfc0"
anchorX="center"
anchorY="middle"
maxWidth={0.68}
font={undefined}
>
{name}
{nameplateText}
</Text>
</Billboard>
) : null}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
import { useJukeboxStore } from "../store";
import {
startSpotifyAuth,
@@ -302,7 +303,7 @@ function PlayerView() {
</div>
{isLoadingPlayer && !track ? (
<div className="flex items-center gap-3 text-slate-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-slate-600 border-t-cyan-500" />
<RunningAvatarLoader size={16} trackWidth={32} inline />
<span className="text-sm">Loading player</span>
</div>
) : track ? (
@@ -374,7 +375,7 @@ function PlayerView() {
/>
{isSearching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-slate-600 border-t-cyan-500" />
<RunningAvatarLoader size={14} trackWidth={28} />
</div>
)}
</div>
+68
View File
@@ -70,6 +70,13 @@ export type StudioOfficePreference = {
remoteOfficePresenceUrl: string;
remoteOfficeGatewayUrl: string;
remoteOfficeToken: string;
companyName: string;
companyPrompt: string;
companyImprovedBrief: string;
companySummary: string;
companyGeneratedAt: string | null;
companyRoleTitles: string[];
companyPlanJson: string;
};
export type StudioOfficePreferencePublic = {
@@ -80,6 +87,13 @@ export type StudioOfficePreferencePublic = {
remoteOfficePresenceUrl: string;
remoteOfficeGatewayUrl: string;
remoteOfficeTokenConfigured: boolean;
companyName: string;
companyPrompt: string;
companyImprovedBrief: string;
companySummary: string;
companyGeneratedAt: string | null;
companyRoleTitles: string[];
companyPlanJson: string;
};
export type StudioOfficePreferencePatch = {
@@ -90,6 +104,13 @@ export type StudioOfficePreferencePatch = {
remoteOfficePresenceUrl?: string | null;
remoteOfficeGatewayUrl?: string | null;
remoteOfficeToken?: string | null;
companyName?: string | null;
companyPrompt?: string | null;
companyImprovedBrief?: string | null;
companySummary?: string | null;
companyGeneratedAt?: string | null;
companyRoleTitles?: string[] | null;
companyPlanJson?: string | null;
};
export type StudioDeskAssignments = Record<string, string>;
@@ -346,6 +367,17 @@ const normalizeRemoteOfficeGatewayUrl = (value: unknown) => {
}
};
const normalizeCompanyField = (value: unknown) => coerceString(value).slice(0, 10_000);
const normalizeCompanyRoleTitles = (value: unknown, fallback: string[] = []) => {
if (!Array.isArray(value)) return fallback;
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.slice(0, 32);
};
export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
title: DEFAULT_OFFICE_TITLE,
remoteOfficeEnabled: false,
@@ -354,6 +386,13 @@ export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
remoteOfficePresenceUrl: "",
remoteOfficeGatewayUrl: "",
remoteOfficeToken: "",
companyName: "",
companyPrompt: "",
companyImprovedBrief: "",
companySummary: "",
companyGeneratedAt: null,
companyRoleTitles: [],
companyPlanJson: "",
});
export const defaultStudioOfficePreferencePublic =
@@ -365,6 +404,13 @@ export const defaultStudioOfficePreferencePublic =
remoteOfficePresenceUrl: "",
remoteOfficeGatewayUrl: "",
remoteOfficeTokenConfigured: false,
companyName: "",
companyPrompt: "",
companyImprovedBrief: "",
companySummary: "",
companyGeneratedAt: null,
companyRoleTitles: [],
companyPlanJson: "",
});
export const sanitizeStudioOfficePreference = (
@@ -377,6 +423,13 @@ export const sanitizeStudioOfficePreference = (
remoteOfficePresenceUrl: value.remoteOfficePresenceUrl,
remoteOfficeGatewayUrl: value.remoteOfficeGatewayUrl,
remoteOfficeTokenConfigured: value.remoteOfficeToken.length > 0,
companyName: value.companyName,
companyPrompt: value.companyPrompt,
companyImprovedBrief: value.companyImprovedBrief,
companySummary: value.companySummary,
companyGeneratedAt: value.companyGeneratedAt,
companyRoleTitles: value.companyRoleTitles,
companyPlanJson: value.companyPlanJson,
});
const normalizeStandupScheduleConfig = (
@@ -669,6 +722,21 @@ const normalizeOfficePreference = (
value.remoteOfficeToken === null
? ""
: coerceString(value.remoteOfficeToken) || fallback.remoteOfficeToken,
companyName: normalizeCompanyField(value.companyName ?? fallback.companyName),
companyPrompt: normalizeCompanyField(value.companyPrompt ?? fallback.companyPrompt),
companyImprovedBrief: normalizeCompanyField(
value.companyImprovedBrief ?? fallback.companyImprovedBrief
),
companySummary: normalizeCompanyField(value.companySummary ?? fallback.companySummary),
companyGeneratedAt: normalizeOptionalIsoString(
value.companyGeneratedAt,
fallback.companyGeneratedAt
),
companyRoleTitles: normalizeCompanyRoleTitles(
value.companyRoleTitles,
fallback.companyRoleTitles
),
companyPlanJson: normalizeCompanyField(value.companyPlanJson ?? fallback.companyPlanJson),
};
};
@@ -0,0 +1,310 @@
import { describe, expect, it, vi } from "vitest";
import { runCompanyBootstrapOperation } from "@/features/company-builder/operations/companyBootstrapOperation";
import { parseCompanyPlanFromAssistantText } from "@/features/company-builder/planning";
describe("runCompanyBootstrapOperation", () => {
it("creates agents, reloads the fleet, applies permissions, and persists the snapshot", async () => {
const plan = parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Launch Lab",
summary: "A product org with engineering and QA.",
sharedRules: [],
plannerNotes: [],
roles: [
{
title: "Engineer",
purpose: "Builds features.",
soul: "Fast but careful.",
responsibilities: ["Ship features"],
collaborators: ["QA Lead"],
tools: ["repo"],
heartbeat: ["Check PRs"],
emoji: "🛠",
creature: "otter",
vibe: "focused",
userContext: "",
commandMode: "auto",
},
{
title: "QA Lead",
purpose: "Protects quality.",
soul: "Detail-oriented.",
responsibilities: ["Review releases"],
collaborators: ["Engineer"],
tools: ["test plans"],
heartbeat: ["Review regressions"],
emoji: "🧪",
creature: "owl",
vibe: "precise",
userContext: "",
commandMode: "ask",
},
],
}),
);
const createAgent = vi
.fn<(name: string) => Promise<{ id: string }>>()
.mockResolvedValueOnce({ id: "engineer" })
.mockResolvedValueOnce({ id: "qa-lead" });
const writeAgentFiles = vi.fn<(agentId: string, files: Record<string, string>) => Promise<void>>()
.mockResolvedValue(undefined);
const saveAvatar = vi.fn<(agentId: string) => void>();
const loadAgents = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const findAgentById = vi.fn((agentId: string) => ({
agentId,
sessionKey: `agent:${agentId}:main`,
}));
const applyPermissions = vi
.fn<(agentId: string, sessionKey: string, commandMode: "off" | "ask" | "auto") => Promise<void>>()
.mockResolvedValue(undefined);
const resetAgentSession = vi
.fn<(agentId: string, sessionKey: string) => Promise<void>>()
.mockResolvedValue(undefined);
const persistSnapshot = vi.fn<(input: { businessDescription: string; improvedBrief: string }, planArg: typeof plan) => void>();
const setOfficeTitle = vi.fn<(title: string) => void>();
const selectAgent = vi.fn<(agentId: string) => void>();
const setStatusLine = vi.fn<(value: string | null) => void>();
const createdIds = await runCompanyBootstrapOperation({
input: {
businessDescription: "Build a launch team.",
improvedBrief: "Build a launch team with engineering and QA.",
},
plan,
existingAgentIds: [],
createAgent,
writeAgentFiles,
saveAvatar,
loadAgents,
findAgentById,
resetAgentSession,
applyPermissions,
persistSnapshot,
setOfficeTitle,
selectAgent,
setStatusLine,
});
expect(createdIds).toEqual(["engineer", "qa-lead"]);
expect(createAgent).toHaveBeenCalledTimes(2);
expect(writeAgentFiles).toHaveBeenCalledTimes(2);
expect(saveAvatar).toHaveBeenCalledTimes(2);
expect(loadAgents).toHaveBeenCalledTimes(1);
expect(applyPermissions).toHaveBeenNthCalledWith(
1,
"engineer",
"agent:engineer:main",
"auto",
);
expect(applyPermissions).toHaveBeenNthCalledWith(
2,
"qa-lead",
"agent:qa-lead:main",
"ask",
);
expect(setOfficeTitle).toHaveBeenCalledWith("Launch Lab HQ");
expect(selectAgent).toHaveBeenCalledWith("engineer");
expect(persistSnapshot).toHaveBeenCalledOnce();
expect(resetAgentSession).not.toHaveBeenCalled();
expect(setStatusLine).toHaveBeenLastCalledWith(null);
});
it("deletes existing agents before creating the new company", async () => {
const plan = parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Fresh Start Studio",
summary: "A brand new company plan.",
sharedRules: [],
plannerNotes: [],
roles: [
{
title: "Creative Lead",
purpose: "Leads delivery.",
soul: "Bold.",
responsibilities: ["Direct projects"],
collaborators: [],
tools: [],
heartbeat: [],
emoji: "🎨",
creature: "fox",
vibe: "sharp",
userContext: "",
commandMode: "ask",
},
],
}),
);
const order: string[] = [];
await runCompanyBootstrapOperation({
input: {
businessDescription: "Replace my current team.",
improvedBrief: "Replace my current team with a new creative studio.",
},
plan,
existingAgentIds: ["old-a", "old-b"],
deleteExistingAgent: async (agentId) => {
order.push(`delete:${agentId}`);
},
clearReusedAgentState: async () => {
order.push("clear-reused");
},
onExistingAgentDeleted: (agentId) => {
order.push(`deleted:${agentId}`);
},
createAgent: async (name) => {
order.push(`create:${name}`);
return { id: "creative-lead" };
},
writeAgentFiles: async () => {
order.push("write");
},
saveAvatar: () => {
order.push("avatar");
},
loadAgents: async () => {
order.push("load");
},
findAgentById: () => ({
agentId: "creative-lead",
sessionKey: "agent:creative-lead:main",
}),
resetAgentSession: async () => {
order.push("reset");
},
applyPermissions: async () => {
order.push("permissions");
},
persistSnapshot: () => {
order.push("persist");
},
setOfficeTitle: () => {
order.push("title");
},
selectAgent: () => {
order.push("select");
},
setStatusLine: () => {},
});
expect(order.slice(0, 4)).toEqual([
"delete:old-a",
"deleted:old-a",
"delete:old-b",
"deleted:old-b",
]);
expect(order).not.toContain("clear-reused");
expect(order).toContain("create:CreativeLead");
expect(order).not.toContain("reset");
});
it("reuses main as the first company role instead of deleting it", async () => {
const plan = parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Fresh Start Studio",
summary: "A brand new company plan.",
sharedRules: [],
plannerNotes: [],
roles: [
{
title: "Creative Lead",
purpose: "Leads delivery.",
soul: "Bold.",
responsibilities: ["Direct projects"],
collaborators: [],
tools: [],
heartbeat: [],
emoji: "🎨",
creature: "fox",
vibe: "sharp",
userContext: "",
commandMode: "ask",
},
{
title: "Operator",
purpose: "Runs operations.",
soul: "Calm.",
responsibilities: ["Keep work moving"],
collaborators: [],
tools: [],
heartbeat: [],
emoji: "⚙️",
creature: "owl",
vibe: "steady",
userContext: "",
commandMode: "auto",
},
],
}),
);
const order: string[] = [];
const createdIds = await runCompanyBootstrapOperation({
input: {
businessDescription: "Replace my current team.",
improvedBrief: "Replace my current team with a new creative studio.",
},
plan,
existingAgentIds: ["main", "old-b"],
deleteExistingAgent: async (agentId) => {
order.push(`delete:${agentId}`);
},
clearReusedAgentState: async (agentId) => {
order.push(`clear-reused:${agentId}`);
},
renameAgent: async (agentId, name) => {
order.push(`rename:${agentId}:${name}`);
},
onExistingAgentDeleted: (agentId) => {
order.push(`deleted:${agentId}`);
},
createAgent: async (name) => {
order.push(`create:${name}`);
return { id: "operator" };
},
writeAgentFiles: async (agentId) => {
order.push(`write:${agentId}`);
},
saveAvatar: (agentId) => {
order.push(`avatar:${agentId}`);
},
loadAgents: async () => {
order.push("load");
},
findAgentById: (agentId) => ({
agentId,
sessionKey: `agent:${agentId}:main`,
}),
resetAgentSession: async (agentId, sessionKey) => {
order.push(`reset:${agentId}:${sessionKey}`);
},
applyPermissions: async (agentId, _sessionKey, commandMode) => {
order.push(`permissions:${agentId}:${commandMode}`);
},
persistSnapshot: () => {
order.push("persist");
},
setOfficeTitle: () => {
order.push("title");
},
selectAgent: (agentId) => {
order.push(`select:${agentId}`);
},
setStatusLine: () => {},
});
expect(order).not.toContain("delete:main");
expect(order.slice(0, 2)).toEqual(["delete:old-b", "deleted:old-b"]);
expect(order).toContain("clear-reused:main");
expect(order).toContain("rename:main:CreativeLead");
expect(order).toContain("write:main");
expect(order).toContain("avatar:main");
expect(order).toContain("create:Operator");
expect(order).toContain("permissions:main:ask");
expect(order).toContain("permissions:operator:auto");
expect(order).toContain("reset:main:agent:main:main");
expect(order).toContain("select:main");
expect(createdIds).toEqual(["main", "operator"]);
});
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import {
buildCompanyAgentBlueprints,
buildStoredCompanySnapshot,
parseCompanyPlanFromAssistantText,
} from "@/features/company-builder/planning";
describe("parseCompanyPlanFromAssistantText", () => {
it("parses fenced JSON into a normalized company plan", () => {
const plan = parseCompanyPlanFromAssistantText(`
\`\`\`json
{
"companyName": "Orbit Threads",
"summary": "A silly but practical clothing brand.",
"sharedRules": ["Keep handoffs explicit"],
"plannerNotes": ["Stay brand-consistent"],
"roles": [
{
"name": "StoreManager",
"purpose": "Owns sales and operations.",
"soul": "Decisive and upbeat.",
"responsibilities": ["Run the shop floor", "Track promotions"],
"collaborators": ["Social Media Stylist"],
"tools": ["CRM", "inventory dashboard"],
"heartbeat": ["Check sales every morning"],
"emoji": "🧵",
"creature": "fox",
"vibe": "sharp and stylish",
"userContext": "The founder wants weekly launches.",
"commandMode": "ask"
}
]
}
\`\`\`
`);
expect(plan.companyName).toBe("Orbit Threads");
expect(plan.roles).toHaveLength(1);
expect(plan.roles[0]?.title).toBe("StoreManager");
expect(plan.roles[0]?.responsibilities).toEqual([
"Run the shop floor",
"Track promotions",
]);
});
it("normalizes multi-word role names into one word", () => {
const plan = parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Orbit Threads",
summary: "A silly but practical clothing brand.",
roles: [
{
title: "Pipeline Orbit Captain",
purpose: "Owns delivery.",
soul: "Focused.",
},
],
}),
);
expect(plan.roles[0]?.title).toBe("PipelineOrbitCapta");
});
it("dedupes repeated generated role names", () => {
const plan = parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Orbit Threads",
summary: "A silly but practical clothing brand.",
roles: [
{
name: "Builder",
purpose: "Owns delivery.",
soul: "Focused.",
},
{
name: "Builder",
purpose: "Owns quality.",
soul: "Sharp.",
},
{
name: "Builder",
purpose: "Owns support.",
soul: "Helpful.",
},
],
}),
);
expect(plan.roles.map((role) => role.title)).toEqual(["Builder", "Builder2", "Builder3"]);
expect(plan.roles.map((role) => role.id)).toEqual(["builder", "builder-2", "builder-3"]);
});
it("throws when no roles are returned", () => {
expect(() =>
parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Empty Co",
summary: "Missing roles.",
roles: [],
}),
),
).toThrow("did not return any company roles");
});
});
describe("buildCompanyAgentBlueprints", () => {
it("maps roles into agent file blueprints", () => {
const plan = parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Bug Bistro",
summary: "A software team with strong handoffs.",
sharedRules: ["Document decisions"],
plannerNotes: ["Stay practical"],
roles: [
{
name: "Developer",
purpose: "Ships code.",
soul: "Calm and methodical.",
responsibilities: ["Implement features"],
collaborators: ["QA Lead"],
tools: ["repo", "tests"],
heartbeat: ["Check PR queue"],
emoji: "🛠",
creature: "otter",
vibe: "focused",
userContext: "The company ships weekly.",
commandMode: "auto",
},
{
name: "QaLead",
purpose: "Protects quality.",
soul: "Skeptical and helpful.",
responsibilities: ["Review releases"],
collaborators: ["Developer"],
tools: ["test plans"],
heartbeat: ["Review risk daily"],
emoji: "🧪",
creature: "owl",
vibe: "precise",
userContext: "The company ships weekly.",
commandMode: "ask",
},
],
}),
);
const blueprints = buildCompanyAgentBlueprints(plan);
expect(blueprints).toHaveLength(2);
expect(blueprints[0]?.files["AGENTS.md"]).toContain("Bug Bistro");
expect(blueprints[0]?.files["AGENTS.md"]).toContain("Ships code.");
expect(blueprints[0]?.files["SOUL.md"]).toContain("Calm and methodical.");
expect(blueprints[1]?.files["HEARTBEAT.md"]).toContain("Review risk daily.");
});
});
describe("buildStoredCompanySnapshot", () => {
it("stores the serialized plan for reopening later", () => {
const plan = parseCompanyPlanFromAssistantText(
JSON.stringify({
companyName: "Planner Corp",
summary: "A generated company.",
sharedRules: [],
plannerNotes: [],
roles: [
{
title: "Planner",
purpose: "Plans work.",
soul: "Structured.",
responsibilities: ["Prioritize tasks"],
collaborators: [],
tools: [],
heartbeat: [],
emoji: "📋",
creature: "cat",
vibe: "organized",
userContext: "",
commandMode: "ask",
},
],
}),
);
const snapshot = buildStoredCompanySnapshot({
prompt: "Build me a software company.",
improvedBrief: "Build a software company with planning and delivery.",
plan,
now: () => "2026-03-26T00:00:00.000Z",
});
expect(snapshot.companyName).toBe("Planner Corp");
expect(snapshot.roleTitles).toEqual(["Planner"]);
expect(JSON.parse(snapshot.planJson)).toMatchObject({
companyName: "Planner Corp",
});
});
});
+7
View File
@@ -42,6 +42,13 @@ describe("getStepIndex", () => {
const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect");
expect(getStepIndex("connect")).toBe(idx);
});
it("includes the company step before completion", () => {
const companyIndex = getStepIndex("company");
const completeIndex = getStepIndex("complete");
expect(companyIndex).toBeGreaterThan(getStepIndex("agents"));
expect(companyIndex).toBe(completeIndex - 1);
});
});
describe("getNextStep", () => {