Merge pull request #71 from grp06/codex/simplify-personality-rename-flow

Move agent rename control to chat header
This commit is contained in:
George Pickett
2026-02-21 14:28:09 -08:00
committed by GitHub
6 changed files with 239 additions and 90 deletions
+1 -1
View File
@@ -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}
+4 -12
View File
@@ -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();
});
});
+73 -1
View File
@@ -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 () => {});