From c9789c21485c3c72e5a8315779201bf1e6b7a6bf Mon Sep 17 00:00:00 2001
From: Luke The Dev <252071647+iamlukethedev@users.noreply.github.com>
Date: Mon, 23 Mar 2026 18:04:37 -0500
Subject: [PATCH] Add office agent management wizard (#56)
* Add agents
* Agent
* Added agents management
* Polish agent wizard and release blockers.
Finalize the office agent management flow by aligning the gateway fallback behavior, cleaning up UI semantics, and updating tests so the branch is ready to ship.
Made-with: Cursor
---------
Co-authored-by: iamlukethedev
Co-authored-by: iamlukethedev
---
server/studio-settings.js | 14 +-
src/app/api/gateway/agent-state/route.ts | 4 +-
src/app/api/gateway/media/route.ts | 4 +-
src/app/api/gateway/skills/remove/route.ts | 4 +-
.../components/AgentAvatarEditorPanel.tsx | 96 ++-
.../components/AgentAvatarPreview3D.tsx | 12 +-
.../agents/components/AgentChatPanel.tsx | 10 +-
.../components/AgentCreateWizardModal.tsx | 566 ++++++++++++++++++
.../agents/components/AgentEditorModal.tsx | 68 ++-
.../agents/components/AgentIdentityFields.tsx | 76 +++
.../components/inspect/AgentBrainPanel.tsx | 60 +-
.../agents/operations/agentFleetHydration.ts | 14 +-
.../agents/operations/deleteAgentOperation.ts | 41 +-
.../useAgentSettingsMutationController.ts | 12 +-
.../agents/screens/AgentsPageScreen.tsx | 7 +-
src/features/agents/state/store.tsx | 13 +
src/features/office/components/HQSidebar.tsx | 11 +
.../office/hooks/useOfficeSkillTriggers.ts | 18 +-
.../hooks/useOfficeSkillsMarketplace.ts | 2 +-
src/features/office/screens/OfficeScreen.tsx | 496 ++++++++++++++-
src/features/retro-office/RetroOffice3D.tsx | 36 +-
src/lib/agents/personalityBuilder.ts | 4 +-
src/lib/gateway/GatewayClient.ts | 1 +
src/lib/gateway/local-gateway.ts | 22 +
src/lib/skills/tempAgents.ts | 27 +
tests/unit/agentBrainPanel.test.ts | 28 +-
tests/unit/agentSettingsPanel.test.ts | 2 +-
tests/unit/gatewayConnectRetryPolicy.test.ts | 5 +-
tests/unit/onboardingState.test.ts | 2 +-
tests/unit/settingsRouteWorkflow.test.ts | 4 +-
...useAgentSettingsMutationController.test.ts | 24 +-
tests/unit/useSettingsRouteController.test.ts | 2 +-
32 files changed, 1504 insertions(+), 181 deletions(-)
create mode 100644 src/features/agents/components/AgentCreateWizardModal.tsx
create mode 100644 src/features/agents/components/AgentIdentityFields.tsx
create mode 100644 src/lib/skills/tempAgents.ts
diff --git a/server/studio-settings.js b/server/studio-settings.js
index a36ca0c..5e81dea 100644
--- a/server/studio-settings.js
+++ b/server/studio-settings.js
@@ -58,21 +58,9 @@ const readJsonFile = (filePath) => {
const DEFAULT_GATEWAY_URL = "ws://localhost:18789";
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
-const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
const isRecord = (value) => Boolean(value && typeof value === "object");
-const isLocalGatewayUrl = (value) => {
- const trimmed = typeof value === "string" ? value.trim() : "";
- if (!trimmed) return false;
- try {
- const parsed = new URL(trimmed);
- return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
- } catch {
- return false;
- }
-};
-
const readOpenclawGatewayDefaults = (env = process.env) => {
try {
const stateDir = resolveStateDir(env);
@@ -100,7 +88,7 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
- if (!token && (!url || isLocalGatewayUrl(url))) {
+ if (!token) {
const defaults = readOpenclawGatewayDefaults(env);
if (defaults) {
return {
diff --git a/src/app/api/gateway/agent-state/route.ts b/src/app/api/gateway/agent-state/route.ts
index d57d514..94c77af 100644
--- a/src/app/api/gateway/agent-state/route.ts
+++ b/src/app/api/gateway/agent-state/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local";
-import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
+import { isLikelyLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import {
resolveConfiguredSshTarget,
resolveGatewaySshTargetFromGatewayUrl,
@@ -30,7 +30,7 @@ const resolveAgentStateSshTarget = (): string | null => {
if (configured) return configured;
const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? "";
- if (isLocalGatewayUrl(gatewayUrl)) return null;
+ if (isLikelyLocalGatewayUrl(gatewayUrl)) return null;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
};
diff --git a/src/app/api/gateway/media/route.ts b/src/app/api/gateway/media/route.ts
index bd48e1c..39ee95a 100644
--- a/src/app/api/gateway/media/route.ts
+++ b/src/app/api/gateway/media/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
+import { isLikelyLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import {
resolveConfiguredSshTarget,
resolveGatewaySshTargetFromGatewayUrl,
@@ -151,7 +151,7 @@ PY
const resolveSshTarget = (): string | null => {
const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? "";
- if (isLocalGatewayUrl(gatewayUrl)) return null;
+ if (isLikelyLocalGatewayUrl(gatewayUrl)) return null;
const configured = resolveConfiguredSshTarget(process.env);
if (configured) return configured;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
diff --git a/src/app/api/gateway/skills/remove/route.ts b/src/app/api/gateway/skills/remove/route.ts
index 0fe047c..bcdd4dc 100644
--- a/src/app/api/gateway/skills/remove/route.ts
+++ b/src/app/api/gateway/skills/remove/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
+import { isLikelyLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import { removeSkillLocally } from "@/lib/skills/remove-local";
import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types";
import {
@@ -33,7 +33,7 @@ const resolveSkillRemovalSshTarget = (): string | null => {
if (configured) return configured;
const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? "";
- if (isLocalGatewayUrl(gatewayUrl)) return null;
+ if (isLikelyLocalGatewayUrl(gatewayUrl)) return null;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
};
diff --git a/src/features/agents/components/AgentAvatarEditorPanel.tsx b/src/features/agents/components/AgentAvatarEditorPanel.tsx
index f75d491..f1f0230 100644
--- a/src/features/agents/components/AgentAvatarEditorPanel.tsx
+++ b/src/features/agents/components/AgentAvatarEditorPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { RefreshCcw, Shuffle } from "lucide-react";
import {
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
@@ -22,8 +22,16 @@ export type AgentAvatarEditorPanelProps = {
agentName: string;
initialProfile: AgentAvatarProfile | null | undefined;
onSave: (profile: AgentAvatarProfile) => Promise | void;
+ onDraftChange?: (profile: AgentAvatarProfile) => void;
onCancel?: () => void;
onSaved?: () => void;
+ cancelLabel?: string;
+ saveLabel?: string;
+ showActions?: boolean;
+};
+
+export type AgentAvatarEditorPanelHandle = {
+ save: () => Promise;
};
const pillClassName =
@@ -32,14 +40,24 @@ const pillClassName =
const colorSwatchClassName =
"h-7 w-7 rounded-full border-2 transition-transform hover:scale-105";
-export const AgentAvatarEditorPanel = ({
- agentId,
- agentName,
- initialProfile,
- onSave,
- onCancel,
- onSaved,
-}: AgentAvatarEditorPanelProps) => {
+export const AgentAvatarEditorPanel = forwardRef<
+ AgentAvatarEditorPanelHandle,
+ AgentAvatarEditorPanelProps
+>(function AgentAvatarEditorPanel(
+ {
+ agentId,
+ agentName,
+ initialProfile,
+ onSave,
+ onDraftChange,
+ onCancel,
+ onSaved,
+ cancelLabel = "Cancel",
+ saveLabel = "Save avatar",
+ showActions = true,
+ }: AgentAvatarEditorPanelProps,
+ ref
+) {
const fallbackProfile = useMemo(
() => createDefaultAgentAvatarProfile(agentId),
[agentId]
@@ -52,7 +70,11 @@ export const AgentAvatarEditorPanel = ({
setDraft(resolvedInitialProfile);
}, [resolvedInitialProfile]);
- const save = async () => {
+ useEffect(() => {
+ onDraftChange?.(draft);
+ }, [draft, onDraftChange]);
+
+ const save = useCallback(async () => {
if (saving) return;
setSaving(true);
try {
@@ -61,7 +83,15 @@ export const AgentAvatarEditorPanel = ({
} finally {
setSaving(false);
}
- };
+ }, [draft, onSave, onSaved, saving]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ save,
+ }),
+ [save]
+ );
return (
@@ -99,26 +129,28 @@ export const AgentAvatarEditorPanel = ({
-
-
-
-
+ {showActions ? (
+
+
+
+
+ ) : null}
@@ -420,4 +452,4 @@ export const AgentAvatarEditorPanel = ({
);
-};
+});
diff --git a/src/features/agents/components/AgentAvatarPreview3D.tsx b/src/features/agents/components/AgentAvatarPreview3D.tsx
index 70727d7..d575302 100644
--- a/src/features/agents/components/AgentAvatarPreview3D.tsx
+++ b/src/features/agents/components/AgentAvatarPreview3D.tsx
@@ -305,11 +305,9 @@ export const AgentAvatarPreview3D = ({
() => profile ?? createDefaultAgentAvatarProfile("preview"),
[profile]
);
- const [isReady, setIsReady] = useState(false);
-
- useEffect(() => {
- setIsReady(false);
- }, [resolvedProfile]);
+ const profileKey = useMemo(() => JSON.stringify(resolvedProfile), [resolvedProfile]);
+ const [readyProfileKey, setReadyProfileKey] = useState(null);
+ const isReady = readyProfileKey === profileKey;
return (
@@ -321,7 +319,7 @@ export const AgentAvatarPreview3D = ({
) : null}
-
{emptyFleetMessage}
-
+
+
+
+
) : null}
+ {deleteAgentStatusLine ? (
+
+
+
+ Fleet mutation
+
+
{deleteAgentStatusLine}
+
+
+ ) : null}
+
{!debugEnabled ? (
setSidebarOpen((prev) => !prev)}
onTabChange={setActiveSidebarTab}
onOpenMarketplace={() => setMarketplaceOpen(true)}
+ onAddAgent={handleOpenCreateAgentWizard}
inboxPanel={
{
+ await handleDeleteAgent(agentId);
+ }}
+ onNavigateAgent={(agentId, section) => {
+ openAgentEditor(agentId, section);
+ }}
/>
) : null}
+
);
}
diff --git a/src/features/retro-office/RetroOffice3D.tsx b/src/features/retro-office/RetroOffice3D.tsx
index aa199e4..0b17a7c 100644
--- a/src/features/retro-office/RetroOffice3D.tsx
+++ b/src/features/retro-office/RetroOffice3D.tsx
@@ -9,6 +9,8 @@ import {
Armchair,
Settings2,
Camera,
+ UserPlus,
+ Trash2,
Users,
X,
} from "lucide-react";
@@ -1901,7 +1903,9 @@ export function RetroOffice3D({
onStandupArrivalsChange,
onStandupStartRequested,
onMonitorSelect,
+ onAddAgent,
onAgentEdit,
+ onAgentDelete,
onDeskAssignmentChange,
onDeskAssignmentsReset,
onGithubReviewDismiss,
@@ -1965,7 +1969,9 @@ export function RetroOffice3D({
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
onStandupStartRequested?: () => void;
onMonitorSelect?: (agentId: string | null) => void;
+ onAddAgent?: () => void;
onAgentEdit?: (agentId: string) => void;
+ onAgentDelete?: (agentId: string) => void;
onDeskAssignmentChange?: (deskUid: string, agentId: string | null) => void;
onDeskAssignmentsReset?: (deskUids: string[]) => void;
onGithubReviewDismiss?: () => void;
@@ -2497,10 +2503,7 @@ export function RetroOffice3D({
) ?? null
);
}, [assignedDeskIndexByAgentId, deskLocations, furniture, monitorAgentId]);
- useEffect(() => {
- if (!immersiveOverlayActive) return;
- setAgentRosterOpen(false);
- }, [immersiveOverlayActive]);
+ const agentRosterVisible = agentRosterOpen && !immersiveOverlayActive;
const selectedItem = useMemo(
() => furniture.find((item) => item._uid === selectedUid) ?? null,
[furniture, selectedUid],
@@ -5124,7 +5127,7 @@ export function RetroOffice3D({
- {agentRosterOpen ? (
+ {agentRosterVisible ? (
@@ -5223,6 +5226,19 @@ export function RetroOffice3D({
>
+ {onAgentDelete ? (
+
+ ) : null}
);
})}
@@ -5953,6 +5969,16 @@ export function RetroOffice3D({
{/* Toolbar — top right. */}
{!immersiveOverlayActive ? (
+ {onAddAgent ? (
+
+ ) : null}
{/* New Idea 7: Heatmap toggle. */}