From 319b49cf8f05038aea7505b1f90fa0b621d79706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= Date: Fri, 10 Jul 2026 17:05:40 +0200 Subject: [PATCH] fix: wrap Utility Agents sub-sections in bounded cards (#1654) --- .../settings/config-chat-agent-section.tsx | 43 ++-- .../settings/utility-agents-section.tsx | 70 +++--- .../components/settings/utility-sections.tsx | 215 ++++++++++-------- .../settings/mobile-utility-agents.spec.ts | 108 +++++++++ .../e2e/tests/settings/utility-agents.spec.ts | 95 +++++++- 5 files changed, 364 insertions(+), 167 deletions(-) create mode 100644 apps/web/e2e/tests/settings/mobile-utility-agents.spec.ts diff --git a/apps/web/components/settings/config-chat-agent-section.tsx b/apps/web/components/settings/config-chat-agent-section.tsx index 3d0fa1d6e0..fdc5bd8bd6 100644 --- a/apps/web/components/settings/config-chat-agent-section.tsx +++ b/apps/web/components/settings/config-chat-agent-section.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { Separator } from "@kandev/ui/separator"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { updateWorkspaceAction } from "@/app/actions/workspaces"; @@ -47,28 +47,31 @@ export function ConfigChatAgentSection() { if (!workspace) return null; return ( -
- -
-

Configuration Chat Agent

+ + + +

Configuration Chat Agent

+
+
+

Choose which agent profile to use for the Configuration Chat. This agent can manage your workflows, agent profiles, and MCP configuration.

-
- -
+ + + ); } diff --git a/apps/web/components/settings/utility-agents-section.tsx b/apps/web/components/settings/utility-agents-section.tsx index cc9122bce3..03def81513 100644 --- a/apps/web/components/settings/utility-agents-section.tsx +++ b/apps/web/components/settings/utility-agents-section.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconWand } from "@tabler/icons-react"; import { listUtilityAgents, listInferenceAgents, @@ -10,6 +11,7 @@ import { type InferenceAgent, } from "@/lib/api/domains/utility-api"; import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { SettingsSection } from "@/components/settings/settings-section"; import { UtilityAgentDialog } from "@/components/settings/utility-agent-dialog"; import { DefaultModelSection, @@ -107,41 +109,41 @@ export function UtilityAgentsSection() { return ( <> -
-
-

Utility Agents

-

- One-shot AI helpers for commits, PRs, and prompts. -

+ } + title="Utility Agents" + description="One-shot AI helpers for commits, PRs, and prompts." + > +
+ + handleBuiltinChange(agent, value, setAgents)} + onEdit={openEditDialog} + /> + openEditDialog(null)} + onEdit={openEditDialog} + onDelete={async (agent) => { + try { + await deleteUtilityAgent(agent.id); + setAgents((prev) => prev.filter((a) => a.id !== agent.id)); + } catch { + // Error already logged by API layer + } + }} + />
- - handleBuiltinChange(agent, value, setAgents)} - onEdit={openEditDialog} - /> - openEditDialog(null)} - onEdit={openEditDialog} - onDelete={async (agent) => { - try { - await deleteUtilityAgent(agent.id); - setAgents((prev) => prev.filter((a) => a.id !== agent.id)); - } catch { - // Error already logged by API layer - } - }} - /> -
+ -
-

Default utility agent model

+ + + +

Default utility agent model

+
+
+

Select the default model used by all built-in utility actions.

-
-
-
- - +
+
+ + +
+
+ + onDefaultChange(defaultAgentId, v)} + disabled={!defaultAgentId} + placeholder="Select model..." + ariaLabel="Default utility model settings" + /> +
-
- - onDefaultChange(defaultAgentId, v)} - disabled={!defaultAgentId} - placeholder="Select model..." - ariaLabel="Default utility model settings" + {defaultAgentId && ( + onRefreshAgent(defaultAgentId)} /> -
-
- {defaultAgentId && ( - onRefreshAgent(defaultAgentId)} - /> - )} -
+ )} + + ); } @@ -159,42 +163,44 @@ export function BuiltinActionRow({ return (
-
-
{agent.name}
+
+
{agent.name}

{agent.description}

- onModelChange(agent, v)}> + + + + + + {defaultLabel} - ))} - - - + {groupModelsByAgent(allModels).map((group) => ( + + + {group.agentName} + {group.models.map((m) => ( + + {m.modelName} + + ))} + + ))} + + + +
); } @@ -257,10 +263,13 @@ export function PerActionOverridesSection({ const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default"; return ( -
- -

Actions

-
+ + + +

Actions

+
+
+ {builtins.map((agent) => ( ))} -
-
+ + ); } @@ -286,30 +295,34 @@ type CustomAgentsSectionProps = { export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomAgentsSectionProps) { return ( -
-
-
-

Custom utility agents

-

- Create your own utility agents with custom prompts. -

-
- -
- {agents.length === 0 && ( -

No custom utility agents.

- )} - {agents.length > 0 && ( -
- {agents.map((agent) => ( - - ))} -
- )} -
+ + + +

Custom utility agents

+
+ + + +
+ +

+ Create your own utility agents with custom prompts. +

+ {agents.length === 0 && ( +

No custom utility agents.

+ )} + {agents.length > 0 && ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+
); } diff --git a/apps/web/e2e/tests/settings/mobile-utility-agents.spec.ts b/apps/web/e2e/tests/settings/mobile-utility-agents.spec.ts new file mode 100644 index 0000000000..d7d98cba23 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-utility-agents.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "../../fixtures/test-base"; + +/** + * Regression guard for a Card-wrapping bug: `BuiltinActionRow` used fixed + * 420px/240px widths that, combined with the shared `Card`'s + * `overflow-hidden`, pushed the model selector and edit button outside the + * visible card on narrow viewports — clipped and unreachable rather than + * stacked. See PR #1654 review discussion. + */ +test.describe("Mobile utility agents action rows", () => { + test("model select and edit button stay reachable on a narrow viewport", async ({ testPage }) => { + await testPage.route("**/api/v1/utility/inference-agents", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + agents: [ + { + id: "claude", + name: "claude", + display_name: "Claude", + status: "ok", + models: [ + { id: "claude-fast", name: "Claude Fast", description: "", is_default: true }, + ], + }, + ], + }), + }), + ); + await testPage.route("**/api/v1/utility/agents/builtin-commit-message", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "builtin-commit-message", + name: "commit-message", + description: "Generate a commit message.", + builtin: true, + enabled: true, + agent_id: "claude", + model: "claude-fast", + }), + }), + ); + await testPage.route("**/api/v1/utility/agents", (route) => { + if (route.request().method() !== "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + agents: [ + { + id: "builtin-commit-message", + name: "commit-message", + description: "Generate a commit message.", + builtin: true, + enabled: true, + agent_id: "", + model: "", + }, + ], + }), + }); + }); + + await testPage.goto("/settings/utility-agents"); + await expect( + testPage.getByRole("heading", { name: "Utility Agents", exact: true }), + ).toBeVisible({ timeout: 15_000 }); + + const card = testPage.getByTestId("utility-actions-card"); + await expect(card).toBeVisible(); + + // The card must not have to clip its own content to stay within bounds + // — regression guard for the fixed 420px/240px row colliding with the + // shared Card's `overflow-hidden`. + const isOverflowing = await card.evaluate((el) => el.scrollWidth > el.clientWidth + 1); + expect(isOverflowing).toBe(false); + + // The model selector must also stay a comfortably usable width — not + // just "not clipped" but not squeezed illegibly thin either. The row + // stacks below `md`, so the select gets the card's full content width + // to itself (minus the edit button), not a slice shared with the name + // column. + const row = testPage.getByTestId("utility-action-row-builtin-commit-message"); + const select = row.getByRole("combobox"); + const selectBox = await select.boundingBox(); + expect(selectBox).not.toBeNull(); + expect(selectBox!.width).toBeGreaterThanOrEqual(150); + + // The model selector and edit button must stay clickable, not clipped + // outside the card. + await select.click(); + await testPage.getByRole("option", { name: "Claude Fast", exact: true }).click(); + await expect(select).toContainText("Claude Fast"); + + await row.getByRole("button").click(); + await expect(testPage.getByRole("dialog")).toBeVisible(); + await expect(testPage.getByText("Edit Utility Agent")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/settings/utility-agents.spec.ts b/apps/web/e2e/tests/settings/utility-agents.spec.ts index 4fc83d800a..445a903cb8 100644 --- a/apps/web/e2e/tests/settings/utility-agents.spec.ts +++ b/apps/web/e2e/tests/settings/utility-agents.spec.ts @@ -167,9 +167,7 @@ test.describe("Utility Agents settings page", () => { ).toBeVisible(); // Default-model section. - await expect( - testPage.getByRole("heading", { name: "Default utility agent model", exact: true }), - ).toBeVisible(); + await expect(testPage.getByText("Default utility agent model", { exact: true })).toBeVisible(); // Built-in actions (seeded on first boot — see builtins.go). // Assert a representative subset; the full list lives server-side. @@ -178,9 +176,7 @@ test.describe("Utility Agents settings page", () => { await expect(testPage.getByText("enhance-prompt", { exact: true })).toBeVisible(); // Custom agents section + empty state. - await expect( - testPage.getByRole("heading", { name: "Custom utility agents", exact: true }), - ).toBeVisible(); + await expect(testPage.getByText("Custom utility agents", { exact: true })).toBeVisible(); await expect(testPage.getByText("No custom utility agents.")).toBeVisible(); expect(pageErrors, `uncaught errors: ${pageErrors.map((e) => e.message).join("; ")}`).toEqual( @@ -188,6 +184,85 @@ test.describe("Utility Agents settings page", () => { ); }); + test("wraps each settings sub-section in a bounded card, matching Voice Mode", async ({ + testPage, + }) => { + // Regression guard: these sub-sections used to be bare divs with no + // visible border, unlike every other settings page (e.g. Voice Mode's + // Card-wrapped Enable/Engine/Behavior sections). + // + // Mock the inference-agent + builtin-agent APIs so the Actions card + // (which renders null when there are no builtins — see + // PerActionOverridesSection) is deterministically present, instead of + // depending on the backend's boot-time seed state. + await testPage.route("**/api/v1/utility/inference-agents", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + agents: [ + { + id: "claude", + name: "claude", + display_name: "Claude", + status: "ok", + models: [ + { id: "claude-fast", name: "Claude Fast", description: "", is_default: true }, + ], + }, + ], + }), + }), + ); + await testPage.route("**/api/v1/utility/agents", (route) => { + if (route.request().method() !== "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + agents: [ + { + id: "builtin-commit-message", + name: "commit-message", + description: "Generate a commit message.", + builtin: true, + enabled: true, + agent_id: "", + model: "", + }, + ], + }), + }); + }); + + await testPage.goto("/settings/utility-agents"); + await expect( + testPage.getByRole("heading", { name: "Utility Agents", exact: true }), + ).toBeVisible({ timeout: 15_000 }); + + for (const { testId, heading } of [ + { testId: "utility-default-model-card", heading: "Default utility agent model" }, + { testId: "utility-actions-card", heading: "Actions" }, + { testId: "utility-custom-agents-card", heading: "Custom utility agents" }, + { testId: "config-chat-agent-card", heading: "Configuration Chat Agent" }, + ]) { + const card = testPage.getByTestId(testId); + await expect(card).toBeVisible(); + await expect(card).toHaveAttribute("data-slot", "card"); + // Card titles must stay real headings (level 3), not just styled div + // text, so screen-reader heading navigation keeps working. + await expect( + card.getByRole("heading", { name: heading, level: 3, exact: true }), + ).toBeVisible(); + } + }); + test("opens the create-agent dialog from the Add button", async ({ testPage }) => { const pageErrors: Error[] = []; testPage.on("pageerror", (err) => pageErrors.push(err)); @@ -389,9 +464,7 @@ test.describe("Utility Agents settings page", () => { await expect( testPage.getByRole("heading", { name: "Utility Agents", exact: true }), ).toBeVisible({ timeout: 15_000 }); - await expect( - testPage.getByRole("heading", { name: "Configuration Chat Agent", exact: true }), - ).toBeVisible(); + await expect(testPage.getByText("Configuration Chat Agent", { exact: true })).toBeVisible(); await expect( testPage.getByText( "Choose which agent profile to use for the Configuration Chat. This agent can manage your workflows, agent profiles, and MCP configuration.", @@ -402,8 +475,6 @@ test.describe("Utility Agents settings page", () => { await expect(testPage.getByRole("heading", { name: "Agents", exact: true })).toBeVisible({ timeout: 15_000, }); - await expect( - testPage.getByRole("heading", { name: "Configuration Chat Agent", exact: true }), - ).toHaveCount(0); + await expect(testPage.getByText("Configuration Chat Agent", { exact: true })).toHaveCount(0); }); });