fix: wrap Utility Agents sub-sections in bounded cards (#1654)

This commit is contained in:
Clément Fiere
2026-07-10 17:05:40 +02:00
committed by GitHub
parent b277dcdcdf
commit 319b49cf8f
5 changed files with 364 additions and 167 deletions
@@ -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 (
<div className="space-y-4">
<Separator />
<div>
<h3 className="text-lg font-semibold">Configuration Chat Agent</h3>
<Card data-testid="config-chat-agent-card">
<CardHeader>
<CardTitle className="text-base">
<h3>Configuration Chat Agent</h3>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Choose which agent profile to use for the Configuration Chat. This agent can manage your
workflows, agent profiles, and MCP configuration.
</p>
</div>
<Select value={currentProfileId || "none"} onValueChange={handleChange} disabled={saving}>
<SelectTrigger className="w-full max-w-sm cursor-pointer">
<SelectValue placeholder="Choose an agent profile..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No default</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="cursor-pointer">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={currentProfileId || "none"} onValueChange={handleChange} disabled={saving}>
<SelectTrigger className="w-full max-w-sm cursor-pointer">
<SelectValue placeholder="Choose an agent profile..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No default</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="cursor-pointer">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
);
}
@@ -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 (
<>
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold">Utility Agents</h2>
<p className="text-sm text-muted-foreground">
One-shot AI helpers for commits, PRs, and prompts.
</p>
<SettingsSection
icon={<IconWand className="h-5 w-5" />}
title="Utility Agents"
description="One-shot AI helpers for commits, PRs, and prompts."
>
<div className="space-y-4">
<DefaultModelSection
inferenceAgents={inferenceAgents}
defaultAgentId={defaultAgentId}
defaultModel={defaultModel}
onDefaultChange={handleDefaultChange}
onRefreshAgent={refreshAgent}
/>
<PerActionOverridesSection
builtins={builtins}
allModels={allModels}
defaultModel={defaultModel}
onModelChange={(agent, value) => handleBuiltinChange(agent, value, setAgents)}
onEdit={openEditDialog}
/>
<CustomAgentsSection
agents={customAgents}
onAdd={() => 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
}
}}
/>
</div>
<DefaultModelSection
inferenceAgents={inferenceAgents}
defaultAgentId={defaultAgentId}
defaultModel={defaultModel}
onDefaultChange={handleDefaultChange}
onRefreshAgent={refreshAgent}
/>
<PerActionOverridesSection
builtins={builtins}
allModels={allModels}
defaultModel={defaultModel}
onModelChange={(agent, value) => handleBuiltinChange(agent, value, setAgents)}
onEdit={openEditDialog}
/>
<CustomAgentsSection
agents={customAgents}
onAdd={() => 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
}
}}
/>
</div>
</SettingsSection>
<UtilityAgentDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
+114 -101
View File
@@ -2,8 +2,8 @@
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Separator } from "@kandev/ui/separator";
import {
Select,
SelectContent,
@@ -91,50 +91,54 @@ export function DefaultModelSection({
}));
return (
<div className="space-y-3">
<div>
<h3 className="text-base font-medium">Default utility agent model</h3>
<Card data-testid="utility-default-model-card">
<CardHeader>
<CardTitle className="text-base">
<h3>Default utility agent model</h3>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Select the default model used by all built-in utility actions.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="w-full sm:w-[180px]">
<Label className="text-xs text-muted-foreground mb-1 block">Agent</Label>
<Select value={defaultAgentId} onValueChange={(v) => onDefaultChange(v, "")}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{inferenceAgents.map((ia) => (
<SelectItem key={ia.id} value={ia.id}>
{ia.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="w-full sm:w-[180px]">
<Label className="text-xs text-muted-foreground mb-1 block">Agent</Label>
<Select value={defaultAgentId} onValueChange={(v) => onDefaultChange(v, "")}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{inferenceAgents.map((ia) => (
<SelectItem key={ia.id} value={ia.id}>
{ia.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full sm:w-[280px]">
<Label className="text-xs text-muted-foreground mb-1 block">Model</Label>
<ModelConfigSelector
modelOptions={selectorModels}
currentModel={selectedModel}
configOptions={selectedConfigOptions}
onModelChange={(v) => onDefaultChange(defaultAgentId, v)}
disabled={!defaultAgentId}
placeholder="Select model..."
ariaLabel="Default utility model settings"
/>
</div>
</div>
<div className="w-full sm:w-[280px]">
<Label className="text-xs text-muted-foreground mb-1 block">Model</Label>
<ModelConfigSelector
modelOptions={selectorModels}
currentModel={selectedModel}
configOptions={selectedConfigOptions}
onModelChange={(v) => onDefaultChange(defaultAgentId, v)}
disabled={!defaultAgentId}
placeholder="Select model..."
ariaLabel="Default utility model settings"
{defaultAgentId && (
<InferenceAgentStatusNote
agent={selectedAgent}
fallbackName={defaultAgentId}
onRefresh={() => onRefreshAgent(defaultAgentId)}
/>
</div>
</div>
{defaultAgentId && (
<InferenceAgentStatusNote
agent={selectedAgent}
fallbackName={defaultAgentId}
onRefresh={() => onRefreshAgent(defaultAgentId)}
/>
)}
</div>
)}
</CardContent>
</Card>
);
}
@@ -159,42 +163,44 @@ export function BuiltinActionRow({
return (
<div
className="flex items-center gap-4 py-2 px-2 rounded hover:bg-muted/50"
className="flex flex-col gap-2 py-2 px-2 rounded hover:bg-muted/50 md:flex-row md:items-center md:gap-4"
data-testid={`utility-action-row-${agent.id}`}
>
<div className="w-[420px] shrink-0">
<div className="text-sm font-medium">{agent.name}</div>
<div className="min-w-0 md:flex-1">
<div className="text-sm font-medium truncate">{agent.name}</div>
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
</div>
<Select value={currentValue} onValueChange={(v) => onModelChange(agent, v)}>
<SelectTrigger className="w-[240px] cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={USE_DEFAULT}>{defaultLabel}</SelectItem>
</SelectGroup>
{groupModelsByAgent(allModels).map((group) => (
<SelectGroup key={group.agentName}>
<SelectSeparator />
<SelectLabel>{group.agentName}</SelectLabel>
{group.models.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.modelName}
</SelectItem>
))}
<div className="flex items-center gap-2">
<Select value={currentValue} onValueChange={(v) => onModelChange(agent, v)}>
<SelectTrigger className="min-w-0 flex-1 cursor-pointer md:w-[240px] md:flex-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={USE_DEFAULT}>{defaultLabel}</SelectItem>
</SelectGroup>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(agent)}
className="h-7 w-7 p-0 cursor-pointer text-muted-foreground hover:text-foreground"
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
{groupModelsByAgent(allModels).map((group) => (
<SelectGroup key={group.agentName}>
<SelectSeparator />
<SelectLabel>{group.agentName}</SelectLabel>
{group.models.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.modelName}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(agent)}
className="h-7 w-7 p-0 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
@@ -257,10 +263,13 @@ export function PerActionOverridesSection({
const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default";
return (
<div className="space-y-2">
<Separator />
<h3 className="text-base font-medium pt-2">Actions</h3>
<div className="space-y-0">
<Card data-testid="utility-actions-card">
<CardHeader>
<CardTitle className="text-base">
<h3>Actions</h3>
</CardTitle>
</CardHeader>
<CardContent className="space-y-0">
{builtins.map((agent) => (
<BuiltinActionRow
key={agent.id}
@@ -271,8 +280,8 @@ export function PerActionOverridesSection({
onEdit={onEdit}
/>
))}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -286,30 +295,34 @@ type CustomAgentsSectionProps = {
export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomAgentsSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-medium">Custom utility agents</h3>
<p className="text-sm text-muted-foreground">
Create your own utility agents with custom prompts.
</p>
</div>
<Button onClick={onAdd} size="sm" className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{agents.length === 0 && (
<p className="text-sm text-muted-foreground py-4">No custom utility agents.</p>
)}
{agents.length > 0 && (
<div className="space-y-2">
{agents.map((agent) => (
<CustomAgentRow key={agent.id} agent={agent} onEdit={onEdit} onDelete={onDelete} />
))}
</div>
)}
</div>
<Card data-testid="utility-custom-agents-card">
<CardHeader>
<CardTitle className="text-base">
<h3>Custom utility agents</h3>
</CardTitle>
<CardAction>
<Button onClick={onAdd} size="sm" className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
Add
</Button>
</CardAction>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Create your own utility agents with custom prompts.
</p>
{agents.length === 0 && (
<p className="text-sm text-muted-foreground py-4">No custom utility agents.</p>
)}
{agents.length > 0 && (
<div className="space-y-2">
{agents.map((agent) => (
<CustomAgentRow key={agent.id} agent={agent} onEdit={onEdit} onDelete={onDelete} />
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -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();
});
});
@@ -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);
});
});