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. */}