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
@@ -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>
);
}