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 <iamlukethedev@users.noreply.github.com>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
Luke The Dev
2026-03-23 18:04:37 -05:00
committed by GitHub
parent 5e7812c352
commit c9789c2148
32 changed files with 1504 additions and 181 deletions
+1 -13
View File
@@ -58,21 +58,9 @@ const readJsonFile = (filePath) => {
const DEFAULT_GATEWAY_URL = "ws://localhost:18789"; const DEFAULT_GATEWAY_URL = "ws://localhost:18789";
const OPENCLAW_CONFIG_FILENAME = "openclaw.json"; 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 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) => { const readOpenclawGatewayDefaults = (env = process.env) => {
try { try {
const stateDir = resolveStateDir(env); const stateDir = resolveStateDir(env);
@@ -100,7 +88,7 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null; const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
const url = typeof gateway?.url === "string" ? gateway.url.trim() : ""; const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
const token = typeof gateway?.token === "string" ? gateway.token.trim() : ""; const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
if (!token && (!url || isLocalGatewayUrl(url))) { if (!token) {
const defaults = readOpenclawGatewayDefaults(env); const defaults = readOpenclawGatewayDefaults(env);
if (defaults) { if (defaults) {
return { return {
+2 -2
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local"; import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; import { isLikelyLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import { import {
resolveConfiguredSshTarget, resolveConfiguredSshTarget,
resolveGatewaySshTargetFromGatewayUrl, resolveGatewaySshTargetFromGatewayUrl,
@@ -30,7 +30,7 @@ const resolveAgentStateSshTarget = (): string | null => {
if (configured) return configured; if (configured) return configured;
const settings = loadStudioSettings(); const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? ""; const gatewayUrl = settings.gateway?.url ?? "";
if (isLocalGatewayUrl(gatewayUrl)) return null; if (isLikelyLocalGatewayUrl(gatewayUrl)) return null;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; import { isLikelyLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import { import {
resolveConfiguredSshTarget, resolveConfiguredSshTarget,
resolveGatewaySshTargetFromGatewayUrl, resolveGatewaySshTargetFromGatewayUrl,
@@ -151,7 +151,7 @@ PY
const resolveSshTarget = (): string | null => { const resolveSshTarget = (): string | null => {
const settings = loadStudioSettings(); const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? ""; const gatewayUrl = settings.gateway?.url ?? "";
if (isLocalGatewayUrl(gatewayUrl)) return null; if (isLikelyLocalGatewayUrl(gatewayUrl)) return null;
const configured = resolveConfiguredSshTarget(process.env); const configured = resolveConfiguredSshTarget(process.env);
if (configured) return configured; if (configured) return configured;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; 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 { removeSkillLocally } from "@/lib/skills/remove-local";
import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types"; import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types";
import { import {
@@ -33,7 +33,7 @@ const resolveSkillRemovalSshTarget = (): string | null => {
if (configured) return configured; if (configured) return configured;
const settings = loadStudioSettings(); const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? ""; const gatewayUrl = settings.gateway?.url ?? "";
if (isLocalGatewayUrl(gatewayUrl)) return null; if (isLikelyLocalGatewayUrl(gatewayUrl)) return null;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
}; };
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { RefreshCcw, Shuffle } from "lucide-react"; import { RefreshCcw, Shuffle } from "lucide-react";
import { import {
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS, AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
@@ -22,8 +22,16 @@ export type AgentAvatarEditorPanelProps = {
agentName: string; agentName: string;
initialProfile: AgentAvatarProfile | null | undefined; initialProfile: AgentAvatarProfile | null | undefined;
onSave: (profile: AgentAvatarProfile) => Promise<void> | void; onSave: (profile: AgentAvatarProfile) => Promise<void> | void;
onDraftChange?: (profile: AgentAvatarProfile) => void;
onCancel?: () => void; onCancel?: () => void;
onSaved?: () => void; onSaved?: () => void;
cancelLabel?: string;
saveLabel?: string;
showActions?: boolean;
};
export type AgentAvatarEditorPanelHandle = {
save: () => Promise<void>;
}; };
const pillClassName = const pillClassName =
@@ -32,14 +40,24 @@ const pillClassName =
const colorSwatchClassName = const colorSwatchClassName =
"h-7 w-7 rounded-full border-2 transition-transform hover:scale-105"; "h-7 w-7 rounded-full border-2 transition-transform hover:scale-105";
export const AgentAvatarEditorPanel = ({ export const AgentAvatarEditorPanel = forwardRef<
AgentAvatarEditorPanelHandle,
AgentAvatarEditorPanelProps
>(function AgentAvatarEditorPanel(
{
agentId, agentId,
agentName, agentName,
initialProfile, initialProfile,
onSave, onSave,
onDraftChange,
onCancel, onCancel,
onSaved, onSaved,
}: AgentAvatarEditorPanelProps) => { cancelLabel = "Cancel",
saveLabel = "Save avatar",
showActions = true,
}: AgentAvatarEditorPanelProps,
ref
) {
const fallbackProfile = useMemo( const fallbackProfile = useMemo(
() => createDefaultAgentAvatarProfile(agentId), () => createDefaultAgentAvatarProfile(agentId),
[agentId] [agentId]
@@ -52,7 +70,11 @@ export const AgentAvatarEditorPanel = ({
setDraft(resolvedInitialProfile); setDraft(resolvedInitialProfile);
}, [resolvedInitialProfile]); }, [resolvedInitialProfile]);
const save = async () => { useEffect(() => {
onDraftChange?.(draft);
}, [draft, onDraftChange]);
const save = useCallback(async () => {
if (saving) return; if (saving) return;
setSaving(true); setSaving(true);
try { try {
@@ -61,7 +83,15 @@ export const AgentAvatarEditorPanel = ({
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; }, [draft, onSave, onSaved, saving]);
useImperativeHandle(
ref,
() => ({
save,
}),
[save]
);
return ( return (
<div className="grid h-full min-h-0 gap-0 xl:grid-cols-[360px_minmax(0,1fr)]"> <div className="grid h-full min-h-0 gap-0 xl:grid-cols-[360px_minmax(0,1fr)]">
@@ -99,6 +129,7 @@ export const AgentAvatarEditorPanel = ({
</div> </div>
<div className="min-h-0 overflow-y-auto p-5"> <div className="min-h-0 overflow-y-auto p-5">
{showActions ? (
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4"> <div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
<button <button
type="button" type="button"
@@ -106,7 +137,7 @@ export const AgentAvatarEditorPanel = ({
onClick={onCancel} onClick={onCancel}
disabled={saving} disabled={saving}
> >
Cancel {cancelLabel}
</button> </button>
<button <button
type="button" type="button"
@@ -116,9 +147,10 @@ export const AgentAvatarEditorPanel = ({
}} }}
disabled={saving} disabled={saving}
> >
{saving ? "Saving..." : "Save avatar"} {saving ? "Saving..." : saveLabel}
</button> </button>
</div> </div>
) : null}
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<section className="space-y-3"> <section className="space-y-3">
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground"> <h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
@@ -420,4 +452,4 @@ export const AgentAvatarEditorPanel = ({
</div> </div>
</div> </div>
); );
}; });
@@ -305,11 +305,9 @@ export const AgentAvatarPreview3D = ({
() => profile ?? createDefaultAgentAvatarProfile("preview"), () => profile ?? createDefaultAgentAvatarProfile("preview"),
[profile] [profile]
); );
const [isReady, setIsReady] = useState(false); const profileKey = useMemo(() => JSON.stringify(resolvedProfile), [resolvedProfile]);
const [readyProfileKey, setReadyProfileKey] = useState<string | null>(null);
useEffect(() => { const isReady = readyProfileKey === profileKey;
setIsReady(false);
}, [resolvedProfile]);
return ( return (
<div className={`relative ${className}`}> <div className={`relative ${className}`}>
@@ -321,7 +319,7 @@ export const AgentAvatarPreview3D = ({
</div> </div>
</div> </div>
) : null} ) : null}
<Canvas camera={{ position: [0, 0.7, 2.5], fov: 34 }}> <Canvas key={profileKey} camera={{ position: [0, 0.7, 2.5], fov: 34 }}>
<color attach="background" args={["#070b16"]} /> <color attach="background" args={["#070b16"]} />
<ambientLight intensity={1.4} /> <ambientLight intensity={1.4} />
<directionalLight position={[3, 4, 5]} intensity={2.4} /> <directionalLight position={[3, 4, 5]} intensity={2.4} />
@@ -329,7 +327,7 @@ export const AgentAvatarPreview3D = ({
<PreviewFigure <PreviewFigure
profile={resolvedProfile} profile={resolvedProfile}
onFirstFrame={() => { onFirstFrame={() => {
setIsReady(true); setReadyProfileKey(profileKey);
}} }}
/> />
<Environment preset="city" /> <Environment preset="city" />
@@ -1100,8 +1100,8 @@ const AgentChatComposer = memo(function AgentChatComposer({
<div <div
className={`mb-2 rounded-md border px-2.5 py-1.5 font-mono text-[10px] tracking-[0.02em] ${ className={`mb-2 rounded-md border px-2.5 py-1.5 font-mono text-[10px] tracking-[0.02em] ${
voiceError voiceError
? "border-red-500/30 bg-red-500/10 text-red-200" ? "ui-badge-status-error"
: "border-amber-700/35 bg-amber-500/8 text-amber-200" : "ui-badge-status-approval"
}`} }`}
data-testid="agent-voice-status" data-testid="agent-voice-status"
> >
@@ -1122,7 +1122,7 @@ const AgentChatComposer = memo(function AgentChatComposer({
<button <button
className={`rounded-md border px-2.5 py-2 font-mono text-[11px] font-medium tracking-[0.02em] transition ${ className={`rounded-md border px-2.5 py-2 font-mono text-[11px] font-medium tracking-[0.02em] transition ${
voiceRecording voiceRecording
? "border-red-500/60 bg-red-500/12 text-red-200 hover:bg-red-500/18" ? "ui-btn-danger"
: "border-border/70 bg-surface-3 text-white hover:bg-surface-2 hover:text-white" : "border-border/70 bg-surface-3 text-white hover:bg-surface-2 hover:text-white"
} disabled:cursor-not-allowed disabled:border-border/30 disabled:bg-muted/20 disabled:text-muted-foreground`} } disabled:cursor-not-allowed disabled:border-border/30 disabled:bg-muted/20 disabled:text-muted-foreground`}
type="button" type="button"
@@ -1152,7 +1152,7 @@ const AgentChatComposer = memo(function AgentChatComposer({
</span> </span>
) : null} ) : null}
<button <button
className="rounded border border-amber-700/50 bg-[#0e0a04]/90 px-3 py-2 font-mono text-[12px] font-medium tracking-wider text-amber-500/80 shadow-lg backdrop-blur transition-colors hover:border-amber-600/70 hover:text-amber-400 disabled:cursor-not-allowed disabled:border-border/30 disabled:bg-muted/20 disabled:text-amber-900/50" className="rounded border border-[color:var(--status-approval-border)] bg-[#0e0a04]/90 px-3 py-2 font-mono text-[12px] font-medium tracking-wider text-[color:var(--status-approval-fg)] shadow-lg backdrop-blur transition-colors hover:border-[color:var(--status-approval-border)] hover:text-[color:var(--status-approval-fg)] disabled:cursor-not-allowed disabled:border-border/30 disabled:bg-muted/20 disabled:text-muted-foreground"
type="button" type="button"
onClick={onSend} onClick={onSend}
disabled={sendDisabled} disabled={sendDisabled}
@@ -1562,7 +1562,7 @@ export const AgentChatPanel = ({
<div className="mt-0.5 flex items-center gap-2"> <div className="mt-0.5 flex items-center gap-2">
<button <button
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-amber-800/40 bg-amber-900/20 px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-amber-400/80 transition hover:bg-amber-900/30 hover:text-amber-300 disabled:cursor-not-allowed disabled:opacity-40" className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-[color:var(--status-approval-fg)] transition hover:bg-[color:var(--status-approval-bg)] hover:text-[color:var(--status-approval-fg)] disabled:cursor-not-allowed disabled:opacity-40"
type="button" type="button"
data-testid="agent-new-session-toggle" data-testid="agent-new-session-toggle"
aria-label="Start new session" aria-label="Start new session"
@@ -0,0 +1,566 @@
"use client";
import { useMemo, useState } from "react";
import {
AgentIdentityFields,
type AgentIdentityValues,
} from "@/features/agents/components/AgentIdentityFields";
import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel";
import {
AGENT_FILE_META,
AGENT_FILE_PLACEHOLDERS,
} from "@/lib/agents/agentFiles";
import {
createEmptyPersonalityDraft,
type PersonalityBuilderDraft,
} from "@/lib/agents/personalityBuilder";
import {
createDefaultAgentAvatarProfile,
type AgentAvatarProfile,
} from "@/lib/avatars/profile";
import { randomUUID } from "@/lib/uuid";
type AgentCreateWizardModalProps = {
open: boolean;
suggestedName?: string;
busy?: boolean;
submitError?: string | null;
statusLine?: string | null;
onClose: (createdAgentId: string | null) => void;
onCreateAgent: (identity: AgentIdentityValues) => Promise<string | null>;
onFinishWizard: (params: {
agentId: string;
draft: PersonalityBuilderDraft;
profile: AgentAvatarProfile;
}) => Promise<void>;
};
const stepClassName =
"rounded-full border px-3 py-1 font-mono text-[10px] uppercase tracking-[0.16em]";
const inputClassName =
"h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none";
const textAreaClassName =
"min-h-[180px] w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 text-sm leading-6 text-foreground outline-none";
type WizardStepId =
| "identity"
| "avatar"
| "SOUL.md"
| "AGENTS.md"
| "USER.md"
| "TOOLS.md"
| "MEMORY.md"
| "HEARTBEAT.md";
const wizardSteps: Array<{ id: WizardStepId; label: string; hint: string }> = [
{
id: "identity",
label: "Identity",
hint: "Create the live agent first, then fill in the rest step by step.",
},
{
id: "avatar",
label: "Avatar",
hint: "Customize the office appearance before writing the rest of the profile.",
},
{
id: "SOUL.md",
label: "Soul",
hint: AGENT_FILE_META["SOUL.md"].hint,
},
{
id: "AGENTS.md",
label: "Agents",
hint: AGENT_FILE_META["AGENTS.md"].hint,
},
{
id: "USER.md",
label: "User",
hint: AGENT_FILE_META["USER.md"].hint,
},
{
id: "TOOLS.md",
label: "Tools",
hint: AGENT_FILE_META["TOOLS.md"].hint,
},
{
id: "MEMORY.md",
label: "Memory",
hint: AGENT_FILE_META["MEMORY.md"].hint,
},
{
id: "HEARTBEAT.md",
label: "Heartbeat",
hint: AGENT_FILE_META["HEARTBEAT.md"].hint,
},
];
const buildInitialDraft = (suggestedName: string): PersonalityBuilderDraft => {
const draft = createEmptyPersonalityDraft();
draft.identity.name = suggestedName.trim() || "New Agent";
return draft;
};
const WizardField = ({
label,
value,
placeholder,
disabled,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
onChange: (value: string) => void;
}) => (
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
{label}
<input
className={inputClassName}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => {
onChange(event.target.value);
}}
/>
</label>
);
const WizardTextAreaField = ({
label,
value,
placeholder,
disabled,
rows = 6,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
rows?: number;
onChange: (value: string) => void;
}) => (
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
{label}
<textarea
className={textAreaClassName}
value={value}
placeholder={placeholder}
rows={rows}
disabled={disabled}
onChange={(event) => {
onChange(event.target.value);
}}
/>
</label>
);
export function AgentCreateWizardModal({
open,
suggestedName = "",
busy = false,
submitError = null,
statusLine = null,
onClose,
onCreateAgent,
onFinishWizard,
}: AgentCreateWizardModalProps) {
const [step, setStep] = useState<WizardStepId>("identity");
const [draft, setDraft] = useState<PersonalityBuilderDraft>(() =>
buildInitialDraft(suggestedName),
);
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
const [draftAvatarProfile, setDraftAvatarProfile] = useState<AgentAvatarProfile>(() =>
createDefaultAgentAvatarProfile(randomUUID()),
);
const [finishing, setFinishing] = useState(false);
const canCreate = useMemo(() => draft.identity.name.trim().length > 0, [draft.identity.name]);
const activeStepIndex = wizardSteps.findIndex((entry) => entry.id === step);
const activeStep = wizardSteps[activeStepIndex] ?? wizardSteps[0];
const isWorking = busy || finishing;
const isFinalStep = step === "HEARTBEAT.md";
const statusCopy = finishing ? "Saving the agent files and avatar." : statusLine;
const updateDraft = <K extends keyof PersonalityBuilderDraft>(
key: K,
value: PersonalityBuilderDraft[K],
) => {
setDraft((current) => ({
...current,
[key]: value,
}));
};
const advanceStep = async () => {
if (step === "identity") {
if (!canCreate || isWorking) return;
if (!createdAgentId) {
const agentId = await onCreateAgent({
name: draft.identity.name,
creature: draft.identity.creature,
vibe: draft.identity.vibe,
emoji: draft.identity.emoji,
});
if (!agentId) return;
setCreatedAgentId(agentId);
}
setStep("avatar");
return;
}
if (isFinalStep) {
if (!createdAgentId || isWorking) return;
setFinishing(true);
try {
await onFinishWizard({
agentId: createdAgentId,
draft,
profile: draftAvatarProfile,
});
} finally {
setFinishing(false);
}
return;
}
const nextStep = wizardSteps[activeStepIndex + 1];
if (nextStep) {
setStep(nextStep.id);
}
};
const stepActionLabel =
step === "identity" && !createdAgentId
? busy
? "Creating..."
: "Create and continue"
: isFinalStep
? isWorking
? "Saving..."
: "Finish wizard"
: "Next";
if (!open) return null;
return (
<div
className="fixed inset-0 z-[140] flex items-center justify-center bg-background/84 p-4"
role="dialog"
aria-modal="true"
aria-label="Create agent wizard"
onClick={() => {
if (!isWorking) {
onClose(createdAgentId);
}
}}
>
<div
className="ui-panel flex h-[min(92vh,980px)] w-full max-w-6xl flex-col overflow-hidden shadow-xs"
onClick={(event) => event.stopPropagation()}
>
<div className="border-b border-border/40 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
New agent wizard
</div>
<div className="mt-1 text-lg font-semibold text-foreground">
Create an agent step by step
</div>
<div className="mt-1 text-sm text-muted-foreground">
Start with identity, then build the rest of the profile before finishing.
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="ui-btn-ghost px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-60"
disabled={isWorking}
onClick={() => {
onClose(createdAgentId);
}}
>
Close
</button>
{activeStepIndex > 0 ? (
<button
type="button"
className="ui-btn-ghost px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-60"
disabled={isWorking}
onClick={() => {
const previousStep = wizardSteps[activeStepIndex - 1];
if (previousStep) {
setStep(previousStep.id);
}
}}
>
Back
</button>
) : null}
<button
type="button"
className="ui-btn-primary px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-60"
disabled={(step === "identity" && !canCreate) || isWorking}
onClick={() => {
void advanceStep();
}}
>
{stepActionLabel}
</button>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{wizardSteps.map((wizardStep, index) => {
const complete = index < activeStepIndex;
const active = wizardStep.id === step;
return (
<span
key={wizardStep.id}
className={`${stepClassName} ${
active
? "border-primary/40 bg-primary/10 text-foreground"
: complete
? "border-emerald-400/35 bg-emerald-500/10 text-foreground"
: "border-border/45 bg-background/40 text-muted-foreground"
}`}
>
{index + 1}. {wizardStep.label}
</span>
);
})}
</div>
<div className="mt-4 text-sm text-muted-foreground">{activeStep.hint}</div>
{statusCopy ? (
<div className="mt-4 rounded-md border border-border/45 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{statusCopy}
</div>
) : null}
{submitError ? (
<div className="ui-alert-danger mt-4 rounded-md px-3 py-2 text-xs">
{submitError}
</div>
) : null}
</div>
{step === "identity" ? (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col">
<section className="space-y-3">
<h3 className="text-sm font-medium text-foreground">Identity</h3>
<div className="text-xs text-muted-foreground">
Confirm the live agent name first, then fill in the rest of `IDENTITY.md`.
</div>
<AgentIdentityFields
values={draft.identity}
disabled={isWorking}
onChange={(field, value) => {
updateDraft("identity", {
...draft.identity,
[field]: value,
});
}}
/>
</section>
<div className="mt-6 rounded-xl border border-border/45 bg-muted/20 p-4 text-sm text-muted-foreground">
Creating the agent in this step makes it available in OpenClaw immediately so the
wizard can save the full profile through the gateway in later steps.
</div>
</div>
</div>
) : createdAgentId ? (
<>
{step === "avatar" ? (
<AgentAvatarEditorPanel
agentId={createdAgentId}
agentName={draft.identity.name.trim() || "New Agent"}
initialProfile={draftAvatarProfile}
showActions={false}
onDraftChange={(profile) => {
setDraftAvatarProfile(profile);
}}
onSave={async (profile) => {
setDraftAvatarProfile(profile);
}}
/>
) : step === "SOUL.md" ? (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 pb-8">
<section className="space-y-3">
<h3 className="text-sm font-medium text-foreground">Soul</h3>
<div className="grid gap-4">
<WizardTextAreaField
label="Core truths"
value={draft.soul.coreTruths}
placeholder="e.g. Protect the user's time. Prefer clarity over theatrics."
disabled={isWorking}
rows={5}
onChange={(value) => {
updateDraft("soul", { ...draft.soul, coreTruths: value });
}}
/>
<WizardTextAreaField
label="Boundaries"
value={draft.soul.boundaries}
placeholder="e.g. Do not bluff. Say when something is uncertain."
disabled={isWorking}
rows={5}
onChange={(value) => {
updateDraft("soul", { ...draft.soul, boundaries: value });
}}
/>
<WizardTextAreaField
label="Vibe"
value={draft.soul.vibe}
placeholder="e.g. Friendly, direct, and lightly playful."
disabled={isWorking}
rows={4}
onChange={(value) => {
updateDraft("soul", { ...draft.soul, vibe: value });
}}
/>
<WizardTextAreaField
label="Continuity"
value={draft.soul.continuity}
placeholder="e.g. Keep naming, preferences, and previous decisions consistent."
disabled={isWorking}
rows={4}
onChange={(value) => {
updateDraft("soul", { ...draft.soul, continuity: value });
}}
/>
</div>
</section>
</div>
</div>
) : step === "USER.md" ? (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 pb-8">
<section className="space-y-3">
<h3 className="text-sm font-medium text-foreground">User</h3>
<div className="grid gap-4 sm:grid-cols-2">
<WizardField
label="Name"
value={draft.user.name}
placeholder="e.g. Luke"
disabled={isWorking}
onChange={(value) => {
updateDraft("user", { ...draft.user, name: value });
}}
/>
<WizardField
label="What to call them"
value={draft.user.callThem}
placeholder="e.g. Luke"
disabled={isWorking}
onChange={(value) => {
updateDraft("user", { ...draft.user, callThem: value });
}}
/>
<WizardField
label="Pronouns"
value={draft.user.pronouns}
placeholder="e.g. he/him"
disabled={isWorking}
onChange={(value) => {
updateDraft("user", { ...draft.user, pronouns: value });
}}
/>
<WizardField
label="Timezone"
value={draft.user.timezone}
placeholder="e.g. America/Chicago"
disabled={isWorking}
onChange={(value) => {
updateDraft("user", { ...draft.user, timezone: value });
}}
/>
<div className="sm:col-span-2">
<WizardField
label="Notes"
value={draft.user.notes}
placeholder="e.g. Prefers concise answers and fast iteration."
disabled={isWorking}
onChange={(value) => {
updateDraft("user", { ...draft.user, notes: value });
}}
/>
</div>
<div className="sm:col-span-2">
<WizardTextAreaField
label="Context"
value={draft.user.context}
placeholder="e.g. Building Claw3D, likes practical UI improvements, and wants direct feedback."
disabled={isWorking}
rows={7}
onChange={(value) => {
updateDraft("user", { ...draft.user, context: value });
}}
/>
</div>
</div>
</section>
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 pb-8">
<section className="space-y-3">
<h3 className="text-sm font-medium text-foreground">{activeStep.label}</h3>
<div className="text-xs text-muted-foreground">{activeStep.hint}</div>
<textarea
className={`${textAreaClassName} min-h-[56vh] font-mono`}
value={
step === "AGENTS.md"
? draft.agents
: step === "TOOLS.md"
? draft.tools
: step === "MEMORY.md"
? draft.memory
: step === "HEARTBEAT.md"
? draft.heartbeat
: ""
}
placeholder={
AGENT_FILE_PLACEHOLDERS[
step as Extract<
WizardStepId,
"AGENTS.md" | "TOOLS.md" | "MEMORY.md" | "HEARTBEAT.md"
>
]
}
disabled={isWorking}
onChange={(event) => {
const nextValue = event.target.value;
if (step === "AGENTS.md") {
updateDraft("agents", nextValue);
return;
}
if (step === "TOOLS.md") {
updateDraft("tools", nextValue);
return;
}
if (step === "MEMORY.md") {
updateDraft("memory", nextValue);
return;
}
if (step === "HEARTBEAT.md") {
updateDraft("heartbeat", nextValue);
}
}}
/>
</section>
</div>
</div>
)}
</>
) : null}
</div>
</div>
);
}
@@ -1,13 +1,16 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { import {
Brain, Brain,
ChevronLeft,
ChevronRight,
Database, Database,
FileText, FileText,
HeartPulse, HeartPulse,
Palette, Palette,
Shield, Shield,
Trash2,
UserRound, UserRound,
Wrench, Wrench,
X, X,
@@ -32,6 +35,8 @@ type AgentEditorModalProps = {
onClose: () => void; onClose: () => void;
onAvatarSave: (agentId: string, profile: AgentAvatarProfile) => Promise<void> | void; onAvatarSave: (agentId: string, profile: AgentAvatarProfile) => Promise<void> | void;
onRename?: (agentId: string, name: string) => Promise<boolean>; onRename?: (agentId: string, name: string) => Promise<boolean>;
onDelete?: (agentId: string) => Promise<void> | void;
onNavigateAgent?: (agentId: string, section: AgentEditorSection) => void;
}; };
const menuButtonClassName = const menuButtonClassName =
@@ -102,13 +107,17 @@ export const AgentEditorModal = ({
onClose, onClose,
onAvatarSave, onAvatarSave,
onRename, onRename,
onDelete,
onNavigateAgent,
}: AgentEditorModalProps) => { }: AgentEditorModalProps) => {
const [activeSection, setActiveSection] = useState<AgentEditorSection>(initialSection); const [activeSection, setActiveSection] = useState<AgentEditorSection>(initialSection);
const activeAgentIndex = agents.findIndex((entry) => entry.agentId === agent.agentId);
useEffect(() => { const previousAgent =
if (!open) return; activeAgentIndex > 0 ? agents[activeAgentIndex - 1] : null;
setActiveSection(initialSection); const nextAgent =
}, [initialSection, open, agent.agentId]); activeAgentIndex >= 0 && activeAgentIndex < agents.length - 1
? agents[activeAgentIndex + 1]
: null;
if (!open) return null; if (!open) return null;
@@ -144,6 +153,34 @@ export const AgentEditorModal = ({
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
Edit avatar and agent brain settings from the office. Edit avatar and agent brain settings from the office.
</div> </div>
{onNavigateAgent ? (
<div className="mt-4 flex items-center gap-2">
<button
type="button"
onClick={() => {
if (!previousAgent) return;
onNavigateAgent(previousAgent.agentId, activeSection);
}}
disabled={!previousAgent}
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/40 px-2.5 py-1.5 text-xs text-foreground transition-colors hover:border-border hover:bg-background/70 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft className="h-3.5 w-3.5" />
<span>Previous</span>
</button>
<button
type="button"
onClick={() => {
if (!nextAgent) return;
onNavigateAgent(nextAgent.agentId, activeSection);
}}
disabled={!nextAgent}
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/40 px-2.5 py-1.5 text-xs text-foreground transition-colors hover:border-border hover:bg-background/70 disabled:cursor-not-allowed disabled:opacity-40"
>
<span>Next</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
) : null}
</div> </div>
<div className="flex-1 space-y-2 overflow-y-auto p-3"> <div className="flex-1 space-y-2 overflow-y-auto p-3">
@@ -169,6 +206,25 @@ export const AgentEditorModal = ({
); );
})} })}
</div> </div>
{onDelete ? (
<div className="border-t border-border/40 p-3">
<button
type="button"
onClick={() => {
void onDelete(agent.agentId);
}}
className="flex w-full items-center gap-3 rounded-xl border border-red-500/40 bg-red-950/55 px-3 py-3 text-left text-red-100 transition-colors hover:border-red-300/65 hover:bg-red-900/75 hover:text-white"
>
<Trash2 className="h-4 w-4" />
<div>
<div className="text-sm font-semibold text-inherit">Delete Agent</div>
<div className="text-xs text-red-100/85">
Remove this agent from Claw3D and OpenClaw.
</div>
</div>
</button>
</div>
) : null}
</aside> </aside>
<section className="flex min-w-0 flex-1 flex-col"> <section className="flex min-w-0 flex-1 flex-col">
@@ -0,0 +1,76 @@
"use client";
export type AgentIdentityValues = {
name: string;
creature: string;
vibe: string;
emoji: string;
};
type AgentIdentityFieldsProps = {
values: AgentIdentityValues;
disabled?: boolean;
onChange: (field: keyof AgentIdentityValues, value: string) => void;
};
const inputClassName =
"h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none";
export function AgentIdentityFields({
values,
disabled = false,
onChange,
}: AgentIdentityFieldsProps) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Name
<input
className={inputClassName}
value={values.name}
placeholder="e.g. Luke"
disabled={disabled}
onChange={(event) => {
onChange("name", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Role
<input
className={inputClassName}
value={values.creature}
placeholder="e.g. Product Designer"
disabled={disabled}
onChange={(event) => {
onChange("creature", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Vibe
<input
className={inputClassName}
value={values.vibe}
placeholder="e.g. Calm, sharp, and helpful"
disabled={disabled}
onChange={(event) => {
onChange("vibe", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Emoji
<input
className={inputClassName}
value={values.emoji}
placeholder="e.g. ✨"
disabled={disabled}
onChange={(event) => {
onChange("emoji", event.target.value);
}}
/>
</label>
</div>
);
}
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import type { AgentState } from "@/features/agents/state/store"; import type { AgentState } from "@/features/agents/state/store";
import type { GatewayClient } from "@/lib/gateway/GatewayClient"; import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { AgentIdentityFields } from "@/features/agents/components/AgentIdentityFields";
import { import {
AGENT_FILE_META, AGENT_FILE_META,
AGENT_FILE_PLACEHOLDERS, AGENT_FILE_PLACEHOLDERS,
@@ -140,52 +141,13 @@ export const AgentBrainPanel = ({
Changing <span className="font-medium text-foreground">Name</span> here also renames the live agent Changing <span className="font-medium text-foreground">Name</span> here also renames the live agent
when you save. when you save.
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <AgentIdentityFields
<label className="flex flex-col gap-2 text-xs text-muted-foreground"> values={draft.identity}
Name
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.name}
disabled={agentFilesLoading || agentFilesSaving} disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => { onChange={(field, value) => {
setIdentityField("name", event.target.value); setIdentityField(field, value);
}} }}
/> />
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Creature
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.creature}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("creature", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Vibe
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.vibe}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("vibe", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Emoji
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.emoji}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("emoji", event.target.value);
}}
/>
</label>
</div>
</section> </section>
), ),
[agentFilesLoading, agentFilesSaving, draft.identity, setIdentityField], [agentFilesLoading, agentFilesSaving, draft.identity, setIdentityField],
@@ -249,7 +211,11 @@ export const AgentBrainPanel = ({
</button> </button>
</div> </div>
<div className="space-y-8 pb-8">{renderedSections}</div> <div className="space-y-8 pb-8">
{renderedSections.map((section, index) => (
<div key={`${activeSection ?? "all"}-${index}`}>{section}</div>
))}
</div>
</section> </section>
</div> </div>
</div> </div>
@@ -1,5 +1,11 @@
import { buildAgentMainSessionKey, isSameSessionKey } from "@/lib/gateway/GatewayClient"; import {
buildAgentMainSessionKey,
isSameSessionKey,
} from "@/lib/gateway/GatewayClient";
import { type GatewayModelPolicySnapshot } from "@/lib/gateway/models"; import { type GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import {
isTemporarySkillAgentName,
} from "@/lib/skills/tempAgents";
import { type StudioSettings, type StudioSettingsPublic } from "@/lib/studio/settings"; import { type StudioSettings, type StudioSettingsPublic } from "@/lib/studio/settings";
import { import {
type SummaryPreviewSnapshot, type SummaryPreviewSnapshot,
@@ -156,6 +162,12 @@ export async function hydrateAgentFleetFromGateway(params: {
agentsResult = fallback; agentsResult = fallback;
} }
} }
agentsResult = {
...agentsResult,
agents: agentsResult.agents.filter(
(agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name)
),
};
const mainKey = agentsResult.mainKey?.trim() || "main"; const mainKey = agentsResult.mainKey?.trim() || "main";
const mainSessionKeyByAgent = new Map<string, SessionsListEntry | null>(); const mainSessionKeyByAgent = new Map<string, SessionsListEntry | null>();
@@ -34,6 +34,11 @@ export type DeleteAgentTransactionResult = {
restored: RestoreAgentStateResult | null; restored: RestoreAgentStateResult | null;
}; };
const EMPTY_TRASH_RESULT: TrashAgentStateResult = {
trashDir: "",
moved: [],
};
const runDeleteFlow = async ( const runDeleteFlow = async (
deps: DeleteAgentTransactionDeps, deps: DeleteAgentTransactionDeps,
agentId: string agentId: string
@@ -43,7 +48,15 @@ const runDeleteFlow = async (
throw new Error("Agent id is required."); throw new Error("Agent id is required.");
} }
const trashed = await deps.trashAgentState(trimmedAgentId); let trashed = EMPTY_TRASH_RESULT;
try {
trashed = await deps.trashAgentState(trimmedAgentId);
} catch (err) {
deps.logError?.(
"Failed to move agent workspace/state into trash. Continuing with gateway deletion only.",
err
);
}
let removedCronJobs: CronJobRestoreInput[] = []; let removedCronJobs: CronJobRestoreInput[] = [];
try { try {
@@ -116,3 +129,29 @@ export const deleteAgentViaStudio = async (params: {
params.agentId params.agentId
); );
}; };
export const deleteAgentRecordViaStudio = async (params: {
client: GatewayClient;
agentId: string;
logError?: (message: string, error: unknown) => void;
}): Promise<void> => {
const trimmedAgentId = params.agentId.trim();
if (!trimmedAgentId) {
throw new Error("Agent id is required.");
}
const logError = params.logError ?? ((message, error) => console.error(message, error));
let removedCronJobs: CronJobRestoreInput[] = [];
try {
removedCronJobs = await removeCronJobsForAgentWithBackup(params.client, trimmedAgentId);
await deleteGatewayAgent({ client: params.client, agentId: trimmedAgentId });
} catch (err) {
if (removedCronJobs.length > 0) {
try {
await restoreCronJobs(params.client, removedCronJobs);
} catch (restoreErr) {
logError("Failed to restore removed cron jobs.", restoreErr);
}
}
throw err;
}
};
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation"; import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation"; import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation"; import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation"; import { deleteAgentRecordViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import { import {
planAgentSettingsMutation, planAgentSettingsMutation,
type AgentSettingsMutationContext, type AgentSettingsMutationContext,
@@ -34,7 +34,6 @@ import {
renameGatewayAgent, renameGatewayAgent,
updateGatewayAgentSkillsAllowlist, updateGatewayAgentSkillsAllowlist,
} from "@/lib/gateway/agentConfig"; } from "@/lib/gateway/agentConfig";
import { fetchJson } from "@/lib/http";
import { canRemoveSkillSource } from "@/lib/skills/presentation"; import { canRemoveSkillSource } from "@/lib/skills/presentation";
import { setAgentSkillEnabled } from "@/lib/skills/agentAccess"; import { setAgentSkillEnabled } from "@/lib/skills/agentAccess";
import { removeSkillFromGateway } from "@/lib/skills/remove"; import { removeSkillFromGateway } from "@/lib/skills/remove";
@@ -73,6 +72,7 @@ export type UseAgentSettingsMutationControllerParams = {
clearInspectSidebar: () => void; clearInspectSidebar: () => void;
setInspectSidebarCapabilities: (agentId: string) => void; setInspectSidebarCapabilities: (agentId: string) => void;
dispatchUpdateAgent: (agentId: string, patch: Partial<AgentState>) => void; dispatchUpdateAgent: (agentId: string, patch: Partial<AgentState>) => void;
removeAgent?: (agentId: string) => void;
setMobilePaneChat: () => void; setMobilePaneChat: () => void;
setError: (message: string) => void; setError: (message: string) => void;
}; };
@@ -441,7 +441,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId); const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId);
if (!agent) return; if (!agent) return;
const confirmed = window.confirm( const confirmed = window.confirm(
`Delete ${agent.name}? This removes the agent from gateway config + scheduled automations and moves its workspace/state into ~/.openclaw/trash on the gateway host.` `Delete ${agent.name}? This removes the agent record from OpenClaw and clears its scheduled automations. Claw3D will not touch workspace files.`
); );
if (!confirmed) return; if (!confirmed) return;
@@ -451,12 +451,12 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
agentName: agent.name, agentName: agent.name,
label: `Delete ${agent.name}`, label: `Delete ${agent.name}`,
executeMutation: async () => { executeMutation: async () => {
await deleteAgentViaStudio({ await deleteAgentRecordViaStudio({
client: params.client, client: params.client,
agentId: decision.normalizedAgentId, agentId: decision.normalizedAgentId,
fetchJson,
logError: (message, error) => console.error(message, error), logError: (message, error) => console.error(message, error),
}); });
params.removeAgent?.(decision.normalizedAgentId);
params.clearInspectSidebar(); params.clearInspectSidebar();
}, },
}); });
@@ -947,7 +947,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
}, },
}); });
}, },
[runSkillSetupMutation, setSkillMessage, settingsSkillsReport] [params.client, runSkillSetupMutation, setSkillMessage, settingsSkillsReport]
); );
const handleSaveSkillApiKey = useCallback( const handleSaveSkillApiKey = useCallback(
@@ -65,7 +65,6 @@ import { useFinalizedAssistantReplyListener } from "@/hooks/useFinalizedAssistan
import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference"; import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference";
import { useVoiceReplyPlayback } from "@/hooks/useVoiceReplyPlayback"; import { useVoiceReplyPlayback } from "@/hooks/useVoiceReplyPlayback";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import { randomUUID } from "@/lib/uuid";
import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types"; import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types";
import { import {
planAwaitingUserInputPatches, planAwaitingUserInputPatches,
@@ -588,6 +587,12 @@ const AgentsPageScreen = () => {
patch, patch,
}); });
}, },
removeAgent: (agentId) => {
dispatch({
type: "removeAgent",
agentId,
});
},
setMobilePaneChat: () => { setMobilePaneChat: () => {
setMobilePane("chat"); setMobilePane("chat");
}, },
+13
View File
@@ -122,6 +122,7 @@ type Action =
| { type: "setError"; error: string | null } | { type: "setError"; error: string | null }
| { type: "setLoading"; loading: boolean } | { type: "setLoading"; loading: boolean }
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "removeAgent"; agentId: string }
| { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta } | { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta }
| { type: "enqueueQueuedMessage"; agentId: string; message: string } | { type: "enqueueQueuedMessage"; agentId: string; message: string }
| { type: "removeQueuedMessage"; agentId: string; index: number } | { type: "removeQueuedMessage"; agentId: string; index: number }
@@ -338,6 +339,18 @@ const reducer = (state: AgentStoreState, action: Action): AgentStoreState => {
}; };
}), }),
}; };
case "removeAgent": {
const nextAgents = state.agents.filter((agent) => agent.agentId !== action.agentId);
const selectedAgentId =
state.selectedAgentId === action.agentId
? nextAgents[0]?.agentId ?? null
: state.selectedAgentId;
return {
...state,
agents: nextAgents,
selectedAgentId,
};
}
case "appendOutput": case "appendOutput":
return { return {
...state, ...state,
@@ -15,6 +15,7 @@ type HQSidebarProps = {
onToggle: () => void; onToggle: () => void;
onTabChange: (tab: HQSidebarTab) => void; onTabChange: (tab: HQSidebarTab) => void;
onOpenMarketplace: () => void; onOpenMarketplace: () => void;
onAddAgent?: () => void;
inboxPanel: ReactNode; inboxPanel: ReactNode;
historyPanel: ReactNode; historyPanel: ReactNode;
playbooksPanel: ReactNode; playbooksPanel: ReactNode;
@@ -37,6 +38,7 @@ export function HQSidebar({
onToggle, onToggle,
onTabChange, onTabChange,
onOpenMarketplace, onOpenMarketplace,
onAddAgent,
inboxPanel, inboxPanel,
historyPanel, historyPanel,
playbooksPanel, playbooksPanel,
@@ -114,6 +116,15 @@ export function HQSidebar({
? "Cost, budgets, and performance intelligence." ? "Cost, budgets, and performance intelligence."
: "Monitor outputs, runs, and schedules."} : "Monitor outputs, runs, and schedules."}
</div> </div>
{!railOnly && onAddAgent ? (
<button
type="button"
onClick={onAddAgent}
className="mt-3 rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
>
Add Agent
</button>
) : null}
{railOnly ? ( {railOnly ? (
<button <button
type="button" type="button"
@@ -54,10 +54,11 @@ export const useOfficeSkillTriggers = ({
() => (agentIdsKey ? agentIdsKey.split("|").filter((value) => value.length > 0) : []), () => (agentIdsKey ? agentIdsKey.split("|").filter((value) => value.length > 0) : []),
[agentIdsKey], [agentIdsKey],
); );
const shouldLoadTriggers =
status === "connected" && stableAgentIds.length > 0 && packagedTriggers.length > 0;
useEffect(() => { useEffect(() => {
if (status !== "connected" || agents.length === 0 || packagedTriggers.length === 0) { if (!shouldLoadTriggers) {
setEnabledTriggersByAgentId({});
return; return;
} }
@@ -107,7 +108,12 @@ export const useOfficeSkillTriggers = ({
cancelled = true; cancelled = true;
window.clearInterval(intervalId); window.clearInterval(intervalId);
}; };
}, [agentIdsKey, client, packagedTriggers, stableAgentIds, status]); }, [client, packagedTriggers, shouldLoadTriggers, stableAgentIds]);
const visibleEnabledTriggersByAgentId = useMemo(
() => (shouldLoadTriggers ? enabledTriggersByAgentId : {}),
[enabledTriggersByAgentId, shouldLoadTriggers]
);
const movementTargetByAgentId = useMemo<Record<string, OfficeSkillTriggerMovementTarget>>(() => { const movementTargetByAgentId = useMemo<Record<string, OfficeSkillTriggerMovementTarget>>(() => {
const next: Record<string, OfficeSkillTriggerMovementTarget> = {}; const next: Record<string, OfficeSkillTriggerMovementTarget> = {};
@@ -116,17 +122,17 @@ export const useOfficeSkillTriggers = ({
isAgentRunning: agent.status === "running" || Boolean(agent.runId), isAgentRunning: agent.status === "running" || Boolean(agent.runId),
lastUserMessage: agent.lastUserMessage, lastUserMessage: agent.lastUserMessage,
transcriptEntries: agent.transcriptEntries, transcriptEntries: agent.transcriptEntries,
triggers: enabledTriggersByAgentId[agent.agentId] ?? [], triggers: visibleEnabledTriggersByAgentId[agent.agentId] ?? [],
}); });
if (trigger) { if (trigger) {
next[agent.agentId] = trigger.movementTarget; next[agent.agentId] = trigger.movementTarget;
} }
} }
return next; return next;
}, [agents, enabledTriggersByAgentId]); }, [agents, visibleEnabledTriggersByAgentId]);
return { return {
enabledTriggersByAgentId, enabledTriggersByAgentId: visibleEnabledTriggersByAgentId,
movementTargetByAgentId, movementTargetByAgentId,
}; };
}; };
@@ -318,7 +318,7 @@ export const useOfficeSkillsMarketplace = ({
}, },
}); });
}, },
[runSkillMutation], [client, runSkillMutation],
); );
return { return {
+478 -2
View File
@@ -25,7 +25,10 @@ import {
type StudioSettingsLoadOptions, type StudioSettingsLoadOptions,
} from "@/lib/studio/coordinator"; } from "@/lib/studio/coordinator";
import { resolveDeskAssignments } from "@/lib/studio/settings"; import { resolveDeskAssignments } from "@/lib/studio/settings";
import { renameGatewayAgent } from "@/lib/gateway/agentConfig"; import {
createGatewayAgent,
renameGatewayAgent,
} from "@/lib/gateway/agentConfig";
import { import {
runStudioBootstrapLoadOperation, runStudioBootstrapLoadOperation,
executeStudioBootstrapLoadCommands, executeStudioBootstrapLoadCommands,
@@ -53,11 +56,26 @@ import {
AgentEditorModal, AgentEditorModal,
type AgentEditorSection, type AgentEditorSection,
} from "@/features/agents/components/AgentEditorModal"; } from "@/features/agents/components/AgentEditorModal";
import { AgentCreateWizardModal } from "@/features/agents/components/AgentCreateWizardModal";
import type { AgentIdentityValues } from "@/features/agents/components/AgentIdentityFields";
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController"; import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
import {
applyCreateAgentBootstrapPermissions,
CREATE_AGENT_DEFAULT_PERMISSIONS,
} from "@/features/agents/operations/createAgentBootstrapOperation";
import { deleteAgentRecordViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import { planAgentSettingsMutation } from "@/features/agents/operations/agentSettingsMutationWorkflow";
import { import {
executeHistorySyncCommands, executeHistorySyncCommands,
runHistorySyncOperation, runHistorySyncOperation,
} from "@/features/agents/operations/historySyncOperation"; } from "@/features/agents/operations/historySyncOperation";
import {
buildQueuedMutationBlock,
runAgentConfigMutationLifecycle,
runCreateAgentMutationLifecycle,
type CreateAgentBlockState,
} from "@/features/agents/operations/mutationLifecycleWorkflow";
import { useConfigMutationQueue } from "@/features/agents/operations/useConfigMutationQueue";
import { import {
RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT, RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT,
RUNTIME_SYNC_MAX_HISTORY_LIMIT, RUNTIME_SYNC_MAX_HISTORY_LIMIT,
@@ -72,6 +90,12 @@ import {
} 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 type { AgentAvatarProfile } from "@/lib/avatars/profile";
import {
createEmptyPersonalityDraft,
serializePersonalityFiles,
type PersonalityBuilderDraft,
} from "@/lib/agents/personalityBuilder";
import { writeGatewayAgentFiles } from "@/lib/gateway/agentFiles";
import { randomUUID } from "@/lib/uuid"; import { randomUUID } from "@/lib/uuid";
import { import {
HQSidebar, HQSidebar,
@@ -167,6 +191,15 @@ type PreparedTextMessageEntry = {
scenario: MockTextMessageScenario; scenario: MockTextMessageScenario;
}; };
type OfficeDeleteMutationBlockState = {
kind: "delete-agent";
agentId: string;
agentName: string;
phase: "queued" | "mutating" | "awaiting-restart";
startedAt: number;
sawDisconnect: boolean;
};
type PhoneCallSpeakPayload = { type PhoneCallSpeakPayload = {
agentId: string; agentId: string;
requestKey: string; requestKey: string;
@@ -214,6 +247,31 @@ const formatOpenClawValue = (value: string | null | undefined) => {
const buildPhoneCallOutputLine = (text: string) => `[phone booth] ${text}`; const buildPhoneCallOutputLine = (text: string) => `[phone booth] ${text}`;
const buildTextMessageOutputLine = (text: string) => `[messaging booth] ${text}`; const buildTextMessageOutputLine = (text: string) => `[messaging booth] ${text}`;
const buildIdentityFileDraft = (identity: AgentIdentityValues) => {
const draft = createEmptyPersonalityDraft();
draft.identity = {
...draft.identity,
...identity,
};
return serializePersonalityFiles(draft);
};
const resolveOfficeMutationGuardMessage = (guardReason?: string) => {
if (guardReason === "not-connected") {
return "Connect to the gateway before changing the office fleet.";
}
if (guardReason === "create-block-active") {
return "Finish the active agent creation before starting another fleet change.";
}
if (guardReason === "rename-block-active") {
return "Finish the active rename before changing the office fleet.";
}
if (guardReason === "delete-block-active") {
return "Finish the active deletion before changing the office fleet.";
}
return "The office fleet is busy right now.";
};
const PHONE_BOOTH_ASSISTANT_FALLBACK_RE = const PHONE_BOOTH_ASSISTANT_FALLBACK_RE =
/\b(?:i\s+)?can(?:not|[']t)\s+(?:place|make)\s+(?:phone\s+)?calls?\b/i; /\b(?:i\s+)?can(?:not|[']t)\s+(?:place|make)\s+(?:phone\s+)?calls?\b/i;
@@ -745,6 +803,16 @@ export function OfficeScreen({
const [agentEditorAgentId, setAgentEditorAgentId] = useState<string | null>(null); const [agentEditorAgentId, setAgentEditorAgentId] = useState<string | null>(null);
const [agentEditorInitialSection, setAgentEditorInitialSection] = const [agentEditorInitialSection, setAgentEditorInitialSection] =
useState<AgentEditorSection>("avatar"); useState<AgentEditorSection>("avatar");
const [createAgentWizardNonce, setCreateAgentWizardNonce] = useState(0);
const [createAgentWizardOpen, setCreateAgentWizardOpen] = useState(false);
const [createAgentBusy, setCreateAgentBusy] = useState(false);
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(
null,
);
const [createAgentBlock, setCreateAgentBlock] =
useState<CreateAgentBlockState | null>(null);
const [deleteAgentBlock, setDeleteAgentBlock] =
useState<OfficeDeleteMutationBlockState | null>(null);
const [preparedPhoneCallsByAgentId, setPreparedPhoneCallsByAgentId] = useState< const [preparedPhoneCallsByAgentId, setPreparedPhoneCallsByAgentId] = useState<
Record<string, PreparedPhoneCallEntry> Record<string, PreparedPhoneCallEntry>
>({}); >({});
@@ -900,6 +968,22 @@ export function OfficeScreen({
stateRef.current = state; stateRef.current = state;
}, [state]); }, [state]);
const hasRunningAgents = useMemo(
() =>
state.agents.some(
(agent) => agent.status === "running" || Boolean(agent.runId),
),
[state.agents],
);
const hasDeleteMutationBlock = deleteAgentBlock?.kind === "delete-agent";
const { enqueueConfigMutation } = useConfigMutationQueue({
status,
hasRunningAgents,
hasRestartBlockInProgress: Boolean(
deleteAgentBlock && deleteAgentBlock.phase !== "queued",
),
});
useEffect(() => { useEffect(() => {
officeTriggerStateRef.current = officeTriggerState; officeTriggerStateRef.current = officeTriggerState;
}, [officeTriggerState]); }, [officeTriggerState]);
@@ -1111,6 +1195,335 @@ export function OfficeScreen({
status, status,
]); ]);
const handleCloseCreateAgentWizard = useCallback(
(createdAgentId: string | null) => {
setCreateAgentWizardOpen(false);
setCreateAgentModalError(null);
if (createdAgentId) {
openAgentEditor(createdAgentId, "IDENTITY.md");
}
},
[openAgentEditor],
);
const handleOpenCreateAgentWizard = useCallback(() => {
setCreateAgentModalError(null);
setCreateAgentWizardNonce((current) => current + 1);
setCreateAgentWizardOpen(true);
}, []);
const clearDeletedAgentUiState = useCallback((agentId: string) => {
setSelectedChatAgentId((current) => (current === agentId ? null : current));
setAgentEditorAgentId((current) => (current === agentId ? null : current));
setMonitorAgentId((current) => (current === agentId ? null : current));
setGithubReviewAgentId((current) => (current === agentId ? null : current));
setQaTestingAgentId((current) => (current === agentId ? null : current));
setPreparedPhoneCallsByAgentId((current) => {
if (!(agentId in current)) return current;
const next = { ...current };
delete next[agentId];
return next;
});
setPreparedTextMessagesByAgentId((current) => {
if (!(agentId in current)) return current;
const next = { ...current };
delete next[agentId];
return next;
});
}, []);
const createAgentStatusLine = useMemo(() => {
if (!createAgentBlock) return null;
if (createAgentBlock.phase === "queued") {
return "Waiting for active runs to finish before creating the new agent.";
}
return `Creating ${createAgentBlock.agentName}.`;
}, [createAgentBlock]);
const deleteAgentStatusLine = useMemo(() => {
if (!deleteAgentBlock) return null;
if (deleteAgentBlock.phase === "queued") {
return `Waiting for active runs to finish before deleting ${deleteAgentBlock.agentName}.`;
}
return `Deleting ${deleteAgentBlock.agentName}.`;
}, [deleteAgentBlock]);
const handleCreateAgentFromIdentity = useCallback(
async (identity: AgentIdentityValues) => {
let createdAgentId: string | null = null;
const success = await runCreateAgentMutationLifecycle(
{
payload: {
name: identity.name,
},
status,
hasCreateBlock: Boolean(createAgentBlock),
hasRenameBlock: false,
hasDeleteBlock: Boolean(hasDeleteMutationBlock),
createAgentBusy,
},
{
enqueueConfigMutation,
createAgent: async (name) => {
const created = await createGatewayAgent({ client, name });
const files = buildIdentityFileDraft(identity);
await writeGatewayAgentFiles({
client,
agentId: created.id,
files: {
"IDENTITY.md": files["IDENTITY.md"],
},
});
return { id: created.id };
},
setQueuedBlock: ({ agentName, startedAt }) => {
const queuedCreateBlock = buildQueuedMutationBlock({
kind: "create-agent",
agentId: "",
agentName,
startedAt,
});
setCreateAgentBlock({
agentName: queuedCreateBlock.agentName,
phase: "queued",
startedAt: queuedCreateBlock.startedAt,
});
},
setCreatingBlock: (agentName) => {
setCreateAgentBlock((current) => {
if (!current || current.agentName !== agentName) return current;
return { ...current, phase: "creating" };
});
},
onCompletion: async (completion) => {
createdAgentId = completion.agentId;
await loadAgents({ forceSettings: true });
const createdAgent =
stateRef.current.agents.find(
(entry) => entry.agentId === completion.agentId,
) ?? null;
if (createdAgent?.sessionKey) {
try {
await applyCreateAgentBootstrapPermissions({
client,
agentId: createdAgent.agentId,
sessionKey: createdAgent.sessionKey,
draft: { ...CREATE_AGENT_DEFAULT_PERMISSIONS },
loadAgents: () => loadAgents({ forceSettings: true }),
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to apply default permissions.";
setError(
`Agent created, but default permissions could not be applied: ${message}`,
);
}
}
dispatch({
type: "selectAgent",
agentId: completion.agentId,
});
setSelectedChatAgentId(completion.agentId);
setCreateAgentBlock(null);
setCreateAgentModalError(null);
},
setCreateAgentModalError,
setCreateAgentBusy,
clearCreateBlock: () => {
setCreateAgentBlock(null);
},
onError: setError,
},
);
return success ? createdAgentId : null;
},
[
client,
createAgentBlock,
createAgentBusy,
dispatch,
enqueueConfigMutation,
hasDeleteMutationBlock,
loadAgents,
setError,
status,
],
);
const handleFinishCreateAgentAvatar = useCallback(
async (params: {
agentId: string;
draft: PersonalityBuilderDraft;
profile: AgentAvatarProfile;
}) => {
setCreateAgentBusy(true);
setCreateAgentModalError(null);
try {
const files = serializePersonalityFiles(params.draft);
await writeGatewayAgentFiles({
client,
agentId: params.agentId,
files,
});
const currentAgent =
stateRef.current.agents.find((entry) => entry.agentId === params.agentId) ?? null;
const nextName = params.draft.identity.name.trim();
const currentName = currentAgent?.name.trim() ?? "";
if (nextName && nextName !== currentName) {
const renamed = await renameGatewayAgent({
client,
agentId: params.agentId,
name: nextName,
});
if (!renamed) {
throw new Error("Saved the wizard files, but could not rename the live agent.");
}
}
handleAvatarProfileSave(params.agentId, params.profile);
await loadAgents({ forceSettings: true });
setCreateAgentWizardOpen(false);
setCreateAgentModalError(null);
openAgentEditor(params.agentId, "IDENTITY.md");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to finish creating the agent.";
setCreateAgentModalError(message);
} finally {
setCreateAgentBusy(false);
}
},
[client, handleAvatarProfileSave, loadAgents, openAgentEditor],
);
const handleDeleteAgent = useCallback(
async (agentId: string) => {
const decision = planAgentSettingsMutation(
{ kind: "delete-agent", agentId },
{
status,
hasCreateBlock: Boolean(createAgentBlock),
hasRenameBlock: false,
hasDeleteBlock: Boolean(hasDeleteMutationBlock),
cronCreateBusy: false,
cronRunBusyJobId: null,
cronDeleteBusyJobId: null,
},
);
if (decision.kind === "deny") {
setError(
decision.message ?? resolveOfficeMutationGuardMessage(decision.guardReason),
);
return;
}
const agent = state.agents.find(
(entry) => entry.agentId === decision.normalizedAgentId,
);
if (!agent) return;
const confirmed = window.confirm(
`Delete ${agent.name}? This removes the agent record from OpenClaw and clears its scheduled automations. Claw3D will not touch workspace files.`,
);
if (!confirmed) return;
await runAgentConfigMutationLifecycle({
kind: "delete-agent",
label: `Delete ${agent.name}`,
isLocalGateway: false,
deps: {
enqueueConfigMutation,
setQueuedBlock: () => {
const queuedBlock = buildQueuedMutationBlock({
kind: "delete-agent",
agentId: decision.normalizedAgentId,
agentName: agent.name,
startedAt: Date.now(),
});
setDeleteAgentBlock({
kind: "delete-agent",
agentId: queuedBlock.agentId,
agentName: queuedBlock.agentName,
phase: queuedBlock.phase,
startedAt: queuedBlock.startedAt,
sawDisconnect: queuedBlock.sawDisconnect,
});
},
setMutatingBlock: () => {
setDeleteAgentBlock((current) => {
if (!current || current.agentId !== decision.normalizedAgentId) {
return current;
}
return {
...current,
phase: "mutating",
};
});
},
patchBlockAwaitingRestart: (patch) => {
setDeleteAgentBlock((current) => {
if (!current || current.agentId !== decision.normalizedAgentId) {
return current;
}
return {
...current,
...patch,
};
});
},
clearBlock: () => {
setDeleteAgentBlock((current) => {
if (!current || current.agentId !== decision.normalizedAgentId) {
return current;
}
return null;
});
},
executeMutation: async () => {
await deleteAgentRecordViaStudio({
client,
agentId: decision.normalizedAgentId,
logError: (message, error) => console.error(message, error),
});
clearDeletedAgentUiState(decision.normalizedAgentId);
dispatch({
type: "removeAgent",
agentId: decision.normalizedAgentId,
});
},
shouldAwaitRemoteRestart: async () => false,
reloadAgents: () => loadAgents({ forceSettings: true }),
setMobilePaneChat: () => {},
onError: setError,
},
});
},
[
clearDeletedAgentUiState,
client,
createAgentBlock,
dispatch,
enqueueConfigMutation,
hasDeleteMutationBlock,
loadAgents,
setError,
state.agents,
status,
],
);
useEffect(() => {
if (!createAgentBlock || createAgentBlock.phase === "queued") return;
const maxWaitMs = 90_000;
const elapsed = Date.now() - createAgentBlock.startedAt;
const remaining = Math.max(0, maxWaitMs - elapsed);
const timeoutId = window.setTimeout(() => {
setCreateAgentBlock((current) => {
if (!current || current.phase === "queued") return current;
return null;
});
setCreateAgentBusy(false);
setCreateAgentWizardOpen(false);
setError("Agent creation timed out.");
void loadAgents({ forceSettings: true });
}, remaining);
return () => {
window.clearTimeout(timeoutId);
};
}, [createAgentBlock, loadAgents, setError]);
const requestAgentHistoryRefresh = useCallback( const requestAgentHistoryRefresh = useCallback(
async (params: { async (params: {
agentId: string; agentId: string;
@@ -1341,6 +1754,11 @@ export function OfficeScreen({
if (status === "disconnected") { if (status === "disconnected") {
connectionEpochRef.current += 1; connectionEpochRef.current += 1;
setAgentsLoaded(false); setAgentsLoaded(false);
setCreateAgentWizardOpen(false);
setCreateAgentBusy(false);
setCreateAgentModalError(null);
setCreateAgentBlock(null);
setDeleteAgentBlock(null);
loadAgentsInFlightRef.current = null; loadAgentsInFlightRef.current = null;
gatewayConfigSnapshot.current = null; gatewayConfigSnapshot.current = null;
lastLoadAgentsStartedAtRef.current = 0; lastLoadAgentsStartedAtRef.current = 0;
@@ -1665,6 +2083,19 @@ export function OfficeScreen({
: null; : null;
const mainAgent = const mainAgent =
state.agents.find((agent) => agent.agentId === MAIN_AGENT_ID) ?? null; state.agents.find((agent) => agent.agentId === MAIN_AGENT_ID) ?? null;
useEffect(() => {
if (!selectedChatAgentId) return;
if (state.agents.some((agent) => agent.agentId === selectedChatAgentId)) return;
setSelectedChatAgentId(null);
}, [selectedChatAgentId, state.agents]);
useEffect(() => {
if (!agentEditorAgentId) return;
if (state.agents.some((agent) => agent.agentId === agentEditorAgentId)) return;
setAgentEditorAgentId(null);
}, [agentEditorAgentId, state.agents]);
const runLog = useRunLog({ client, status, agents: state.agents }); const runLog = useRunLog({ client, status, agents: state.agents });
const standupAgentSnapshots = useMemo<StandupAgentSnapshot[]>( const standupAgentSnapshots = useMemo<StandupAgentSnapshot[]>(
() => () =>
@@ -2870,9 +3301,13 @@ export function OfficeScreen({
dispatch({ type: "selectAgent", agentId }); dispatch({ type: "selectAgent", agentId });
} }
}} }}
onAddAgent={handleOpenCreateAgentWizard}
onAgentEdit={(agentId) => { onAgentEdit={(agentId) => {
openAgentEditor(agentId, "avatar"); openAgentEditor(agentId, "avatar");
}} }}
onAgentDelete={(agentId) => {
void handleDeleteAgent(agentId);
}}
onDeskAssignmentChange={handleDeskAssignmentChange} onDeskAssignmentChange={handleDeskAssignmentChange}
onDeskAssignmentsReset={handleDeskAssignmentsReset} onDeskAssignmentsReset={handleDeskAssignmentsReset}
onGithubReviewDismiss={() => { onGithubReviewDismiss={() => {
@@ -2899,9 +3334,19 @@ export function OfficeScreen({
</p> </p>
<p className="mt-1 text-sm text-amber-50">{emptyFleetMessage}</p> <p className="mt-1 text-sm text-amber-50">{emptyFleetMessage}</p>
</div> </div>
<div className="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
className="ui-btn-secondary shrink-0 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"
onClick={() => {
handleOpenCreateAgentWizard();
}}
>
Add Agent
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-2 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={() => { onClick={() => {
void loadAgents({ forceSettings: true }); void loadAgents({ forceSettings: true });
}} }}
@@ -2911,6 +3356,18 @@ export function OfficeScreen({
</div> </div>
</div> </div>
</div> </div>
</div>
) : null}
{deleteAgentStatusLine ? (
<div className="pointer-events-none fixed left-1/2 top-5 z-40 -translate-x-1/2 px-4">
<div className="pointer-events-auto rounded-lg border border-red-400/30 bg-black/85 px-4 py-3 shadow-2xl backdrop-blur">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-red-200/75">
Fleet mutation
</div>
<div className="mt-1 text-sm text-red-50">{deleteAgentStatusLine}</div>
</div>
</div>
) : null} ) : null}
{!debugEnabled ? ( {!debugEnabled ? (
@@ -2921,6 +3378,7 @@ export function OfficeScreen({
onToggle={() => setSidebarOpen((prev) => !prev)} onToggle={() => setSidebarOpen((prev) => !prev)}
onTabChange={setActiveSidebarTab} onTabChange={setActiveSidebarTab}
onOpenMarketplace={() => setMarketplaceOpen(true)} onOpenMarketplace={() => setMarketplaceOpen(true)}
onAddAgent={handleOpenCreateAgentWizard}
inboxPanel={ inboxPanel={
<InboxPanel <InboxPanel
agents={state.agents} agents={state.agents}
@@ -3444,6 +3902,7 @@ export function OfficeScreen({
) : null} ) : null}
{agentEditorAgent ? ( {agentEditorAgent ? (
<AgentEditorModal <AgentEditorModal
key={`${agentEditorAgent.agentId}:${agentEditorInitialSection}`}
open open
client={client} client={client}
agents={state.agents} agents={state.agents}
@@ -3463,8 +3922,25 @@ export function OfficeScreen({
return false; return false;
} }
}} }}
onDelete={async (agentId) => {
await handleDeleteAgent(agentId);
}}
onNavigateAgent={(agentId, section) => {
openAgentEditor(agentId, section);
}}
/> />
) : null} ) : null}
<AgentCreateWizardModal
key={createAgentWizardNonce}
open={createAgentWizardOpen}
suggestedName={`Agent ${state.agents.length + 1}`}
busy={createAgentBusy}
submitError={createAgentModalError}
statusLine={createAgentStatusLine}
onClose={handleCloseCreateAgentWizard}
onCreateAgent={handleCreateAgentFromIdentity}
onFinishWizard={handleFinishCreateAgentAvatar}
/>
</main> </main>
); );
} }
+31 -5
View File
@@ -9,6 +9,8 @@ import {
Armchair, Armchair,
Settings2, Settings2,
Camera, Camera,
UserPlus,
Trash2,
Users, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -1901,7 +1903,9 @@ export function RetroOffice3D({
onStandupArrivalsChange, onStandupArrivalsChange,
onStandupStartRequested, onStandupStartRequested,
onMonitorSelect, onMonitorSelect,
onAddAgent,
onAgentEdit, onAgentEdit,
onAgentDelete,
onDeskAssignmentChange, onDeskAssignmentChange,
onDeskAssignmentsReset, onDeskAssignmentsReset,
onGithubReviewDismiss, onGithubReviewDismiss,
@@ -1965,7 +1969,9 @@ export function RetroOffice3D({
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void; onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
onStandupStartRequested?: () => void; onStandupStartRequested?: () => void;
onMonitorSelect?: (agentId: string | null) => void; onMonitorSelect?: (agentId: string | null) => void;
onAddAgent?: () => void;
onAgentEdit?: (agentId: string) => void; onAgentEdit?: (agentId: string) => void;
onAgentDelete?: (agentId: string) => void;
onDeskAssignmentChange?: (deskUid: string, agentId: string | null) => void; onDeskAssignmentChange?: (deskUid: string, agentId: string | null) => void;
onDeskAssignmentsReset?: (deskUids: string[]) => void; onDeskAssignmentsReset?: (deskUids: string[]) => void;
onGithubReviewDismiss?: () => void; onGithubReviewDismiss?: () => void;
@@ -2497,10 +2503,7 @@ export function RetroOffice3D({
) ?? null ) ?? null
); );
}, [assignedDeskIndexByAgentId, deskLocations, furniture, monitorAgentId]); }, [assignedDeskIndexByAgentId, deskLocations, furniture, monitorAgentId]);
useEffect(() => { const agentRosterVisible = agentRosterOpen && !immersiveOverlayActive;
if (!immersiveOverlayActive) return;
setAgentRosterOpen(false);
}, [immersiveOverlayActive]);
const selectedItem = useMemo( const selectedItem = useMemo(
() => furniture.find((item) => item._uid === selectedUid) ?? null, () => furniture.find((item) => item._uid === selectedUid) ?? null,
[furniture, selectedUid], [furniture, selectedUid],
@@ -5124,7 +5127,7 @@ export function RetroOffice3D({
</button> </button>
</div> </div>
{agentRosterOpen ? ( {agentRosterVisible ? (
<div className="absolute left-1/2 top-full mt-2 w-[min(92vw,560px)] -translate-x-1/2 rounded-2xl border border-amber-900/25 bg-[#120e08]/96 p-3 shadow-2xl backdrop-blur-sm"> <div className="absolute left-1/2 top-full mt-2 w-[min(92vw,560px)] -translate-x-1/2 rounded-2xl border border-amber-900/25 bg-[#120e08]/96 p-3 shadow-2xl backdrop-blur-sm">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
@@ -5223,6 +5226,19 @@ export function RetroOffice3D({
> >
<Monitor size={12} /> <Monitor size={12} />
</button> </button>
{onAgentDelete ? (
<button
type="button"
title="Delete agent"
onClick={() => {
onAgentDelete(agent.id);
setAgentRosterOpen(false);
}}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-900/30 text-red-300/70 transition-colors hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-200"
>
<Trash2 size={12} />
</button>
) : null}
</div> </div>
); );
})} })}
@@ -5953,6 +5969,16 @@ export function RetroOffice3D({
{/* Toolbar — top right. */} {/* Toolbar — top right. */}
{!immersiveOverlayActive ? ( {!immersiveOverlayActive ? (
<div className="absolute top-3 right-3 flex items-center gap-2 z-20"> <div className="absolute top-3 right-3 flex items-center gap-2 z-20">
{onAddAgent ? (
<button
onClick={onAddAgent}
title="Add agent"
className="flex h-7 items-center justify-center gap-1 rounded-md border border-cyan-500/35 bg-[#071018]/92 px-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-cyan-200 transition-all backdrop-blur-sm hover:border-cyan-400/55 hover:text-white"
>
<UserPlus size={12} />
<span>Add</span>
</button>
) : null}
{/* New Idea 7: Heatmap toggle. */} {/* New Idea 7: Heatmap toggle. */}
<button <button
onClick={() => setHeatmapMode((p) => !p)} onClick={() => setHeatmapMode((p) => !p)}
+2 -2
View File
@@ -30,7 +30,7 @@ export type PersonalityBuilderDraft = {
type AgentFilesInput = Record<AgentFileName, { content: string; exists: boolean }>; type AgentFilesInput = Record<AgentFileName, { content: string; exists: boolean }>;
const createEmptyDraft = (): PersonalityBuilderDraft => ({ export const createEmptyPersonalityDraft = (): PersonalityBuilderDraft => ({
identity: { identity: {
name: "", name: "",
creature: "", creature: "",
@@ -253,7 +253,7 @@ const serializeSoulMarkdown = (draft: PersonalityBuilderDraft["soul"]) => {
}; };
export const parsePersonalityFiles = (files: AgentFilesInput): PersonalityBuilderDraft => { export const parsePersonalityFiles = (files: AgentFilesInput): PersonalityBuilderDraft => {
const draft = createEmptyDraft(); const draft = createEmptyPersonalityDraft();
const identity = parseLabelMap(files["IDENTITY.md"].content); const identity = parseLabelMap(files["IDENTITY.md"].content);
const identityName = readFirst(identity, ["name"]); const identityName = readFirst(identity, ["name"]);
+1
View File
@@ -479,6 +479,7 @@ const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
"studio.gateway_token_missing", "studio.gateway_token_missing",
"studio.gateway_url_invalid", "studio.gateway_url_invalid",
"studio.settings_load_failed", "studio.settings_load_failed",
"studio.upstream_error",
]); ]);
const isNonRetryableConnectErrorCode = (code: string | null): boolean => { const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
+22
View File
@@ -8,6 +8,18 @@ const parseHostname = (gatewayUrl: string): string | null => {
} }
}; };
const LOCAL_HOST_SUFFIXES = [".local", ".home.arpa", ".lan", ".internal"] as const;
const isPrivateIpv4Address = (hostname: string) => {
const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname);
if (!match) return false;
const [first, second] = [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10)];
if (first === 10) return true;
if (first === 172 && second >= 16 && second <= 31) return true;
if (first === 192 && second === 168) return true;
return false;
};
export const isLocalGatewayUrl = (gatewayUrl: string): boolean => { export const isLocalGatewayUrl = (gatewayUrl: string): boolean => {
const hostname = parseHostname(gatewayUrl); const hostname = parseHostname(gatewayUrl);
if (!hostname) return false; if (!hostname) return false;
@@ -20,3 +32,13 @@ export const isLocalGatewayUrl = (gatewayUrl: string): boolean => {
); );
}; };
export const isLikelyLocalGatewayUrl = (gatewayUrl: string): boolean => {
if (isLocalGatewayUrl(gatewayUrl)) return true;
const hostname = parseHostname(gatewayUrl);
if (!hostname) return false;
const normalized = hostname.trim().toLowerCase();
if (isPrivateIpv4Address(normalized)) return true;
if (normalized.endsWith(".ts.net")) return true;
return LOCAL_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
};
+27
View File
@@ -0,0 +1,27 @@
const TEMP_SKILL_AGENT_RE = /^Skill (Installer|Remover) (\d{13})$/;
const STALE_TEMP_SKILL_AGENT_MS = 15 * 60 * 1000;
export const isTemporarySkillAgentName = (value: string | null | undefined): boolean => {
const trimmed = value?.trim() ?? "";
return TEMP_SKILL_AGENT_RE.test(trimmed);
};
export const resolveTemporarySkillAgentCreatedAt = (
value: string | null | undefined
): number | null => {
const trimmed = value?.trim() ?? "";
const match = TEMP_SKILL_AGENT_RE.exec(trimmed);
if (!match) return null;
const timestamp = Number.parseInt(match[2], 10);
return Number.isFinite(timestamp) ? timestamp : null;
};
export const isStaleTemporarySkillAgentName = (
value: string | null | undefined,
nowMs: number = Date.now()
): boolean => {
const createdAt = resolveTemporarySkillAgentCreatedAt(value);
if (createdAt === null) return false;
return nowMs - createdAt >= STALE_TEMP_SKILL_AGENT_MS;
};
+15 -13
View File
@@ -111,14 +111,14 @@ describe("AgentBrainPanel", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "SOUL.md" })).toBeInTheDocument();
}); });
expect(screen.getByRole("heading", { name: "Directives" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "AGENTS.md" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Context" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "USER.md" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Identity" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "IDENTITY.md" })).toBeInTheDocument();
expect(screen.getByLabelText("Directives")).toHaveValue("alpha agents"); expect(screen.getByLabelText("AGENTS.md")).toHaveValue("alpha agents");
expect(screen.getByLabelText("Persona")).toHaveValue( expect(screen.getByLabelText("SOUL.md")).toHaveValue(
"# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful." "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful."
); );
expect(screen.getByLabelText("Name")).toHaveValue("Alpha"); expect(screen.getByLabelText("Name")).toHaveValue("Alpha");
@@ -154,10 +154,10 @@ describe("AgentBrainPanel", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText("Directives")).toBeInTheDocument(); expect(screen.getByLabelText("AGENTS.md")).toBeInTheDocument();
}); });
fireEvent.change(screen.getByLabelText("Directives"), { fireEvent.change(screen.getByLabelText("AGENTS.md"), {
target: { value: "alpha directives updated" }, target: { value: "alpha directives updated" },
}); });
@@ -171,15 +171,17 @@ describe("AgentBrainPanel", () => {
expect(filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated"); expect(filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated");
}); });
it("discards_unsaved_changes_without_writing_files", async () => { it("calls_cancel_without_writing_files", async () => {
const { client, calls } = createMockClient(); const { client, calls } = createMockClient();
const agents = [createAgent("agent-1", "Alpha", "session-1")]; const agents = [createAgent("agent-1", "Alpha", "session-1")];
const onCancel = vi.fn();
render( render(
createElement(AgentBrainPanel, { createElement(AgentBrainPanel, {
client, client,
agents, agents,
selectedAgentId: "agent-1", selectedAgentId: "agent-1",
onCancel,
}) })
); );
@@ -192,12 +194,12 @@ describe("AgentBrainPanel", () => {
}); });
expect(screen.getByLabelText("Name")).toHaveValue("Alpha Prime"); expect(screen.getByLabelText("Name")).toHaveValue("Alpha Prime");
fireEvent.click(screen.getByRole("button", { name: "Discard" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByLabelText("Name")).toHaveValue("Alpha"); expect(onCancel).toHaveBeenCalledTimes(1);
expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(false); expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(false);
}); });
it("does_not_render_name_editor_in_personality_panel", async () => { it("does_not_render_legacy_name_editor_controls", async () => {
const { client } = createMockClient(); const { client } = createMockClient();
const agents = [createAgent("agent-1", "Alpha", "session-1")]; const agents = [createAgent("agent-1", "Alpha", "session-1")];
@@ -210,7 +212,7 @@ describe("AgentBrainPanel", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "SOUL.md" })).toBeInTheDocument();
}); });
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
+1 -1
View File
@@ -615,7 +615,7 @@ describe("AgentSettingsPanel", () => {
target: { value: "git" }, target: { value: "git" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Configure" })); fireEvent.click(screen.getByRole("button", { name: "Configure" }));
fireEvent.click(screen.getByRole("button", { name: "Remove skill from gateway" })); fireEvent.click(screen.getByRole("button", { name: "Remove for all agents" }));
expect(onRemoveSkill).toHaveBeenCalledWith({ expect(onRemoveSkill).toHaveBeenCalledWith({
skillKey: "github", skillKey: "github",
+2 -3
View File
@@ -18,7 +18,7 @@ describe("resolveGatewayAutoRetryDelayMs", () => {
expect(delay).toBeNull(); expect(delay).toBeNull();
}); });
it("retries for non-auth connect failures", () => { it("does not retry when the upstream websocket upgrade fails", () => {
const delay = resolveGatewayAutoRetryDelayMs({ const delay = resolveGatewayAutoRetryDelayMs({
status: "disconnected", status: "disconnected",
didAutoConnect: true, didAutoConnect: true,
@@ -31,8 +31,7 @@ describe("resolveGatewayAutoRetryDelayMs", () => {
attempt: 0, attempt: 0,
}); });
expect(delay).toBeTypeOf("number"); expect(delay).toBeNull();
expect(delay).toBeGreaterThan(0);
}); });
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { useOnboardingState } from "@/features/onboarding/useOnboardingState"; import { useOnboardingState } from "@/features/onboarding/useOnboardingState";
+2 -2
View File
@@ -68,7 +68,7 @@ describe("settingsRouteWorkflow", () => {
}) })
).toEqual([ ).toEqual([
{ kind: "set-personality-dirty", value: false }, { kind: "set-personality-dirty", value: false },
{ kind: "push", href: "/agents" }, { kind: "push", href: "/" },
]); ]);
}); });
@@ -169,7 +169,7 @@ describe("settingsRouteWorkflow", () => {
hasRouteAgent: false, hasRouteAgent: false,
currentInspectSidebar: null, currentInspectSidebar: null,
}) })
).toEqual([{ kind: "replace", href: "/agents" }]); ).toEqual([{ kind: "replace", href: "/" }]);
}); });
it("plans non-route selection reconciliation", () => { it("plans non-route selection reconciliation", () => {
@@ -8,7 +8,7 @@ import type { CronRunResult } from "@/lib/cron/types";
import type { MutationBlockState } from "@/features/agents/operations/mutationLifecycleWorkflow"; import type { MutationBlockState } from "@/features/agents/operations/mutationLifecycleWorkflow";
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController"; import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation"; import { deleteAgentRecordViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation"; import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation"; import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow"; import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow";
@@ -50,7 +50,7 @@ vi.mock("@/features/agents/operations/useGatewayRestartBlock", () => ({
})); }));
vi.mock("@/features/agents/operations/deleteAgentOperation", () => ({ vi.mock("@/features/agents/operations/deleteAgentOperation", () => ({
deleteAgentViaStudio: vi.fn(), deleteAgentRecordViaStudio: vi.fn(),
})); }));
vi.mock("@/features/agents/operations/cronCreateOperation", () => ({ vi.mock("@/features/agents/operations/cronCreateOperation", () => ({
@@ -219,7 +219,7 @@ const renderController = (overrides?: Partial<Parameters<typeof useAgentSettings
}; };
describe("useAgentSettingsMutationController", () => { describe("useAgentSettingsMutationController", () => {
const mockedDeleteAgentViaStudio = vi.mocked(deleteAgentViaStudio); const mockedDeleteAgentViaStudio = vi.mocked(deleteAgentRecordViaStudio);
const mockedPerformCronCreateFlow = vi.mocked(performCronCreateFlow); const mockedPerformCronCreateFlow = vi.mocked(performCronCreateFlow);
const mockedRunCronJobNow = vi.mocked(runCronJobNow); const mockedRunCronJobNow = vi.mocked(runCronJobNow);
const mockedRemoveCronJob = vi.mocked(removeCronJob); const mockedRemoveCronJob = vi.mocked(removeCronJob);
@@ -347,7 +347,7 @@ describe("useAgentSettingsMutationController", () => {
deps.clearBlock(); deps.clearBlock();
return true; return true;
}); });
mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null }); mockedDeleteAgentViaStudio.mockResolvedValue(undefined);
const ctx = renderController(); const ctx = renderController();
@@ -653,13 +653,15 @@ describe("useAgentSettingsMutationController", () => {
}); });
}); });
expect(mockedRemoveSkillFromGateway).toHaveBeenCalledWith({ expect(mockedRemoveSkillFromGateway).toHaveBeenCalledWith(
expect.objectContaining({
skillKey: "browser", skillKey: "browser",
source: "openclaw-workspace", source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/browser", baseDir: "/tmp/workspace/skills/browser",
workspaceDir: "/tmp/workspace", workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills", managedSkillsDir: "/tmp/skills",
}); })
);
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" }) expect.objectContaining({ kind: "update-skill-setup" })
); );
@@ -276,7 +276,7 @@ describe("useSettingsRouteController", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(ctx.replace).toHaveBeenCalledWith("/agents"); expect(ctx.replace).toHaveBeenCalledWith("/");
}); });
}); });