From 65c2b9cf85a36a796398d6a6f7ad003ea0664baf Mon Sep 17 00:00:00 2001 From: Luke The Dev <252071647+iamlukethedev@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:05:14 -0500 Subject: [PATCH] Avatar Customization + Update Agent Brain (#23) Co-authored-by: iamlukethedev --- .env.example | 3 + CHANGELOG.md | 32 +- src/app/office/page.tsx | 12 +- .../components/AgentAvatarCreatorModal.tsx | 48 ++ .../components/AgentAvatarEditorPanel.tsx | 423 ++++++++++++++++ .../components/AgentAvatarPreview3D.tsx | 336 +++++++++++++ .../agents/components/AgentChatPanel.tsx | 8 +- .../agents/components/AgentEditorModal.tsx | 225 +++++++++ .../components/inspect/AgentBrainPanel.tsx | 252 +++++----- .../agentFleetHydrationDerivation.ts | 9 +- .../agents/screens/AgentsPageScreen.tsx | 55 ++- src/features/agents/state/store.tsx | 3 + .../components/panels/SettingsPanel.tsx | 31 ++ src/features/office/screens/OfficeScreen.tsx | 197 +++++--- src/features/retro-office/RetroOffice3D.tsx | 454 +++++++++++------- src/features/retro-office/core/types.ts | 3 + src/features/retro-office/objects/agents.tsx | 250 +++++++++- src/features/retro-office/objects/types.ts | 2 + src/hooks/useStudioOfficePreference.ts | 83 ++++ src/lib/avatars/profile.ts | 272 +++++++++++ src/lib/studio/coordinator.ts | 32 +- src/lib/studio/settings.ts | 116 ++++- tests/e2e/agent-avatar.spec.ts | 39 +- tests/e2e/agent-ia-split.spec.ts | 37 +- tests/e2e/agent-inspect-panel.spec.ts | 11 +- tests/e2e/connection-settings.spec.ts | 31 +- tests/e2e/fleet-sidebar.spec.ts | 24 +- tests/e2e/focused-smoke.spec.ts | 35 +- tests/e2e/helpers/studioRoute.ts | 23 +- tests/e2e/invalid-route-redirect.spec.ts | 8 +- tests/e2e/settings-route-disconnected.spec.ts | 11 +- tests/unit/agentAvatarCreatorModal.test.tsx | 43 ++ tests/unit/agentEditorModal.test.ts | 137 ++++++ tests/unit/agentFleetHydration.test.ts | 5 +- .../agentFleetHydrationDerivation.test.ts | 5 +- tests/unit/studioBootstrapOperation.test.ts | 2 + tests/unit/studioBootstrapWorkflow.test.ts | 2 + tests/unit/studioSettings.test.ts | 61 ++- tests/unit/studioSettingsRoute.test.ts | 34 +- 39 files changed, 2803 insertions(+), 551 deletions(-) create mode 100644 src/features/agents/components/AgentAvatarCreatorModal.tsx create mode 100644 src/features/agents/components/AgentAvatarEditorPanel.tsx create mode 100644 src/features/agents/components/AgentAvatarPreview3D.tsx create mode 100644 src/features/agents/components/AgentEditorModal.tsx create mode 100644 src/hooks/useStudioOfficePreference.ts create mode 100644 src/lib/avatars/profile.ts create mode 100644 tests/unit/agentAvatarCreatorModal.test.tsx create mode 100644 tests/unit/agentEditorModal.test.ts diff --git a/.env.example b/.env.example index 09a3439..1f0005b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Browser/client gateway URL NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789 +# Debug UI +DEBUG=true + # App server # PORT=3000 # HOST=127.0.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e4f0e..ee737d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,36 @@ # Changelog -All notable changes to Claw3D will be documented in this file. +## [0.1.2] - 2026-03-20 -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows Semantic Versioning where practical. +### Added -## [0.1.0] - 2026-03-19 +- An in-app avatar creator for agents with live 3D preview, appearance presets, and accessory controls for customizing office avatars. +- A unified agent editor modal in the office that lets you edit avatars alongside agent brain files such as `IDENTITY.md`, `SOUL.md`, `AGENTS.md`, `USER.md`, `TOOLS.md`, `MEMORY.md`, and `HEARTBEAT.md`. +- Structured avatar profile persistence and normalization so studio settings can store full avatar appearance data per gateway and agent instead of only avatar seeds. +- A `DEBUG` environment toggle for controlling the OpenClaw event console in the office UI. + +### Changed + +- Reworked office avatar rendering so 3D agents reflect saved appearance profiles, including hair, clothing, hats, glasses, headsets, backpacks, and other visual variations. +- Replaced avatar shuffle entry points in the chat and office surfaces with avatar customization flows that open the editor directly. +- Updated the office HUD with a compact agent roster, overflow handling, and direct shortcuts into per-agent editing from the 3D office view. +- Expanded the brain editor so `IDENTITY.md` fields are edited in structured form and agent renames can be applied to the live gateway agent after saving. +- Defaulted the OpenClaw event console to a collapsed state and made it optional from environment configuration. +- Updated hydration and store state to carry full avatar profiles through agent loading, persistence, and rendering. + +### Fixed + +- Fixed WebSocket gateway authentication during the upgrade handshake by wiring access control through the `ws` `verifyClient` flow. +- Fixed the gym release directive TypeScript error by adding explicit `"release"` support to office gym directives and aligning release-hold logic. +- Corrected studio settings merging and normalization for avatar data so saved office appearances survive reloads and patch updates. +- Kept skill gym hold state active for release directives during office animation trigger reconciliation. + +### Tests + +- Added unit coverage for avatar profile persistence, studio settings normalization, and fleet hydration with structured avatar data. +- Expanded end-to-end coverage for avatar settings fixtures, office header and sidebar flows, voice reply settings persistence, disconnected office settings surfaces, and office route expectations. + +## [0.1.1] - 2026-03-19 ### Added diff --git a/src/app/office/page.tsx b/src/app/office/page.tsx index 2be82e8..6da0655 100644 --- a/src/app/office/page.tsx +++ b/src/app/office/page.tsx @@ -2,11 +2,21 @@ import { Suspense } from "react"; import { AgentStoreProvider } from "@/features/agents/state/store"; import { OfficeScreen } from "@/features/office/screens/OfficeScreen"; +const ENABLED_RE = /^(1|true|yes|on)$/i; + +const readDebugFlag = (value: string | undefined): boolean => { + const normalized = (value ?? "").trim(); + if (!normalized) return true; + return ENABLED_RE.test(normalized); +}; + export default function OfficePage() { + const showOpenClawConsole = readDebugFlag(process.env.DEBUG); + return ( - + ); diff --git a/src/features/agents/components/AgentAvatarCreatorModal.tsx b/src/features/agents/components/AgentAvatarCreatorModal.tsx new file mode 100644 index 0000000..bfb64f9 --- /dev/null +++ b/src/features/agents/components/AgentAvatarCreatorModal.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { AgentAvatarProfile } from "@/lib/avatars/profile"; +import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel"; + +type AgentAvatarCreatorModalProps = { + open: boolean; + agentId: string; + agentName: string; + initialProfile: AgentAvatarProfile | null | undefined; + onClose: () => void; + onSave: (profile: AgentAvatarProfile) => Promise | void; +}; + +export const AgentAvatarCreatorModal = ({ + open, + agentId, + agentName, + initialProfile, + onClose, + onSave, +}: AgentAvatarCreatorModalProps) => { + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > + +
+
+ ); +}; diff --git a/src/features/agents/components/AgentAvatarEditorPanel.tsx b/src/features/agents/components/AgentAvatarEditorPanel.tsx new file mode 100644 index 0000000..f75d491 --- /dev/null +++ b/src/features/agents/components/AgentAvatarEditorPanel.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { RefreshCcw, Shuffle } from "lucide-react"; +import { + AGENT_AVATAR_BOTTOM_STYLE_OPTIONS, + AGENT_AVATAR_CLOTHING_COLOR_OPTIONS, + AGENT_AVATAR_HAIR_COLOR_OPTIONS, + AGENT_AVATAR_HAIR_STYLE_OPTIONS, + AGENT_AVATAR_HAT_STYLE_OPTIONS, + AGENT_AVATAR_SHOE_COLOR_OPTIONS, + AGENT_AVATAR_SKIN_TONE_OPTIONS, + AGENT_AVATAR_TOP_STYLE_OPTIONS, + type AgentAvatarProfile, + createDefaultAgentAvatarProfile, +} from "@/lib/avatars/profile"; +import { AgentAvatarPreview3D } from "@/features/agents/components/AgentAvatarPreview3D"; +import { randomUUID } from "@/lib/uuid"; + +export type AgentAvatarEditorPanelProps = { + agentId: string; + agentName: string; + initialProfile: AgentAvatarProfile | null | undefined; + onSave: (profile: AgentAvatarProfile) => Promise | void; + onCancel?: () => void; + onSaved?: () => void; +}; + +const pillClassName = + "rounded-full border px-3 py-1.5 text-[11px] transition-colors"; + +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) => { + const fallbackProfile = useMemo( + () => createDefaultAgentAvatarProfile(agentId), + [agentId] + ); + const resolvedInitialProfile = initialProfile ?? fallbackProfile; + const [draft, setDraft] = useState(resolvedInitialProfile); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setDraft(resolvedInitialProfile); + }, [resolvedInitialProfile]); + + const save = async () => { + if (saving) return; + setSaving(true); + try { + await onSave(draft); + onSaved?.(); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+ Avatar creator +
+
{agentName}
+
+ Personalize this office avatar locally on this machine. +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+

+ Skin tone +

+
+ {AGENT_AVATAR_SKIN_TONE_OPTIONS.map((option) => { + const selected = draft.body.skinTone === option.color; + return ( +
+
+ +
+

+ Hair style +

+
+ {AGENT_AVATAR_HAIR_STYLE_OPTIONS.map((option) => { + const selected = draft.hair.style === option.id; + return ( + + ); + })} +
+
+ +
+

+ Hair color +

+
+ {AGENT_AVATAR_HAIR_COLOR_OPTIONS.map((option) => { + const selected = draft.hair.color === option.color; + return ( +
+
+ +
+

+ Top style +

+
+ {AGENT_AVATAR_TOP_STYLE_OPTIONS.map((option) => { + const selected = draft.clothing.topStyle === option.id; + return ( + + ); + })} +
+
+ +
+

+ Top color +

+
+ {AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => { + const selected = draft.clothing.topColor === option.color; + return ( +
+
+ +
+

+ Bottom style +

+
+ {AGENT_AVATAR_BOTTOM_STYLE_OPTIONS.map((option) => { + const selected = draft.clothing.bottomStyle === option.id; + return ( + + ); + })} +
+
+ +
+

+ Bottom color +

+
+ {AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => { + const selected = draft.clothing.bottomColor === option.color; + return ( +
+
+ +
+

+ Shoe color +

+
+ {AGENT_AVATAR_SHOE_COLOR_OPTIONS.map((option) => { + const selected = draft.clothing.shoesColor === option.color; + return ( +
+
+ +
+

+ Hat +

+
+ {AGENT_AVATAR_HAT_STYLE_OPTIONS.map((option) => { + const selected = draft.accessories.hatStyle === option.id; + return ( + + ); + })} +
+
+ +
+

+ Accessories +

+
+ {[ + { + key: "glasses" as const, + label: "Glasses", + enabled: draft.accessories.glasses, + }, + { + key: "headset" as const, + label: "Headset", + enabled: draft.accessories.headset, + }, + { + key: "backpack" as const, + label: "Backpack", + enabled: draft.accessories.backpack, + }, + ].map((option) => ( + + ))} +
+
+
+ +
+
+ ); +}; diff --git a/src/features/agents/components/AgentAvatarPreview3D.tsx b/src/features/agents/components/AgentAvatarPreview3D.tsx new file mode 100644 index 0000000..037cce5 --- /dev/null +++ b/src/features/agents/components/AgentAvatarPreview3D.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { Environment, OrbitControls } from "@react-three/drei"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { useEffect, useMemo, useRef, useState } from "react"; +import * as THREE from "three"; +import { + type AgentAvatarProfile, + createDefaultAgentAvatarProfile, +} from "@/lib/avatars/profile"; + +const PreviewFigure = ({ + profile, + onFirstFrame, +}: { + profile: AgentAvatarProfile; + onFirstFrame: () => void; +}) => { + const groupRef = useRef(null); + const reportedReadyRef = useRef(false); + + useFrame((state) => { + if (!reportedReadyRef.current) { + reportedReadyRef.current = true; + onFirstFrame(); + } + if (!groupRef.current) return; + groupRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.45) * 0.35 + 0.25; + }); + + const skin = profile.body.skinTone; + const topColor = profile.clothing.topColor; + const bottomColor = profile.clothing.bottomColor; + const shoeColor = profile.clothing.shoesColor; + const hairColor = profile.hair.color; + const accessoryColor = topColor; + const sleeveColor = profile.clothing.topStyle === "jacket" ? "#dbe4ff" : topColor; + const cuffColor = profile.clothing.topStyle === "hoodie" ? "#d1d5db" : sleeveColor; + + return ( + + + + + + + {profile.accessories.backpack ? ( + + + + + + + ) : null} + + + {profile.clothing.bottomStyle === "shorts" ? ( + <> + + + + + + + + + + ) : ( + + + + + )} + + + + + + + {profile.clothing.bottomStyle === "shorts" ? ( + <> + + + + + + + + + + ) : ( + + + + + )} + + + + + + + + + + + {profile.clothing.topStyle === "hoodie" ? ( + <> + + + + + + + + + + ) : null} + {profile.clothing.topStyle === "jacket" ? ( + <> + + + + + + + + + + ) : null} + + + + + + + {profile.clothing.topStyle === "hoodie" ? ( + + + + + ) : null} + + + + + + + + + + + {profile.clothing.topStyle === "hoodie" ? ( + + + + + ) : null} + + + + + + + + + + + + + + + + {profile.hair.style === "short" ? ( + + + + + ) : null} + {profile.hair.style === "parted" ? ( + <> + + + + + + + + + + ) : null} + {profile.hair.style === "spiky" ? ( + <> + + + + + + + + + + + + + + + + + + ) : null} + {profile.hair.style === "bun" ? ( + <> + + + + + + + + + + ) : null} + + {profile.accessories.hatStyle === "cap" ? ( + <> + + + + + + + + + + ) : null} + {profile.accessories.hatStyle === "beanie" ? ( + + + + + ) : null} + + {profile.accessories.headset ? ( + <> + + + + + + + + + + + + + + ) : null} + + + + + + + + + + {profile.accessories.glasses ? ( + <> + + + + + + + + + + + + + + ) : null} + + + + + + ); +}; + +export const AgentAvatarPreview3D = ({ + profile, + className = "", +}: { + profile: AgentAvatarProfile | null | undefined; + className?: string; +}) => { + const resolvedProfile = useMemo( + () => profile ?? createDefaultAgentAvatarProfile("preview"), + [profile] + ); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + setIsReady(false); + }, [resolvedProfile]); + + return ( +
+ {!isReady ? ( +
+
+
+ Loading avatar... +
+
+ ) : null} + + + + + + { + setIsReady(true); + }} + /> + + + +
+ ); +}; diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx index b76eb6b..7a60afe 100644 --- a/src/features/agents/components/AgentChatPanel.tsx +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -13,7 +13,7 @@ import { import type { AgentState as AgentRecord } from "@/features/agents/state/store"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Shuffle, Square, Trash2, X } from "lucide-react"; +import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Square, Trash2, X } from "lucide-react"; import type { GatewayModelChoice } from "@/lib/gateway/models"; import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown"; import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; @@ -1481,15 +1481,15 @@ export const AgentChatPanel = ({ className="nodrag ui-btn-icon ui-btn-icon-xs agent-avatar-shuffle-btn absolute bottom-0 right-0" style={{ "--ui-btn-icon-size": "1.1rem" } as React.CSSProperties} type="button" - aria-label="Shuffle avatar" - data-testid="agent-avatar-shuffle" + aria-label="Customize avatar" + data-testid="agent-avatar-customize" onClick={(event) => { event.preventDefault(); event.stopPropagation(); onAvatarShuffle(); }} > - +
diff --git a/src/features/agents/components/AgentEditorModal.tsx b/src/features/agents/components/AgentEditorModal.tsx new file mode 100644 index 0000000..7a5fad6 --- /dev/null +++ b/src/features/agents/components/AgentEditorModal.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Brain, + Database, + FileText, + HeartPulse, + Palette, + Shield, + UserRound, + Wrench, + X, +} from "lucide-react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel"; +import { AgentBrainPanel } from "@/features/agents/components/inspect/AgentBrainPanel"; +import type { AgentAvatarProfile } from "@/lib/avatars/profile"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import type { AgentFileName } from "@/lib/agents/agentFiles"; +import { AGENT_FILE_META } from "@/lib/agents/agentFiles"; +import { renameGatewayAgent } from "@/lib/gateway/agentConfig"; + +export type AgentEditorSection = "avatar" | AgentFileName; + +type AgentEditorModalProps = { + open: boolean; + client: GatewayClient | null; + agents: AgentState[]; + agent: AgentState; + initialSection?: AgentEditorSection; + onClose: () => void; + onAvatarSave: (agentId: string, profile: AgentAvatarProfile) => Promise | void; + onRename?: (agentId: string, name: string) => Promise; +}; + +const menuButtonClassName = + "flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors"; + +const editorSections: Array<{ + id: AgentEditorSection; + label: string; + hint: string; + icon: typeof Palette; +}> = [ + { + id: "IDENTITY.md", + label: "Identity", + hint: AGENT_FILE_META["IDENTITY.md"].hint, + icon: FileText, + }, + { + id: "avatar", + label: "Avatar", + hint: "Office appearance.", + icon: Palette, + }, + { + id: "SOUL.md", + label: "Soul", + hint: AGENT_FILE_META["SOUL.md"].hint, + icon: Brain, + }, + { + id: "AGENTS.md", + label: "Agents", + hint: AGENT_FILE_META["AGENTS.md"].hint, + icon: Shield, + }, + { + id: "USER.md", + label: "User", + hint: AGENT_FILE_META["USER.md"].hint, + icon: UserRound, + }, + { + id: "TOOLS.md", + label: "Tools", + hint: AGENT_FILE_META["TOOLS.md"].hint, + icon: Wrench, + }, + { + id: "MEMORY.md", + label: "Memory", + hint: AGENT_FILE_META["MEMORY.md"].hint, + icon: Database, + }, + { + id: "HEARTBEAT.md", + label: "Heartbeat", + hint: AGENT_FILE_META["HEARTBEAT.md"].hint, + icon: HeartPulse, + }, +]; + +export const AgentEditorModal = ({ + open, + client, + agents, + agent, + initialSection = "avatar", + onClose, + onAvatarSave, + onRename, +}: AgentEditorModalProps) => { + const [activeSection, setActiveSection] = useState(initialSection); + + useEffect(() => { + if (!open) return; + setActiveSection(initialSection); + }, [initialSection, open, agent.agentId]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > + +
+ + +
+ {activeSection === "avatar" ? ( + onAvatarSave(agent.agentId, profile)} + /> + ) : client ? ( +
+
+
+ Agent file editor +
+
+ Edit one agent file at a time and save it through the gateway. +
+
+
+ { + if (!client) return false; + try { + await renameGatewayAgent({ client, agentId, name }); + return true; + } catch { + return false; + } + }) + } + /> +
+
+ ) : ( +
+ Connect to a gateway to edit brain files. +
+ )} +
+
+
+
+ ); +}; diff --git a/src/features/agents/components/inspect/AgentBrainPanel.tsx b/src/features/agents/components/inspect/AgentBrainPanel.tsx index 4a093b0..37fe387 100644 --- a/src/features/agents/components/inspect/AgentBrainPanel.tsx +++ b/src/features/agents/components/inspect/AgentBrainPanel.tsx @@ -1,9 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import type { AgentState } from "@/features/agents/state/store"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { + AGENT_FILE_META, + AGENT_FILE_PLACEHOLDERS, + type AgentFileName, +} from "@/lib/agents/agentFiles"; import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder"; import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor"; @@ -11,7 +16,10 @@ export type AgentBrainPanelProps = { client: GatewayClient; agents: AgentState[]; selectedAgentId: string | null; + activeSection?: AgentFileName; + onCancel?: () => void; onUnsavedChangesChange?: (dirty: boolean) => void; + onRename?: (agentId: string, name: string) => Promise; }; const AgentBrainPanelSection = ({ @@ -31,7 +39,10 @@ export const AgentBrainPanel = ({ client, agents, selectedAgentId, + activeSection, + onCancel, onUnsavedChangesChange, + onRename, }: AgentBrainPanelProps) => { const selectedAgent = useMemo( () => @@ -49,9 +60,9 @@ export const AgentBrainPanel = ({ agentFilesError, setAgentFileContent, saveAgentFiles, - discardAgentFileChanges, } = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null }); const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]); + const [saveError, setSaveError] = useState(null); const setIdentityField = useCallback( (field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => { @@ -65,8 +76,29 @@ export const AgentBrainPanel = ({ const handleSave = useCallback(async () => { if (agentFilesLoading || agentFilesSaving || !agentFilesDirty) return; - await saveAgentFiles(); - }, [agentFilesDirty, agentFilesLoading, agentFilesSaving, saveAgentFiles]); + setSaveError(null); + const saved = await saveAgentFiles(); + if (!saved || !selectedAgent || !onRename) { + return; + } + const nextName = draft.identity.name.trim(); + const currentName = selectedAgent.name.trim(); + if (!nextName || nextName === currentName) { + return; + } + const renamed = await onRename(selectedAgent.agentId, nextName); + if (!renamed) { + setSaveError("Saved IDENTITY.md, but could not rename the live agent."); + } + }, [ + agentFilesDirty, + agentFilesLoading, + agentFilesSaving, + draft.identity.name, + onRename, + saveAgentFiles, + selectedAgent, + ]); useEffect(() => { onUnsavedChangesChange?.(agentFilesDirty); @@ -78,6 +110,102 @@ export const AgentBrainPanel = ({ }; }, [onUnsavedChangesChange]); + const renderMarkdownEditor = useCallback( + (name: Exclude) => ( + +
{AGENT_FILE_META[name].hint}
+