mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
Simplify agent rename UX
This commit is contained in:
+1
-1
@@ -2359,6 +2359,7 @@ const AgentStudioPage = () => {
|
||||
stopDisabledReason={focusedAgentStopDisabledReason}
|
||||
onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)}
|
||||
onOpenSettings={() => handleOpenAgentPersonality(focusedAgent.agentId)}
|
||||
onRename={(name) => handleRenameAgent(focusedAgent.agentId, name)}
|
||||
onNewSession={() => handleNewSession(focusedAgent.agentId)}
|
||||
onModelChange={(value) =>
|
||||
handleModelChange(focusedAgent.agentId, focusedAgent.sessionKey, value)
|
||||
@@ -2437,7 +2438,6 @@ const AgentStudioPage = () => {
|
||||
client={client}
|
||||
agents={agents}
|
||||
selectedAgentId={inspectSidebarAgent.agentId}
|
||||
onRename={(name) => handleRenameAgent(inspectSidebarAgent.agentId, name)}
|
||||
onClose={() => {
|
||||
setInspectSidebar(null);
|
||||
setMobilePane("chat");
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChevronRight, Clock, Cog, Shuffle } from "lucide-react";
|
||||
import { Check, ChevronRight, Clock, Cog, Pencil, Shuffle, X } from "lucide-react";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
@@ -98,6 +98,7 @@ type AgentChatPanelProps = {
|
||||
stopDisabledReason?: string | null;
|
||||
onLoadMoreHistory: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onRename?: (name: string) => Promise<boolean>;
|
||||
onNewSession?: () => Promise<void> | void;
|
||||
onModelChange: (value: string | null) => void;
|
||||
onThinkingChange: (value: string | null) => void;
|
||||
@@ -832,6 +833,7 @@ export const AgentChatPanel = ({
|
||||
stopDisabledReason = null,
|
||||
onLoadMoreHistory,
|
||||
onOpenSettings,
|
||||
onRename,
|
||||
onNewSession,
|
||||
onModelChange,
|
||||
onThinkingChange,
|
||||
@@ -844,7 +846,12 @@ export const AgentChatPanel = ({
|
||||
}: AgentChatPanelProps) => {
|
||||
const [draftValue, setDraftValue] = useState(agent.draft);
|
||||
const [newSessionBusy, setNewSessionBusy] = useState(false);
|
||||
const [renameEditing, setRenameEditing] = useState(false);
|
||||
const [renameSaving, setRenameSaving] = useState(false);
|
||||
const [renameDraft, setRenameDraft] = useState(agent.name);
|
||||
const [renameError, setRenameError] = useState<string | null>(null);
|
||||
const draftRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const scrollToBottomNextOutputRef = useRef(false);
|
||||
const plainDraftRef = useRef(agent.draft);
|
||||
const draftIdentityRef = useRef<{ agentId: string; sessionKey: string }>({
|
||||
@@ -885,6 +892,24 @@ export const AgentChatPanel = ({
|
||||
setDraftValue("");
|
||||
}, [agent.agentId, agent.draft, agent.sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setRenameEditing(false);
|
||||
setRenameSaving(false);
|
||||
setRenameError(null);
|
||||
setRenameDraft(agent.name);
|
||||
}, [agent.agentId, agent.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renameEditing) return;
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [renameEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingResizeFrameRef.current !== null) {
|
||||
cancelAnimationFrame(pendingResizeFrameRef.current);
|
||||
@@ -991,6 +1016,64 @@ export const AgentChatPanel = ({
|
||||
handleSend(draftValue);
|
||||
}, [draftValue, handleSend]);
|
||||
|
||||
const beginRename = useCallback(() => {
|
||||
if (!onRename) return;
|
||||
setRenameEditing(true);
|
||||
setRenameDraft(agent.name);
|
||||
setRenameError(null);
|
||||
}, [agent.name, onRename]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
if (renameSaving) return;
|
||||
setRenameEditing(false);
|
||||
setRenameDraft(agent.name);
|
||||
setRenameError(null);
|
||||
}, [agent.name, renameSaving]);
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
if (!onRename || renameSaving) return;
|
||||
const nextName = renameDraft.trim();
|
||||
const currentName = agent.name.trim();
|
||||
if (!nextName) {
|
||||
setRenameError("Agent name is required.");
|
||||
return;
|
||||
}
|
||||
if (nextName === currentName) {
|
||||
setRenameEditing(false);
|
||||
setRenameError(null);
|
||||
setRenameDraft(agent.name);
|
||||
return;
|
||||
}
|
||||
setRenameSaving(true);
|
||||
setRenameError(null);
|
||||
try {
|
||||
const ok = await onRename(nextName);
|
||||
if (!ok) {
|
||||
setRenameError("Failed to rename agent.");
|
||||
return;
|
||||
}
|
||||
setRenameEditing(false);
|
||||
setRenameDraft(nextName);
|
||||
} finally {
|
||||
setRenameSaving(false);
|
||||
}
|
||||
}, [agent.name, onRename, renameDraft, renameSaving]);
|
||||
|
||||
const handleRenameInputKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void submitRename();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
},
|
||||
[cancelRename, submitRename]
|
||||
);
|
||||
|
||||
const handleNewSession = useCallback(async () => {
|
||||
if (!onNewSession || newSessionBusy || !canSend) return;
|
||||
setNewSessionBusy(true);
|
||||
@@ -1032,20 +1115,89 @@ export const AgentChatPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="type-agent-name min-w-0 truncate text-foreground">
|
||||
{agent.name}
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<div
|
||||
className={`min-w-0 transition-[max-width] duration-200 ease-out ${
|
||||
renameEditing ? "max-w-[32rem] flex-1" : "max-w-full"
|
||||
}`}
|
||||
>
|
||||
{renameEditing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="ui-input h-8 min-w-0 flex-1 rounded-md px-2 text-[12px] font-semibold text-foreground"
|
||||
aria-label="Edit agent name"
|
||||
data-testid="agent-rename-input"
|
||||
value={renameDraft}
|
||||
disabled={renameSaving}
|
||||
onChange={(event) => {
|
||||
setRenameDraft(event.target.value);
|
||||
if (renameError) setRenameError(null);
|
||||
}}
|
||||
onKeyDown={handleRenameInputKeyDown}
|
||||
/>
|
||||
<button
|
||||
className="ui-btn-icon h-8 w-8"
|
||||
type="button"
|
||||
aria-label="Save agent name"
|
||||
data-testid="agent-rename-save"
|
||||
onClick={() => {
|
||||
void submitRename();
|
||||
}}
|
||||
disabled={renameSaving}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="ui-btn-icon h-8 w-8"
|
||||
type="button"
|
||||
aria-label="Cancel agent rename"
|
||||
data-testid="agent-rename-cancel"
|
||||
onClick={cancelRename}
|
||||
disabled={renameSaving}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="type-agent-name min-w-0 truncate text-foreground">
|
||||
{agent.name}
|
||||
</div>
|
||||
{onRename ? (
|
||||
<button
|
||||
className="ui-btn-icon h-6 w-6 shrink-0"
|
||||
type="button"
|
||||
aria-label="Rename agent"
|
||||
data-testid="agent-rename-toggle"
|
||||
onClick={beginRename}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span aria-hidden className="shrink-0 text-[11px] text-muted-foreground/80">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`shrink-0 text-[11px] text-muted-foreground/80 transition-opacity duration-150 ${
|
||||
renameEditing ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
<span
|
||||
className={`ui-badge shrink-0 ${statusClassName}`}
|
||||
className={`ui-badge shrink-0 transition-transform duration-200 ease-out ${statusClassName} ${
|
||||
renameEditing ? "translate-x-1" : ""
|
||||
}`}
|
||||
data-status={agent.status}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
{renameError ? (
|
||||
<div className="ui-text-danger mt-1 text-[11px]">{renameError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-[minmax(0,1fr)_128px]">
|
||||
<label className="flex min-w-0 flex-col gap-1 font-mono text-[12px] font-medium tracking-[0.02em] text-muted-foreground">
|
||||
|
||||
@@ -92,7 +92,7 @@ const AgentCreateModalContent = ({
|
||||
/>
|
||||
</label>
|
||||
<div className="-mt-2 text-[11px] text-muted-foreground">
|
||||
You can rename this agent later in settings.
|
||||
You can rename this agent from the main chat header.
|
||||
</div>
|
||||
<div className="grid justify-items-center gap-2 border-t border-border/40 pt-3">
|
||||
<div className={labelClassName}>Choose avatar</div>
|
||||
|
||||
@@ -1129,7 +1129,6 @@ type AgentBrainPanelProps = {
|
||||
client: GatewayClient;
|
||||
agents: AgentState[];
|
||||
selectedAgentId: string | null;
|
||||
onRename: (value: string) => Promise<boolean>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -1292,7 +1291,6 @@ export const AgentBrainPanel = ({
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onRename,
|
||||
onClose,
|
||||
}: AgentBrainPanelProps) => {
|
||||
const selectedAgent = useMemo(
|
||||
@@ -1316,14 +1314,6 @@ export const AgentBrainPanel = ({
|
||||
reloadAgentFiles,
|
||||
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
||||
const [previewMode, setPreviewMode] = useState(true);
|
||||
const [nameDraft, setNameDraft] = useState(selectedAgent?.name ?? "");
|
||||
const [renameSaving, setRenameSaving] = useState(false);
|
||||
const [renameError, setRenameError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNameDraft(selectedAgent?.name ?? "");
|
||||
setRenameError(null);
|
||||
}, [selectedAgent?.agentId, selectedAgent?.name]);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
async (nextTab: PersonalityFileName) => {
|
||||
@@ -1332,33 +1322,6 @@ export const AgentBrainPanel = ({
|
||||
[handleAgentFileTabChange]
|
||||
);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
const currentName = selectedAgent?.name?.trim() ?? "";
|
||||
const next = nameDraft.trim();
|
||||
if (!next) {
|
||||
setRenameError("Agent name is required.");
|
||||
return;
|
||||
}
|
||||
if (!selectedAgent || !currentName) {
|
||||
setRenameError("No agent selected.");
|
||||
return;
|
||||
}
|
||||
if (next === currentName) {
|
||||
setRenameError(null);
|
||||
return;
|
||||
}
|
||||
setRenameSaving(true);
|
||||
setRenameError(null);
|
||||
try {
|
||||
const ok = await onRename(next);
|
||||
if (!ok) {
|
||||
setRenameError("Failed to rename agent.");
|
||||
}
|
||||
} finally {
|
||||
setRenameSaving(false);
|
||||
}
|
||||
}, [nameDraft, onRename, selectedAgent]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (agentFilesSaving) return;
|
||||
if (agentFilesDirty) {
|
||||
@@ -1381,41 +1344,11 @@ export const AgentBrainPanel = ({
|
||||
void handleClose();
|
||||
}}
|
||||
closeTestId="agent-personality-close"
|
||||
closeDisabled={agentFilesSaving || renameSaving}
|
||||
closeDisabled={agentFilesSaving}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<section className="pb-4 pt-1" data-testid="agent-personality-identity">
|
||||
<label className="sidebar-copy mt-1 flex flex-col gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground/88">Agent name</span>
|
||||
<input
|
||||
aria-label="Agent name"
|
||||
className="sidebar-input h-10 rounded-md px-3 text-xs font-semibold text-foreground outline-none"
|
||||
value={nameDraft}
|
||||
disabled={renameSaving}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{renameError ? (
|
||||
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">
|
||||
{renameError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2">
|
||||
<button
|
||||
className="sidebar-btn-utility w-full px-4 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:text-muted-foreground/55"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleRename();
|
||||
}}
|
||||
disabled={renameSaving}
|
||||
>
|
||||
{renameSaving ? "Saving..." : "Update Name"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 flex min-h-0 flex-1 flex-col" data-testid="agent-personality-files">
|
||||
<section className="flex min-h-0 flex-1 flex-col" data-testid="agent-personality-files">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||
{AGENT_FILE_META[agentFileTab].hint}
|
||||
|
||||
@@ -107,7 +107,6 @@ describe("AgentBrainPanel", () => {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
onRename: vi.fn(async () => true),
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
@@ -139,7 +138,6 @@ describe("AgentBrainPanel", () => {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "",
|
||||
onRename: vi.fn(async () => true),
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
@@ -159,7 +157,6 @@ describe("AgentBrainPanel", () => {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
onRename: vi.fn(async () => true),
|
||||
onClose,
|
||||
})
|
||||
);
|
||||
@@ -205,28 +202,23 @@ describe("AgentBrainPanel", () => {
|
||||
).toContain("- Name: Alpha Prime");
|
||||
});
|
||||
|
||||
it("renames_agent_from_personality_panel", async () => {
|
||||
it("does_not_render_name_editor_in_personality_panel", async () => {
|
||||
const { client } = createMockClient();
|
||||
const agents = [createAgent("agent-1", "Alpha", "session-1")];
|
||||
const onRename = vi.fn(async () => true);
|
||||
|
||||
render(
|
||||
createElement(AgentBrainPanel, {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
onRename,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Agent name"), {
|
||||
target: { value: " Alpha Prime " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Name" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalledWith("Alpha Prime");
|
||||
expect(screen.getByRole("button", { name: "Personality" })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
@@ -61,6 +61,7 @@ describe("AgentChatPanel controls", () => {
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
@@ -73,6 +74,8 @@ describe("AgentChatPanel controls", () => {
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("Agent One")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-rename-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Rename agent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-new-session-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Start new session")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-settings-toggle")).toBeInTheDocument();
|
||||
@@ -80,6 +83,75 @@ describe("AgentChatPanel controls", () => {
|
||||
expect(screen.queryByText("Inspect")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renames_agent_inline_from_header", async () => {
|
||||
const onRename = vi.fn(async () => true);
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onRename,
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-rename-toggle"));
|
||||
const input = screen.getByTestId("agent-rename-input") as HTMLInputElement;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveFocus();
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe("Agent One".length);
|
||||
});
|
||||
|
||||
fireEvent.change(input, { target: { value: " Agent Prime " } });
|
||||
fireEvent.click(screen.getByTestId("agent-rename-save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalledWith("Agent Prime");
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels_inline_rename_without_saving", () => {
|
||||
const onRename = vi.fn(async () => true);
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onRename,
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-rename-toggle"));
|
||||
fireEvent.change(screen.getByTestId("agent-rename-input"), {
|
||||
target: { value: "Edited Name" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("agent-rename-cancel"));
|
||||
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("agent-rename-input")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes_on_new_session_when_control_clicked", () => {
|
||||
const onNewSession = vi.fn(async () => {});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user