refactor(ui): move Agent Defaults entry from the sidebar into the Agents page (#115836)

* refactor(ui): move Agent Defaults entry from the sidebar into the Agents page

* fix(ui): keep Agent Defaults searchable
This commit is contained in:
Peter Steinberger
2026-07-29 06:30:40 -04:00
committed by GitHub
parent 662abec754
commit af70829cdf
12 changed files with 120 additions and 10 deletions
+2 -2
View File
@@ -282,14 +282,14 @@ select it to open the owning Approvals page.
</Accordion>
<Accordion title="Config">
- View/edit `~/.openclaw/openclaw.json` (`config.get`, `config.set`).
- Settings navigation starts with Ask OpenClaw, Profile, Appearance, and Notifications up top; Connections (Connection, Channels, Communications, Talk, Devices); Agents & Tools (Agents, AI & Agents, Labs, Models, MCP, Memory, Automation); Privacy & Security (Security, Approvals); and System (Infrastructure, Advanced, Debug, Logs, About). Language leads the Appearance page, model defaults live on Models, and Gateway host details live on Connection.
- Settings navigation starts with Ask OpenClaw, Profile, Appearance, and Notifications up top; Connections (Connection, Channels, Communications, Talk, Devices); Agents & Tools (Agents, Labs, Models, MCP, Memory, Automation); Privacy & Security (Security, Approvals); and System (Infrastructure, Advanced, Debug, Logs, About). Language leads the Appearance page, model defaults live on Models, and Gateway host details live on Connection.
- Privacy & Security: curated rows for gateway auth, exec policy, browser enablement, tool profile, device auth, and mobile pairing, above the schema-backed `security`/`approvals` sections.
- Approvals includes newest-first, 30-day history for resolved exec, plugin, and system-agent requests. Filter by kind or page through older rows to review the decision, reason, source session, and resolver attribution recorded by the Gateway.
- Labs exposes shipped experimental switches. Code Mode and Swarm are the current entries and save `tools.codeMode.enabled` and `tools.swarm.enabled` immediately; unshipped experiments do not appear or write speculative config keys.
- Notifications: browser web-push status, subscribe/unsubscribe, and a test send.
- Advanced: every config section without a curated home, plus the raw JSON5 editor (previously the General page's Advanced mode).
- Model Setup (`/settings/model-setup`) is a subpage of Model Providers, launched from its header.
- Agents: a settings page (**Settings → Agents**, `/settings/agents`) with per-agent tabs (Overview, Files, Tools, Skills, Channels, Automations, Memory). The Overview tab edits the agent's identity — display name, emoji, and an avatar image that is downscaled and size-bounded in the browser before `agents.update`. Saving stores configured identity fields and mirrors them to the workspace `IDENTITY.md`; configured values take precedence over manual edits to the same file fields.
- Agents: a settings page (**Settings → Agents**, `/settings/agents`) with an **Agent defaults** row for the shared template plus per-agent tabs (Overview, Files, Tools, Skills, Channels, Automations, Memory). The Overview tab edits the agent's identity — display name, emoji, and an avatar image that is downscaled and size-bounded in the browser before `agents.update`. Saving stores configured identity fields and mirrors them to the workspace `IDENTITY.md`; configured values take precedence over manual edits to the same file fields.
- Profile: a settings page showing the default agent's identity with all-time usage stats — lifetime tokens, peak day, longest session, activity streaks, a year-long token heatmap, top tools, and channel highlights (`usage.cost`, `sessions.usage`).
- MCP has a dedicated settings page with server rows (transport, enablement, OAuth/filter/parallel summaries), direct add/enable/disable/remove controls, common operator commands, and the scoped `mcp` config editor. The Plugins page remains the home for one-click connectors and discovery.
- Model Providers: a settings page listing every configured model provider with its brand icon, auth state (`models.authStatus`), model availability (`models.list`), live plan/quota/billing data where the provider reports it (`usage.status`), and local session spend for the last 30 days (`sessions.usage`). A Refresh action re-reads credential state and provider usage.
+6
View File
@@ -62,6 +62,12 @@ describe("sidebar entries", () => {
expect(settingsNavigationOwnerRoute("model-setup")).toBe("model-providers");
});
it("keeps Agent Defaults routed as an Agents subpage without a sidebar entry", () => {
expect(settingsRoutes).not.toContain("ai-agents");
expect(isSettingsNavigationRoute("ai-agents")).toBe(true);
expect(settingsNavigationOwnerRoute("ai-agents")).toBe("agents");
});
it("keeps devices in connection settings and drops stale pinned entries", () => {
expect(SIDEBAR_NAV_ROUTES).not.toContain("nodes");
expect(settingsRoutes).toContain("nodes");
+1 -1
View File
@@ -132,6 +132,7 @@ const ALL_ROUTES: RouteId[] = Array.from(
// Hub tabs and settings subpages route without their own nav entry.
"worktrees",
"memory-import",
"ai-agents",
"model-setup",
"lobsterdex",
...SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.routes),
@@ -787,7 +788,6 @@ describe("SIDEBAR_NAV_ROUTES", () => {
"talk",
"nodes",
"agents",
"ai-agents",
"labs",
"model-providers",
"mcp",
+8 -2
View File
@@ -185,7 +185,7 @@ export const SETTINGS_NAVIGATION_GROUPS = [
},
{
labelKey: "nav.settingsGroupAgents",
routes: ["agents", "ai-agents", "labs", "model-providers", "mcp", "memory", "automation"],
routes: ["agents", "labs", "model-providers", "mcp", "memory", "automation"],
},
{
labelKey: "nav.settingsGroupSecurity",
@@ -200,10 +200,16 @@ export const SETTINGS_NAVIGATION_GROUPS = [
// Settings subpages render with settings chrome but stay out of the sidebar.
// Subpages with a visible owner keep that owner selected so users retain
// location context while completing the nested flow.
const SETTINGS_SUBPAGE_ROUTES: readonly NavigationRouteId[] = ["model-setup", "lobsterdex"];
const SETTINGS_SUBPAGE_ROUTES: readonly NavigationRouteId[] = [
"ai-agents",
"model-setup",
"lobsterdex",
];
export const SETTINGS_SEARCHABLE_SUBPAGE_ROUTES: readonly NavigationRouteId[] = ["ai-agents"];
const SETTINGS_SUBPAGE_OWNER_ROUTES: Partial<
Readonly<Record<NavigationRouteId, NavigationRouteId>>
> = {
"ai-agents": "agents",
"model-setup": "model-providers",
};
@@ -235,6 +235,34 @@ describe("settings sidebar search", () => {
});
});
it("finds Agent Defaults by page name after its sidebar demotion", () => {
render(
renderSettingsSidebar({
basePath: "",
activeRouteId: "agents",
offline: false,
lastError: null,
version: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
searchQuery: "agent defaults",
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate: vi.fn(),
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
saveIndicator: saveIndicator(),
}),
container,
);
const result = container.querySelector<HTMLAnchorElement>(
'.settings-sidebar__item[href="/settings/ai-agents"]',
);
expect(result?.textContent?.trim()).toBe("Agent Defaults");
});
it("keeps Memory search results on the canonical Settings tab path", () => {
const onNavigate = vi.fn();
render(
+11 -3
View File
@@ -6,6 +6,7 @@ import {
navigationIconForRoute,
scheduleRoutePreload,
SETTINGS_NAVIGATION_GROUPS,
SETTINGS_SEARCHABLE_SUBPAGE_ROUTES,
settingsNavigationLabelForRoute,
settingsNavigationOwnerRoute,
settingsSearchTextMatches,
@@ -79,8 +80,15 @@ function filterSettingsNavigationGroups(
items: group.routes.map((routeId) => ({ routeId, blocks: [] })),
}));
}
const allRoutes = SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.routes);
const directRoutes = allRoutes.filter((routeId) =>
const sidebarRoutes = SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.routes);
const searchableRoutes = [
...new Set([
...sidebarRoutes,
...SETTINGS_SEARCHABLE_SUBPAGE_ROUTES,
...blockMatches.map((block) => block.routeId),
]),
];
const directRoutes = searchableRoutes.filter((routeId) =>
[
settingsNavigationLabelForRoute(routeId),
titleForRoute(routeId),
@@ -128,7 +136,7 @@ function filterSettingsNavigationGroups(
},
]
: []),
...allRoutes
...searchableRoutes
.filter((routeId) => !includedRoutes.has(routeId) && blocksByRoute.has(routeId))
.map((routeId) => ({
labelKey: null,
@@ -159,6 +159,16 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
},
},
},
tools: {
type: "object",
title: "Tools",
properties: {
profile: {
type: "string",
description: "Controls sandbox access",
},
},
},
},
},
uiHints: {},
@@ -245,6 +255,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await holdUiProof(page);
const settingsLinks = settingsSidebar.locator(".settings-sidebar__item");
const allSettingsLabels = await trimmedTextContents(settingsLinks);
expect(allSettingsLabels).not.toContain("Agent Defaults");
await expect
.poll(() =>
settingsSearch.evaluate((input) => {
@@ -370,6 +381,28 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await settingsSidebar.getByRole("button", { name: "Clear settings search" }).click();
await expect.poll(() => trimmedTextContents(settingsLinks)).toEqual(allSettingsLabels);
await holdUiProof(page, 300);
await settingsSidebar.getByRole("link", { name: "Agents", exact: true }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/agents");
const agentDefaultsRow = page.getByRole("button", {
name: "Agent defaults Defaults every agent inherits unless overridden.",
});
await expect.poll(() => agentDefaultsRow.isVisible()).toBe(true);
await agentDefaultsRow.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/ai-agents");
await expect
.poll(() =>
settingsSidebar
.getByRole("link", { name: "Agents", exact: true })
.getAttribute("aria-current"),
)
.toBe("page");
await settingsSearch.fill("sandbox access");
const toolsResult = settingsSidebar.getByRole("link", { name: "Tools", exact: true });
await expect.poll(() => toolsResult.isVisible()).toBe(true);
await toolsResult.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/ai-agents");
await expect.poll(() => new URL(page.url()).search).toBe("?section=tools&advanced=1");
await expect.poll(() => new URL(page.url()).hash).toBe("#config-section-tools");
await settingsSearch.fill("channel");
await captureSettingsSidebarProof(settingsSidebar, "01e-settings-search-route.png");
await holdUiProof(page);
+4
View File
@@ -871,6 +871,10 @@ export const en: TranslationMap = {
},
agents: {
noAgents: "No agents",
defaults: {
title: "Agent defaults",
description: "Defaults every agent inherits unless overridden.",
},
copyId: "Copy ID",
copyIdTitle: "Copy agent ID to clipboard",
default: "Default",
+1
View File
@@ -937,6 +937,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState {
onChannelsRefresh: () => void this.context.channels.refresh(false),
onOpenMemoryImport: () => this.context.navigate("memory-import"),
onOpenMemorySettings: () => this.context.navigate("memory"),
onOpenAgentDefaults: () => this.context.navigate("ai-agents"),
onCronRefresh: () => void this.refreshCron(),
onCronRunNow: (jobId) => this.runCronJobNow(jobId),
onSkillsFilterChange: (next) => (this.skillsFilter = next),
+16
View File
@@ -153,11 +153,27 @@ function createProps(overrides: Partial<AgentsProps> = {}): AgentsProps {
onIdentityAvatarSelect: () => undefined,
onIdentitySave: () => undefined,
onTogglePinnedAgent: () => undefined,
onOpenAgentDefaults: () => undefined,
...overrides,
};
}
describe("renderAgents", () => {
it("opens global Agent defaults before the per-agent tabs", () => {
const container = document.createElement("div");
const onOpenAgentDefaults = vi.fn();
render(renderAgents(createProps({ onOpenAgentDefaults })), container);
const defaultsRow = container.querySelector<HTMLButtonElement>(".settings-row--nav");
const tabs = container.querySelector(".agent-tabs");
expect(defaultsRow?.textContent).toContain("Agent defaults");
expect(defaultsRow?.textContent).toContain("Defaults every agent inherits unless overridden.");
expect(defaultsRow?.compareDocumentPosition(tabs!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
defaultsRow?.click();
expect(onOpenAgentDefaults).toHaveBeenCalledOnce();
});
it("prefills the identity editor from the fetched agent identity", () => {
const container = document.createElement("div");
render(
+8
View File
@@ -133,6 +133,7 @@ type AgentsProps = {
onChannelsRefresh: () => void;
onOpenMemoryImport?: () => void;
onOpenMemorySettings?: () => void;
onOpenAgentDefaults: () => void;
onCronRefresh: () => void;
onCronRunNow: (jobId: string) => void;
onSkillsFilterChange: (next: string) => void;
@@ -235,6 +236,13 @@ export function renderAgents(props: AgentsProps) {
: nothing}
</section>
<section class="agents-main">
<div class="settings-group">
${renderSettingsNavRow({
title: t("agents.defaults.title"),
description: t("agents.defaults.description"),
onClick: props.onOpenAgentDefaults,
})}
</div>
${!selectedAgent
? renderSettingsSection(
{ title: t("agents.selectTitle") },
+2 -2
View File
@@ -32,8 +32,8 @@ const INFRASTRUCTURE_SECTION_KEYS = ["gateway", "browser", "nodeHost", "discover
const MCP_SECTION_KEYS = ["mcp"] as const;
// Curated Memory home: engine/backend/add-on rows plus the Dreaming tab render
// above the memory schema section (memory.ts). Memory left AI & Agents because
// the engine choice and dreaming's global cron are not agent defaults.
// above the memory schema section (memory.ts). Memory stays separate from Agent
// Defaults because the engine choice and dreaming's global cron are not defaults.
const MEMORY_SECTION_KEYS = ["memory"] as const;
const AI_AGENTS_SECTION_KEYS = ["agents", "skills", "tools", "session"] as const;