diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 2b1c4722e1..44c2ced89c 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -77,27 +77,24 @@ function InstalledAgentsHeader({

{t("agents:installedAgents")}

{t("agents:installedAgentsDescription")}

-
+
- +
); diff --git a/apps/web/components/settings/agents/agent-profiles-section.test.tsx b/apps/web/components/settings/agents/agent-profiles-section.test.tsx index c3b07cbfc5..3f5140f28e 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.test.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.test.tsx @@ -21,6 +21,8 @@ const AGENT = { profiles: [profile("p-1", "Alpha"), profile("p-2", "Beta")], } as unknown as Agent; +const EMPTY_AGENT = { ...AGENT, profiles: [] } as unknown as Agent; + // A minimal store: the component must read it at write time, so the test needs // real read-after-write semantics rather than a frozen snapshot. let storeState: { @@ -100,7 +102,7 @@ vi.mock("@/components/settings/agent-profile-delete-dialog", () => ({ ) : null, })); -import { ProfileRow } from "./agent-profiles-section"; +import { AgentProfilesSubList, ProfileRow } from "./agent-profiles-section"; function renderRows() { return render( @@ -191,3 +193,28 @@ describe("ProfileRow duplicate", () => { await waitFor(() => expect(mocks.duplicateAgentProfileAction).toHaveBeenCalledWith("p-1")); }); }); + +describe("AgentProfilesSubList layout", () => { + beforeEach(() => cleanup()); + afterEach(() => cleanup()); + + it("renders profile rows without the count or create action", () => { + render(); + + expect(screen.queryByText("2 profiles", { exact: true })).toBeNull(); + expect(screen.getAllByTestId("agent-profile-row")).toHaveLength(2); + expect(screen.queryByTestId("new-profile-claude")).toBeNull(); + }); + + it("omits the profile body when no agent record exists", () => { + render(); + + expect(screen.queryByTestId("agent-profiles-claude")).toBeNull(); + }); + + it("omits the profile body when the saved agent has no profiles", () => { + render(); + + expect(screen.queryByTestId("agent-profiles-claude")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/agents/agent-profiles-section.tsx b/apps/web/components/settings/agents/agent-profiles-section.tsx index e46468d624..6605598fcd 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { IconCopy, IconDotsVertical, IconPlus, IconTrash } from "@tabler/icons-react"; +import { IconCopy, IconDotsVertical, IconTrash } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; @@ -24,18 +24,14 @@ import type { Agent, AgentProfile } from "@/lib/types/http"; import { RecordDot } from "@/components/settings/record-dot"; import { DisabledBadge } from "@/components/settings/record-badges"; -function agentSetupHref(agentName: string): string { - return `/settings/agents/${encodeURIComponent(agentName)}?mode=create`; -} - function profileHref(agentName: string, profileId: string): string { return `/settings/agents/${encodeURIComponent(agentName)}/profiles/${encodeURIComponent(profileId)}`; } /** - * An agent's profiles inside its group card on the Agents page: a contrasted - * body with a count, a prominent "New profile" action, and one clickable row - * per profile (no agent branding — the group header already names the agent). + * An agent's profiles inside its group card on the Agents page: one clickable + * row per profile (no agent branding or duplicate creation controls because + * the group header already names the agent and owns its action). */ export function AgentProfilesSubList({ savedAgent, @@ -44,35 +40,18 @@ export function AgentProfilesSubList({ savedAgent: Agent | undefined; agentName: string; }) { - const { t } = useTranslation(); - const profiles = savedAgent?.profiles ?? []; + if (!savedAgent || savedAgent.profiles.length === 0) return null; + return (
-
- - {profiles.length === 0 - ? t("agents:noProfilesYet") - : t("agents:profileCount", { count: profiles.length })} - - +
+ {savedAgent.profiles.map((profile) => ( + + ))}
- {/* Narrowed on `savedAgent` rather than on `profiles.length`: the rows - need the agent itself, and only this check proves it is there. */} - {savedAgent && savedAgent.profiles.length > 0 && ( -
- {savedAgent.profiles.map((profile) => ( - - ))} -
- )}
); } diff --git a/apps/web/components/settings/installed-agent-card.test.tsx b/apps/web/components/settings/installed-agent-card.test.tsx new file mode 100644 index 0000000000..16ea726831 --- /dev/null +++ b/apps/web/components/settings/installed-agent-card.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Agent, AgentDiscovery } from "@/lib/types/http"; + +vi.mock("@/components/routing/app-link", () => ({ + default: ({ children, ...props }: { children?: ReactNode }) => {children}, +})); + +vi.mock("@/components/agent-logo", () => ({ + AgentLogo: () => , +})); + +vi.mock("@/components/settings/host-shell-dialog", () => ({ + HostShellDialog: () => null, +})); + +vi.mock("@/components/settings/agent-login-dialog", () => ({ + AgentLoginDialog: () => null, +})); + +vi.mock("@/components/settings/agent-runtime-update-control", () => ({ + AgentRuntimeUpdateControl: () => null, +})); + +import { InstalledAgentCard } from "./installed-agent-card"; + +const DISCOVERY = { + name: "mock-agent", + supports_mcp: false, + available: true, + matched_path: null, + login_command: undefined, +} as unknown as AgentDiscovery; + +const SAVED_WITHOUT_PROFILES = { + id: "agent-1", + name: "mock-agent", + profiles: [], +} as unknown as Agent; + +function renderCard(savedAgent?: Agent) { + return render( + , + ); +} + +afterEach(() => cleanup()); + +describe("InstalledAgentCard setup links", () => { + it("uses the base setup route when no agent record exists", () => { + renderCard(); + + expect(screen.getByTestId("setup-profile-mock-agent").getAttribute("href")).toBe( + "/settings/agents/mock-agent", + ); + }); + + it("uses create mode when a saved agent has no profiles", () => { + renderCard(SAVED_WITHOUT_PROFILES); + + expect(screen.getByTestId("setup-profile-mock-agent").getAttribute("href")).toBe( + "/settings/agents/mock-agent?mode=create", + ); + }); +}); diff --git a/apps/web/components/settings/installed-agent-card.tsx b/apps/web/components/settings/installed-agent-card.tsx index d6468d316b..20f01be01a 100644 --- a/apps/web/components/settings/installed-agent-card.tsx +++ b/apps/web/components/settings/installed-agent-card.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; -import { IconLoader2, IconLock, IconSettings } from "@tabler/icons-react"; +import { IconLoader2, IconLock, IconPlus, IconSettings } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { NotInstalledBadge } from "@/components/settings/record-badges"; import { Button } from "@kandev/ui/button"; @@ -136,6 +136,7 @@ export function InstalledAgentCard({ const { t } = useTranslation(); const configured = Boolean(savedAgent && savedAgent.profiles.length > 0); const hasAgentRecord = Boolean(savedAgent); + const agentHref = `/settings/agents/${encodeURIComponent(agent.name)}`; const [loginOpen, setLoginOpen] = useState(false); const [shellOpen, setShellOpen] = useState(false); const authRequired = capabilityStatus === "auth_required"; @@ -153,7 +154,10 @@ export function InstalledAgentCard({ return ( {/* Header section: identity + agent-level actions. */} -
+
)} - {!hasAgentRecord && ( - // Sized to match the runtime-update trigger it sits beside — a - // coarse-pointer target on a phone, 28px from `sm` up. The default - // `sm` size (24px) both mismatched that neighbour on desktop and - // left a sub-target-size tap area on touch. - + ) : ( + // Keep the setup action for agents without a usable profile. A + // saved record needs create mode so its first profile is drafted. +