diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9798dfa31468..f1f2b7011d6b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -282,14 +282,14 @@ select it to open the owning Approvals page. - 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. diff --git a/ui/src/app-navigation-groups.test.ts b/ui/src/app-navigation-groups.test.ts index b936fa2c80b0..4b975659af61 100644 --- a/ui/src/app-navigation-groups.test.ts +++ b/ui/src/app-navigation-groups.test.ts @@ -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"); diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 0a88181699f1..7e190b075785 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -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", diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index 1e45d3974c17..aca1e9989b69 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -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> > = { + "ai-agents": "agents", "model-setup": "model-providers", }; diff --git a/ui/src/components/settings-sidebar.test.ts b/ui/src/components/settings-sidebar.test.ts index a04e53a00e64..bd68ab665ee2 100644 --- a/ui/src/components/settings-sidebar.test.ts +++ b/ui/src/components/settings-sidebar.test.ts @@ -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( + '.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( diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index bfa242ac3d09..a07ccf0faf24 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -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, diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index f5d9430444d8..6cdbd429c178 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -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); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2a4369723618..f313d7f724a4 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index a8ce795c728d..f05bb2ed9d38 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -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), diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index bb6a92f06752..88f5df003f67 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -153,11 +153,27 @@ function createProps(overrides: Partial = {}): 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(".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( diff --git a/ui/src/pages/agents/view.ts b/ui/src/pages/agents/view.ts index 86fb08816678..b59282cb7d83 100644 --- a/ui/src/pages/agents/view.ts +++ b/ui/src/pages/agents/view.ts @@ -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}
+
+ ${renderSettingsNavRow({ + title: t("agents.defaults.title"), + description: t("agents.defaults.description"), + onClick: props.onOpenAgentDefaults, + })} +
${!selectedAgent ? renderSettingsSection( { title: t("agents.selectTitle") }, diff --git a/ui/src/pages/config/config-sections.ts b/ui/src/pages/config/config-sections.ts index e64d4135baee..1e7ed9a0455d 100644 --- a/ui/src/pages/config/config-sections.ts +++ b/ui/src/pages/config/config-sections.ts @@ -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;