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:
Generated
+19
@@ -13,6 +13,7 @@
|
|||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@vercel/otel": "^2.1.0",
|
"@vercel/otel": "^2.1.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -3110,6 +3112,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -4561,6 +4570,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/ccount": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@vercel/otel": "^2.1.0",
|
"@vercel/otel": "^2.1.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
import { AgentStoreProvider } from "@/features/agents/state/store";
|
import { AgentStoreProvider } from "@/features/agents/state/store";
|
||||||
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
|
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
|
||||||
|
|
||||||
@@ -18,10 +19,12 @@ function OfficeLoadingFallback() {
|
|||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<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" />
|
<RunningAvatarLoader
|
||||||
<p className="font-mono text-[11px] tracking-[0.08em] text-muted-foreground">
|
size={28}
|
||||||
Loading…
|
trackWidth={76}
|
||||||
</p>
|
label="Loading..."
|
||||||
|
labelClassName="text-muted-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type AgentAvatarProfile,
|
type AgentAvatarProfile,
|
||||||
createDefaultAgentAvatarProfile,
|
createDefaultAgentAvatarProfile,
|
||||||
} from "@/lib/avatars/profile";
|
} from "@/lib/avatars/profile";
|
||||||
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
|
|
||||||
const PreviewFigure = ({
|
const PreviewFigure = ({
|
||||||
profile,
|
profile,
|
||||||
@@ -313,10 +314,7 @@ export const AgentAvatarPreview3D = ({
|
|||||||
<div className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
{!isReady ? (
|
{!isReady ? (
|
||||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#070b16] text-white/70">
|
<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" />
|
<RunningAvatarLoader size={26} trackWidth={72} label="Loading avatar..." />
|
||||||
<div className="font-mono text-[11px] tracking-[0.08em] text-white/55">
|
|
||||||
Loading avatar...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Canvas key={profileKey} camera={{ position: [0, 0.7, 2.5], fov: 34 }}>
|
<Canvas key={profileKey} camera={{ position: [0, 0.7, 2.5], fov: 34 }}>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||||
import type { StudioGatewaySettings } from "@/lib/studio/settings";
|
import type { StudioGatewaySettings } from "@/lib/studio/settings";
|
||||||
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
|
|
||||||
type GatewayConnectScreenProps = {
|
type GatewayConnectScreenProps = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@@ -168,7 +169,7 @@ export const GatewayConnectScreen = ({
|
|||||||
|
|
||||||
{status === "connecting" ? (
|
{status === "connecting" ? (
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<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…
|
Connecting…
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -192,7 +193,7 @@ export const GatewayConnectScreen = ({
|
|||||||
<div className="ui-card px-4 py-2">
|
<div className="ui-card px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{status === "connecting" ? (
|
{status === "connecting" ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--status-connecting-fg)]" />
|
<RunningAvatarLoader size={18} trackWidth={36} inline />
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`h-2.5 w-2.5 ${statusDotClass}`}
|
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;
|
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' 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' 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.");
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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;
|
onTabChange: (tab: HQSidebarTab) => void;
|
||||||
onOpenMarketplace: () => void;
|
onOpenMarketplace: () => void;
|
||||||
onAddAgent?: () => void;
|
onAddAgent?: () => void;
|
||||||
|
onOpenCompanyBuilder?: () => void;
|
||||||
inboxPanel: ReactNode;
|
inboxPanel: ReactNode;
|
||||||
historyPanel: ReactNode;
|
historyPanel: ReactNode;
|
||||||
playbooksPanel: ReactNode;
|
playbooksPanel: ReactNode;
|
||||||
@@ -39,6 +40,7 @@ export function HQSidebar({
|
|||||||
onTabChange,
|
onTabChange,
|
||||||
onOpenMarketplace,
|
onOpenMarketplace,
|
||||||
onAddAgent,
|
onAddAgent,
|
||||||
|
onOpenCompanyBuilder,
|
||||||
inboxPanel,
|
inboxPanel,
|
||||||
historyPanel,
|
historyPanel,
|
||||||
playbooksPanel,
|
playbooksPanel,
|
||||||
@@ -125,6 +127,15 @@ export function HQSidebar({
|
|||||||
Add Agent
|
Add Agent
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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 ? (
|
{railOnly ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Check, Landmark, Lock, RefreshCw, Wallet } from "lucide-react";
|
import { Check, Landmark, Lock, RefreshCw, Wallet } from "lucide-react";
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
import {
|
import {
|
||||||
type OfficeUsageAnalyticsParams,
|
type OfficeUsageAnalyticsParams,
|
||||||
useOfficeUsageAnalyticsViewModel,
|
useOfficeUsageAnalyticsViewModel,
|
||||||
@@ -271,7 +272,11 @@ export function AtmImmersiveScreen(props: OfficeUsageAnalyticsParams) {
|
|||||||
onClick={() => void usage.refresh()}
|
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]"
|
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
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ShieldX,
|
ShieldX,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GitHubDashboardResponse,
|
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"
|
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
|
{loading || detailLoading ? (
|
||||||
className={`h-3.5 w-3.5 ${loading || detailLoading ? "animate-spin" : ""}`}
|
<RunningAvatarLoader size={16} trackWidth={32} inline />
|
||||||
/>
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
{dashboard?.viewerLogin ? (
|
{dashboard?.viewerLogin ? (
|
||||||
@@ -465,9 +468,7 @@ export function GithubImmersiveScreen({
|
|||||||
{isInitialLoading ? (
|
{isInitialLoading ? (
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center px-8 py-10">
|
<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 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">
|
<RunningAvatarLoader size={40} trackWidth={104} />
|
||||||
<RefreshCw className="h-6 w-6 animate-spin text-cyan-100" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 text-[11px] uppercase tracking-[0.28em] text-cyan-100/55">
|
<div className="mt-5 text-[11px] uppercase tracking-[0.28em] text-cyan-100/55">
|
||||||
Loading GitHub
|
Loading GitHub
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ import {
|
|||||||
createStudioSettingsCoordinator,
|
createStudioSettingsCoordinator,
|
||||||
type StudioSettingsLoadOptions,
|
type StudioSettingsLoadOptions,
|
||||||
} from "@/lib/studio/coordinator";
|
} from "@/lib/studio/coordinator";
|
||||||
import { resolveDeskAssignments } from "@/lib/studio/settings";
|
import {
|
||||||
|
resolveDeskAssignments,
|
||||||
|
resolveOfficePreferencePublic,
|
||||||
|
} from "@/lib/studio/settings";
|
||||||
import {
|
import {
|
||||||
createGatewayAgent,
|
createGatewayAgent,
|
||||||
renameGatewayAgent,
|
renameGatewayAgent,
|
||||||
@@ -69,7 +72,11 @@ import {
|
|||||||
applyCreateAgentBootstrapPermissions,
|
applyCreateAgentBootstrapPermissions,
|
||||||
CREATE_AGENT_DEFAULT_PERMISSIONS,
|
CREATE_AGENT_DEFAULT_PERMISSIONS,
|
||||||
} from "@/features/agents/operations/createAgentBootstrapOperation";
|
} 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 { planAgentSettingsMutation } from "@/features/agents/operations/agentSettingsMutationWorkflow";
|
||||||
import {
|
import {
|
||||||
executeHistorySyncCommands,
|
executeHistorySyncCommands,
|
||||||
@@ -95,7 +102,10 @@ import {
|
|||||||
type GatewayModelChoice,
|
type GatewayModelChoice,
|
||||||
} from "@/lib/gateway/models";
|
} from "@/lib/gateway/models";
|
||||||
import type { GatewayModelPolicySnapshot } 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 {
|
import {
|
||||||
createEmptyPersonalityDraft,
|
createEmptyPersonalityDraft,
|
||||||
serializePersonalityFiles,
|
serializePersonalityFiles,
|
||||||
@@ -107,6 +117,23 @@ import {
|
|||||||
HQSidebar,
|
HQSidebar,
|
||||||
type HQSidebarTab,
|
type HQSidebarTab,
|
||||||
} from "@/features/office/components/HQSidebar";
|
} 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 { AnalyticsPanel } from "@/features/office/components/panels/AnalyticsPanel";
|
||||||
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
||||||
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
||||||
@@ -902,6 +929,19 @@ export function OfficeScreen({
|
|||||||
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(
|
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(
|
||||||
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] =
|
const [createAgentBlock, setCreateAgentBlock] =
|
||||||
useState<CreateAgentBlockState | null>(null);
|
useState<CreateAgentBlockState | null>(null);
|
||||||
const [deleteAgentBlock, setDeleteAgentBlock] =
|
const [deleteAgentBlock, setDeleteAgentBlock] =
|
||||||
@@ -1044,11 +1084,16 @@ export function OfficeScreen({
|
|||||||
const showOnboardingWizard = showOnboarding || forceShowOnboarding;
|
const showOnboardingWizard = showOnboarding || forceShowOnboarding;
|
||||||
const handleOpenOnboarding = useCallback(() => {
|
const handleOpenOnboarding = useCallback(() => {
|
||||||
resetOnboarding();
|
resetOnboarding();
|
||||||
|
setCompanyCreatedSignal(0);
|
||||||
|
setCreatedCompanyName(null);
|
||||||
setForceShowOnboarding(true);
|
setForceShowOnboarding(true);
|
||||||
}, [resetOnboarding]);
|
}, [resetOnboarding]);
|
||||||
const handleCompleteOnboarding = useCallback(() => {
|
const handleCompleteOnboarding = useCallback(() => {
|
||||||
completeOnboarding();
|
completeOnboarding();
|
||||||
|
setCompanyCreatedSignal(0);
|
||||||
|
setCreatedCompanyName(null);
|
||||||
setForceShowOnboarding(false);
|
setForceShowOnboarding(false);
|
||||||
|
setOfficeCameraCenterSignal((current) => current + 1);
|
||||||
}, [completeOnboarding]);
|
}, [completeOnboarding]);
|
||||||
|
|
||||||
const handleAvatarProfileSave = useCallback(
|
const handleAvatarProfileSave = useCallback(
|
||||||
@@ -1212,6 +1257,49 @@ export function OfficeScreen({
|
|||||||
};
|
};
|
||||||
}, [gatewayUrl, loadStudioSettings]);
|
}, [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?: {
|
const loadAgents = useCallback(async (options?: {
|
||||||
forceSettings?: boolean;
|
forceSettings?: boolean;
|
||||||
minIntervalMs?: number;
|
minIntervalMs?: number;
|
||||||
@@ -1384,6 +1472,163 @@ export function OfficeScreen({
|
|||||||
setCreateAgentWizardNonce((current) => current + 1);
|
setCreateAgentWizardNonce((current) => current + 1);
|
||||||
setCreateAgentWizardOpen(true);
|
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) => {
|
const clearDeletedAgentUiState = useCallback((agentId: string) => {
|
||||||
setSelectedChatAgentId((current) => (current === agentId ? null : current));
|
setSelectedChatAgentId((current) => (current === agentId ? null : current));
|
||||||
setAgentEditorAgentId((current) => (current === agentId ? null : current));
|
setAgentEditorAgentId((current) => (current === agentId ? null : current));
|
||||||
@@ -1403,6 +1648,141 @@ export function OfficeScreen({
|
|||||||
return next;
|
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(() => {
|
const createAgentStatusLine = useMemo(() => {
|
||||||
if (!createAgentBlock) return null;
|
if (!createAgentBlock) return null;
|
||||||
if (createAgentBlock.phase === "queued") {
|
if (createAgentBlock.phase === "queued") {
|
||||||
@@ -2419,6 +2799,14 @@ export function OfficeScreen({
|
|||||||
Boolean(immediateGymHoldByAgentId[agent.agentId]),
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
}, [immediateGymHoldByAgentId, state.agents]);
|
}, [immediateGymHoldByAgentId, state.agents]);
|
||||||
@@ -3714,6 +4102,7 @@ export function OfficeScreen({
|
|||||||
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
|
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
|
||||||
<RetroOffice3D
|
<RetroOffice3D
|
||||||
agents={allVisibleAgents}
|
agents={allVisibleAgents}
|
||||||
|
officeCenterSignal={officeCameraCenterSignal}
|
||||||
animationState={officeAnimationState}
|
animationState={officeAnimationState}
|
||||||
deskAssignmentByDeskUid={deskAssignmentByDeskUid}
|
deskAssignmentByDeskUid={deskAssignmentByDeskUid}
|
||||||
githubReviewAgentId={githubReviewAgentId}
|
githubReviewAgentId={githubReviewAgentId}
|
||||||
@@ -3858,6 +4247,15 @@ export function OfficeScreen({
|
|||||||
>
|
>
|
||||||
Add Agent
|
Add Agent
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ui-btn-secondary px-3 py-2 text-xs font-semibold tracking-[0.05em] text-foreground"
|
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}
|
onTabChange={setActiveSidebarTab}
|
||||||
onOpenMarketplace={() => setMarketplaceOpen(true)}
|
onOpenMarketplace={() => setMarketplaceOpen(true)}
|
||||||
onAddAgent={handleOpenCreateAgentWizard}
|
onAddAgent={handleOpenCreateAgentWizard}
|
||||||
|
onOpenCompanyBuilder={handleOpenCompanyBuilder}
|
||||||
inboxPanel={
|
inboxPanel={
|
||||||
<InboxPanel
|
<InboxPanel
|
||||||
agents={state.agents}
|
agents={state.agents}
|
||||||
@@ -3954,6 +4353,7 @@ export function OfficeScreen({
|
|||||||
|
|
||||||
{showOnboardingWizard ? (
|
{showOnboardingWizard ? (
|
||||||
<OnboardingWizard
|
<OnboardingWizard
|
||||||
|
key={companyCreatedSignal > 0 ? `onboarding-company-created-${companyCreatedSignal}` : "onboarding-default"}
|
||||||
gatewayConnected={status === "connected"}
|
gatewayConnected={status === "connected"}
|
||||||
agentCount={state.agents.length}
|
agentCount={state.agents.length}
|
||||||
gatewayUrl={gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
@@ -3964,6 +4364,15 @@ export function OfficeScreen({
|
|||||||
void connect();
|
void connect();
|
||||||
}}
|
}}
|
||||||
onComplete={handleCompleteOnboarding}
|
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}
|
connectionError={gatewayError}
|
||||||
connecting={status === "connecting"}
|
connecting={status === "connecting"}
|
||||||
/>
|
/>
|
||||||
@@ -4473,7 +4882,7 @@ export function OfficeScreen({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AgentCreateWizardModal
|
<AgentCreateWizardModal
|
||||||
key={createAgentWizardNonce}
|
key={`create-agent-${createAgentWizardNonce}`}
|
||||||
open={createAgentWizardOpen}
|
open={createAgentWizardOpen}
|
||||||
suggestedName={`Agent ${state.agents.length + 1}`}
|
suggestedName={`Agent ${state.agents.length + 1}`}
|
||||||
busy={createAgentBusy}
|
busy={createAgentBusy}
|
||||||
@@ -4483,6 +4892,23 @@ export function OfficeScreen({
|
|||||||
onCreateAgent={handleCreateAgentFromIdentity}
|
onCreateAgent={handleCreateAgentFromIdentity}
|
||||||
onFinishWizard={handleFinishCreateAgentAvatar}
|
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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export const AgentsStep = ({ agentCount, connected }: AgentsStepProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<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-emerald-400" />
|
<Users className="h-5 w-5 text-amber-300" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-white">
|
<p className="text-sm font-semibold text-white">
|
||||||
{agentCount} agent{agentCount !== 1 ? "s" : ""} discovered
|
{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.
|
* CompleteStep — Final wizard screen before entering the office.
|
||||||
*/
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import confetti from "canvas-confetti";
|
||||||
import { Building2, Rocket } from "lucide-react";
|
import { Building2, Rocket } from "lucide-react";
|
||||||
|
|
||||||
export const CompleteStep = () => (
|
export const CompleteStep = ({
|
||||||
<div className="flex flex-col items-center justify-center gap-5 py-4">
|
companyCreated = false,
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/15">
|
companyName = null,
|
||||||
<Rocket className="h-7 w-7 text-emerald-400" />
|
}: {
|
||||||
|
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>
|
||||||
|
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<p className="text-base font-semibold text-white">
|
<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>
|
||||||
<p className="max-w-sm text-sm text-white/60">
|
<p className="max-w-sm text-sm text-white/60">
|
||||||
Your gateway is connected and your agents are ready. Step inside and
|
{companyCreated
|
||||||
explore the 3D workspace where your AI team operates.
|
? `${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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-xs space-y-2">
|
<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">
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,3 +88,4 @@ export const CompleteStep = () => (
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* layout suited for the wizard modal.
|
* layout suited for the wizard modal.
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
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 = {
|
export type ConnectStepProps = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@@ -33,8 +34,8 @@ export const ConnectStep = ({
|
|||||||
if (connected) {
|
if (connected) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
<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">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/20">
|
||||||
<CheckCircle2 className="h-6 w-6 text-emerald-400" />
|
<CheckCircle2 className="h-6 w-6 text-amber-300" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-white">Connected!</p>
|
<p className="text-sm font-semibold text-white">Connected!</p>
|
||||||
<p className="text-xs text-white/60">
|
<p className="text-xs text-white/60">
|
||||||
@@ -57,7 +58,7 @@ export const ConnectStep = ({
|
|||||||
Gateway URL
|
Gateway URL
|
||||||
</span>
|
</span>
|
||||||
<input
|
<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"
|
type="text"
|
||||||
value={gatewayUrl}
|
value={gatewayUrl}
|
||||||
onChange={(e) => onGatewayUrlChange(e.target.value)}
|
onChange={(e) => onGatewayUrlChange(e.target.value)}
|
||||||
@@ -72,7 +73,7 @@ export const ConnectStep = ({
|
|||||||
</span>
|
</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<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"}
|
type={showToken ? "text" : "password"}
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => onTokenChange(e.target.value)}
|
onChange={(e) => onTokenChange(e.target.value)}
|
||||||
@@ -96,13 +97,13 @@ export const ConnectStep = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onConnect}
|
||||||
disabled={connecting || !gatewayUrl.trim()}
|
disabled={connecting || !gatewayUrl.trim()}
|
||||||
>
|
>
|
||||||
{connecting ? (
|
{connecting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<RunningAvatarLoader size={16} trackWidth={32} inline />
|
||||||
Connecting…
|
Connecting…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { WelcomeStep } from "@/features/onboarding/components/WelcomeStep";
|
|||||||
import { PrerequisitesStep } from "@/features/onboarding/components/PrerequisitesStep";
|
import { PrerequisitesStep } from "@/features/onboarding/components/PrerequisitesStep";
|
||||||
import { ConnectStep } from "@/features/onboarding/components/ConnectStep";
|
import { ConnectStep } from "@/features/onboarding/components/ConnectStep";
|
||||||
import { AgentsStep } from "@/features/onboarding/components/AgentsStep";
|
import { AgentsStep } from "@/features/onboarding/components/AgentsStep";
|
||||||
|
import { CompanyStep } from "@/features/onboarding/components/CompanyStep";
|
||||||
import { CompleteStep } from "@/features/onboarding/components/CompleteStep";
|
import { CompleteStep } from "@/features/onboarding/components/CompleteStep";
|
||||||
|
|
||||||
export type OnboardingWizardProps = {
|
export type OnboardingWizardProps = {
|
||||||
@@ -36,6 +37,12 @@ export type OnboardingWizardProps = {
|
|||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
/** Called when the user finishes or dismisses the wizard. */
|
/** Called when the user finishes or dismisses the wizard. */
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
|
/** Opens the reusable company builder. */
|
||||||
|
onOpenCompanyBuilder: () => void;
|
||||||
|
initialStep?: OnboardingStepId;
|
||||||
|
initialCompletedSteps?: OnboardingStepId[];
|
||||||
|
createdCompanyName?: string | null;
|
||||||
|
companyCreated?: boolean;
|
||||||
/** Connection error message, if any. */
|
/** Connection error message, if any. */
|
||||||
connectionError: string | null;
|
connectionError: string | null;
|
||||||
/** Whether we're currently connecting. */
|
/** Whether we're currently connecting. */
|
||||||
@@ -51,12 +58,17 @@ export const OnboardingWizard = ({
|
|||||||
onTokenChange,
|
onTokenChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
onOpenCompanyBuilder,
|
||||||
|
initialStep = "welcome",
|
||||||
|
initialCompletedSteps,
|
||||||
|
createdCompanyName = null,
|
||||||
|
companyCreated = false,
|
||||||
connectionError,
|
connectionError,
|
||||||
connecting,
|
connecting,
|
||||||
}: OnboardingWizardProps) => {
|
}: OnboardingWizardProps) => {
|
||||||
const [currentStep, setCurrentStep] = useState<OnboardingStepId>("welcome");
|
const [currentStep, setCurrentStep] = useState<OnboardingStepId>(initialStep);
|
||||||
const [completedSteps, setCompletedSteps] = useState<Set<OnboardingStepId>>(
|
const [completedSteps, setCompletedSteps] = useState<Set<OnboardingStepId>>(
|
||||||
new Set(),
|
() => new Set(initialCompletedSteps ?? []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stepIndex = useMemo(() => getStepIndex(currentStep), [currentStep]);
|
const stepIndex = useMemo(() => getStepIndex(currentStep), [currentStep]);
|
||||||
@@ -116,8 +128,21 @@ export const OnboardingWizard = ({
|
|||||||
);
|
);
|
||||||
case "agents":
|
case "agents":
|
||||||
return <AgentsStep agentCount={agentCount} connected={gatewayConnected} />;
|
return <AgentsStep agentCount={agentCount} connected={gatewayConnected} />;
|
||||||
|
case "company":
|
||||||
|
return (
|
||||||
|
<CompanyStep
|
||||||
|
connected={gatewayConnected}
|
||||||
|
agentCount={agentCount}
|
||||||
|
onOpenCompanyBuilder={onOpenCompanyBuilder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "complete":
|
case "complete":
|
||||||
return <CompleteStep />;
|
return (
|
||||||
|
<CompleteStep
|
||||||
|
companyCreated={companyCreated}
|
||||||
|
companyName={createdCompanyName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -125,7 +150,7 @@ export const OnboardingWizard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -154,9 +179,9 @@ export const OnboardingWizard = ({
|
|||||||
key={step.id}
|
key={step.id}
|
||||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||||
idx <= stepIndex
|
idx <= stepIndex
|
||||||
? "bg-emerald-500"
|
? "bg-amber-400"
|
||||||
: completedSteps.has(step.id)
|
: completedSteps.has(step.id)
|
||||||
? "bg-emerald-500/40"
|
? "bg-amber-400/40"
|
||||||
: "bg-white/10"
|
: "bg-white/10"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +189,7 @@ export const OnboardingWizard = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step content */}
|
{/* 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 */}
|
{/* Footer navigation */}
|
||||||
<div className="flex items-center justify-between border-t border-white/10 px-6 py-4">
|
<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" ? (
|
{currentStep === "complete" ? (
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onComplete}
|
||||||
>
|
>
|
||||||
Enter Office
|
Enter Office
|
||||||
|
|||||||
@@ -28,24 +28,24 @@ const prerequisites = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const PrerequisitesStep = () => (
|
export const PrerequisitesStep = () => (
|
||||||
<div className="space-y-4">
|
<div className="space-y-2.5">
|
||||||
<p className="text-sm text-white/70">
|
<p className="text-[13px] leading-5 text-white/70">
|
||||||
Make sure you have these ready before connecting. If you already have
|
Make sure you have these ready before connecting. If you already have
|
||||||
OpenClaw running, you can skip this step.
|
OpenClaw running, you can skip this step.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-1.5">
|
||||||
{prerequisites.map(({ label, detail, ...rest }) => (
|
{prerequisites.map(({ label, detail, ...rest }) => (
|
||||||
<div
|
<div
|
||||||
key={label}
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-semibold text-white">{label}</p>
|
<p className="text-[11px] font-semibold text-white">{label}</p>
|
||||||
<p className="mt-0.5 text-[11px] text-white/55">{detail}</p>
|
<p className="mt-0.5 text-[10px] leading-4 text-white/55">{detail}</p>
|
||||||
{"command" in rest ? (
|
{"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}
|
{rest.command}
|
||||||
</code>
|
</code>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -54,7 +54,7 @@ export const PrerequisitesStep = () => (
|
|||||||
href={rest.link}
|
href={rest.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"}
|
{rest.linkLabel ?? "Learn more"}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
@@ -65,13 +65,13 @@ export const PrerequisitesStep = () => (
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-white/40">
|
<p className="text-[10px] leading-4 text-white/40">
|
||||||
Need help? Check{" "}
|
Need help? Check{" "}
|
||||||
<a
|
<a
|
||||||
href="https://docs.openclaw.ai"
|
href="https://docs.openclaw.ai"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-emerald-400/70 hover:text-emerald-300"
|
className="text-amber-300/70 hover:text-amber-200"
|
||||||
>
|
>
|
||||||
docs.openclaw.ai
|
docs.openclaw.ai
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
@@ -80,7 +80,7 @@ export const PrerequisitesStep = () => (
|
|||||||
href="https://discord.com/invite/clawd"
|
href="https://discord.com/invite/clawd"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-emerald-400/70 hover:text-emerald-300"
|
className="text-amber-300/70 hover:text-amber-200"
|
||||||
>
|
>
|
||||||
join Discord
|
join Discord
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const WelcomeStep = () => (
|
|||||||
className="rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-3"
|
className="rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-xs font-semibold text-white">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[11px] leading-snug text-white/55">
|
<p className="mt-1.5 text-[11px] leading-snug text-white/55">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type OnboardingStepId =
|
|||||||
| "prerequisites"
|
| "prerequisites"
|
||||||
| "connect"
|
| "connect"
|
||||||
| "agents"
|
| "agents"
|
||||||
|
| "company"
|
||||||
| "complete";
|
| "complete";
|
||||||
|
|
||||||
export type OnboardingStep = {
|
export type OnboardingStep = {
|
||||||
@@ -57,6 +58,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
description: "Meet your AI team",
|
description: "Meet your AI team",
|
||||||
skippable: true,
|
skippable: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "company",
|
||||||
|
title: "Build Your Company",
|
||||||
|
description: "Generate your org structure",
|
||||||
|
skippable: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "complete",
|
id: "complete",
|
||||||
title: "You're All Set",
|
title: "You're All Set",
|
||||||
|
|||||||
@@ -2295,6 +2295,7 @@ const getAgentInitials = (name: string | null | undefined): string => {
|
|||||||
|
|
||||||
export function RetroOffice3D({
|
export function RetroOffice3D({
|
||||||
agents,
|
agents,
|
||||||
|
officeCenterSignal = 0,
|
||||||
animationState = null,
|
animationState = null,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
storageNamespace = "default",
|
storageNamespace = "default",
|
||||||
@@ -2365,6 +2366,7 @@ export function RetroOffice3D({
|
|||||||
onJukeboxInteract,
|
onJukeboxInteract,
|
||||||
}: {
|
}: {
|
||||||
agents: OfficeAgent[];
|
agents: OfficeAgent[];
|
||||||
|
officeCenterSignal?: number;
|
||||||
animationState?: Pick<
|
animationState?: Pick<
|
||||||
OfficeAnimationState,
|
OfficeAnimationState,
|
||||||
| "cleaningCues"
|
| "cleaningCues"
|
||||||
@@ -4958,6 +4960,21 @@ export function RetroOffice3D({
|
|||||||
? DISTRICT_CAMERA_TARGET
|
? DISTRICT_CAMERA_TARGET
|
||||||
: LOCAL_CAMERA_TARGET;
|
: LOCAL_CAMERA_TARGET;
|
||||||
const cameraZoom = remoteOfficeEnabled ? DISTRICT_CAMERA_ZOOM : 56;
|
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 (
|
return (
|
||||||
<div className="relative w-full h-full bg-[#1a1008] font-mono text-white overflow-hidden">
|
<div className="relative w-full h-full bg-[#1a1008] font-mono text-white overflow-hidden">
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import type {
|
|||||||
} from "@/features/retro-office/core/types";
|
} from "@/features/retro-office/core/types";
|
||||||
import { AgentModelProps } from "@/features/retro-office/objects/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({
|
export const AgentModel = memo(function AgentModel({
|
||||||
agentId,
|
agentId,
|
||||||
name,
|
name,
|
||||||
@@ -118,7 +128,9 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
agent.state === "walking"
|
agent.state === "walking"
|
||||||
? Math.sin(frameValue * WALK_ANIM_SPEED) * 0.04
|
? Math.sin(frameValue * WALK_ANIM_SPEED) * 0.04
|
||||||
: isDancing
|
: 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
|
: isWorkout
|
||||||
? workoutStyle === "stretch"
|
? workoutStyle === "stretch"
|
||||||
? 0.012 + Math.abs(workoutPhase) * 0.018
|
? 0.012 + Math.abs(workoutPhase) * 0.018
|
||||||
@@ -142,8 +154,10 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
} else if (agent.state === "walking") {
|
} else if (agent.state === "walking") {
|
||||||
leftArmRef.current.rotation.x = walkPhase * 0.4;
|
leftArmRef.current.rotation.x = walkPhase * 0.4;
|
||||||
} else if (isDancing) {
|
} else if (isDancing) {
|
||||||
leftArmRef.current.rotation.x = -0.8 + Math.sin(agent.frame * 0.22) * 0.9;
|
leftArmRef.current.rotation.x =
|
||||||
leftArmRef.current.rotation.z = -0.45 + Math.cos(agent.frame * 0.16) * 0.18;
|
-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;
|
leftArmRef.current.rotation.y = -0.08;
|
||||||
groupRef.current.rotation.z = Math.sin(agent.frame * 0.12) * 0.08;
|
groupRef.current.rotation.z = Math.sin(agent.frame * 0.12) * 0.08;
|
||||||
} else if (isWorkout) {
|
} else if (isWorkout) {
|
||||||
@@ -199,8 +213,10 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
} else if (agent.state === "walking") {
|
} else if (agent.state === "walking") {
|
||||||
rightArmRef.current.rotation.x = -walkPhase * 0.4;
|
rightArmRef.current.rotation.x = -walkPhase * 0.4;
|
||||||
} else if (isDancing) {
|
} else if (isDancing) {
|
||||||
rightArmRef.current.rotation.x = -0.8 - Math.sin(agent.frame * 0.22) * 0.9;
|
rightArmRef.current.rotation.x =
|
||||||
rightArmRef.current.rotation.z = 0.45 - Math.cos(agent.frame * 0.16) * 0.18;
|
-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;
|
rightArmRef.current.rotation.y = 0.08;
|
||||||
groupRef.current.rotation.z = Math.sin(agent.frame * 0.12) * 0.08;
|
groupRef.current.rotation.z = Math.sin(agent.frame * 0.12) * 0.08;
|
||||||
} else if (isWorkout) {
|
} else if (isWorkout) {
|
||||||
@@ -287,7 +303,10 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const working =
|
const working =
|
||||||
agent.state === "sitting" || isWorkout || isDancing || agent.status === "working";
|
agent.state === "sitting" ||
|
||||||
|
isWorkout ||
|
||||||
|
isDancing ||
|
||||||
|
agent.status === "working";
|
||||||
const isError = agent.status === "error";
|
const isError = agent.status === "error";
|
||||||
const isAway = agent.state === "away";
|
const isAway = agent.state === "away";
|
||||||
|
|
||||||
@@ -620,6 +639,9 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
: "#8dc4ff"
|
: "#8dc4ff"
|
||||||
: "transparent";
|
: "transparent";
|
||||||
const speechBubbleBorderInset = activeSpeechBubble ? 0.03 : 0;
|
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 (
|
return (
|
||||||
<group
|
<group
|
||||||
@@ -1045,7 +1067,7 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
{!activeSpeechBubble && name ? (
|
{!activeSpeechBubble && nameplateText ? (
|
||||||
<Billboard position={[0, 1.05, 0]}>
|
<Billboard position={[0, 1.05, 0]}>
|
||||||
<mesh position={[0, 0, -0.001]}>
|
<mesh position={[0, 0, -0.001]}>
|
||||||
<planeGeometry args={[0.82, 0.24]} />
|
<planeGeometry args={[0.82, 0.24]} />
|
||||||
@@ -1061,14 +1083,14 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
</mesh>
|
</mesh>
|
||||||
<Text
|
<Text
|
||||||
position={[-0.02, 0, 0.001]}
|
position={[-0.02, 0, 0.001]}
|
||||||
fontSize={0.16}
|
fontSize={nameplateFontSize}
|
||||||
color="#e8dfc0"
|
color="#e8dfc0"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
maxWidth={0.68}
|
maxWidth={0.68}
|
||||||
font={undefined}
|
font={undefined}
|
||||||
>
|
>
|
||||||
{name}
|
{nameplateText}
|
||||||
</Text>
|
</Text>
|
||||||
</Billboard>
|
</Billboard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||||
import { useJukeboxStore } from "../store";
|
import { useJukeboxStore } from "../store";
|
||||||
import {
|
import {
|
||||||
startSpotifyAuth,
|
startSpotifyAuth,
|
||||||
@@ -302,7 +303,7 @@ function PlayerView() {
|
|||||||
</div>
|
</div>
|
||||||
{isLoadingPlayer && !track ? (
|
{isLoadingPlayer && !track ? (
|
||||||
<div className="flex items-center gap-3 text-slate-500">
|
<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>
|
<span className="text-sm">Loading player…</span>
|
||||||
</div>
|
</div>
|
||||||
) : track ? (
|
) : track ? (
|
||||||
@@ -374,7 +375,7 @@ function PlayerView() {
|
|||||||
/>
|
/>
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ export type StudioOfficePreference = {
|
|||||||
remoteOfficePresenceUrl: string;
|
remoteOfficePresenceUrl: string;
|
||||||
remoteOfficeGatewayUrl: string;
|
remoteOfficeGatewayUrl: string;
|
||||||
remoteOfficeToken: string;
|
remoteOfficeToken: string;
|
||||||
|
companyName: string;
|
||||||
|
companyPrompt: string;
|
||||||
|
companyImprovedBrief: string;
|
||||||
|
companySummary: string;
|
||||||
|
companyGeneratedAt: string | null;
|
||||||
|
companyRoleTitles: string[];
|
||||||
|
companyPlanJson: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StudioOfficePreferencePublic = {
|
export type StudioOfficePreferencePublic = {
|
||||||
@@ -80,6 +87,13 @@ export type StudioOfficePreferencePublic = {
|
|||||||
remoteOfficePresenceUrl: string;
|
remoteOfficePresenceUrl: string;
|
||||||
remoteOfficeGatewayUrl: string;
|
remoteOfficeGatewayUrl: string;
|
||||||
remoteOfficeTokenConfigured: boolean;
|
remoteOfficeTokenConfigured: boolean;
|
||||||
|
companyName: string;
|
||||||
|
companyPrompt: string;
|
||||||
|
companyImprovedBrief: string;
|
||||||
|
companySummary: string;
|
||||||
|
companyGeneratedAt: string | null;
|
||||||
|
companyRoleTitles: string[];
|
||||||
|
companyPlanJson: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StudioOfficePreferencePatch = {
|
export type StudioOfficePreferencePatch = {
|
||||||
@@ -90,6 +104,13 @@ export type StudioOfficePreferencePatch = {
|
|||||||
remoteOfficePresenceUrl?: string | null;
|
remoteOfficePresenceUrl?: string | null;
|
||||||
remoteOfficeGatewayUrl?: string | null;
|
remoteOfficeGatewayUrl?: string | null;
|
||||||
remoteOfficeToken?: 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>;
|
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 => ({
|
export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
|
||||||
title: DEFAULT_OFFICE_TITLE,
|
title: DEFAULT_OFFICE_TITLE,
|
||||||
remoteOfficeEnabled: false,
|
remoteOfficeEnabled: false,
|
||||||
@@ -354,6 +386,13 @@ export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
|
|||||||
remoteOfficePresenceUrl: "",
|
remoteOfficePresenceUrl: "",
|
||||||
remoteOfficeGatewayUrl: "",
|
remoteOfficeGatewayUrl: "",
|
||||||
remoteOfficeToken: "",
|
remoteOfficeToken: "",
|
||||||
|
companyName: "",
|
||||||
|
companyPrompt: "",
|
||||||
|
companyImprovedBrief: "",
|
||||||
|
companySummary: "",
|
||||||
|
companyGeneratedAt: null,
|
||||||
|
companyRoleTitles: [],
|
||||||
|
companyPlanJson: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const defaultStudioOfficePreferencePublic =
|
export const defaultStudioOfficePreferencePublic =
|
||||||
@@ -365,6 +404,13 @@ export const defaultStudioOfficePreferencePublic =
|
|||||||
remoteOfficePresenceUrl: "",
|
remoteOfficePresenceUrl: "",
|
||||||
remoteOfficeGatewayUrl: "",
|
remoteOfficeGatewayUrl: "",
|
||||||
remoteOfficeTokenConfigured: false,
|
remoteOfficeTokenConfigured: false,
|
||||||
|
companyName: "",
|
||||||
|
companyPrompt: "",
|
||||||
|
companyImprovedBrief: "",
|
||||||
|
companySummary: "",
|
||||||
|
companyGeneratedAt: null,
|
||||||
|
companyRoleTitles: [],
|
||||||
|
companyPlanJson: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sanitizeStudioOfficePreference = (
|
export const sanitizeStudioOfficePreference = (
|
||||||
@@ -377,6 +423,13 @@ export const sanitizeStudioOfficePreference = (
|
|||||||
remoteOfficePresenceUrl: value.remoteOfficePresenceUrl,
|
remoteOfficePresenceUrl: value.remoteOfficePresenceUrl,
|
||||||
remoteOfficeGatewayUrl: value.remoteOfficeGatewayUrl,
|
remoteOfficeGatewayUrl: value.remoteOfficeGatewayUrl,
|
||||||
remoteOfficeTokenConfigured: value.remoteOfficeToken.length > 0,
|
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 = (
|
const normalizeStandupScheduleConfig = (
|
||||||
@@ -669,6 +722,21 @@ const normalizeOfficePreference = (
|
|||||||
value.remoteOfficeToken === null
|
value.remoteOfficeToken === null
|
||||||
? ""
|
? ""
|
||||||
: coerceString(value.remoteOfficeToken) || fallback.remoteOfficeToken,
|
: 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,13 @@ describe("getStepIndex", () => {
|
|||||||
const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect");
|
const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect");
|
||||||
expect(getStepIndex("connect")).toBe(idx);
|
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", () => {
|
describe("getNextStep", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user