From 79baf722d5ea44b8c6ca228f6a4596068ae365d7 Mon Sep 17 00:00:00 2001 From: Torey Scheer <87029548+toreysoloio@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:33:42 -0500 Subject: [PATCH 1/2] feat(ui): merge the create buttons into one Create modal The agents page header had three create buttons. They are now one Create button that opens a modal listing Agent, AgentTemplate and Harness, each with a short tagline. The overview diagram gets a small "+" on the Agent, AgentTemplate and Harness boxes, so each create form is still one click away without interfering with the box's own tab navigation. Two extension points: app_agents_agentsList_overview_createActions (per diagram box, told its kind) and app_agents_agentsList_createModal_options (extra rows in the modal, built with the exported CreateOptionRow). Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Torey Scheer <87029548+toreysoloio@users.noreply.github.com> --- ui/docs/app-extensions.md | 2 + ui/playwright/helpers/extensions.ts | 2 + .../agent-templates/agent-templates.spec.ts | 1 + ui/playwright/tests/agents/agent-crud.spec.ts | 1 + ui/playwright/tests/agents/agents.spec.ts | 7 + ui/playwright/tests/app-shell.spec.ts | 5 + .../tests/harnesses/harnesses.spec.ts | 1 + ui/src/appExtensions/extensionPoints.ts | 9 ++ ui/src/appExtensions/index.ts | 4 + ui/src/components/CreateOptionRow.tsx | 42 ++++++ ui/src/pages/agents/AgentConcepts.tsx | 141 +++++++++++------- ui/src/pages/agents/AgentsLandingPage.tsx | 47 +++--- ui/src/pages/agents/createOptions.ts | 35 +++++ 13 files changed, 228 insertions(+), 69 deletions(-) create mode 100644 ui/src/components/CreateOptionRow.tsx create mode 100644 ui/src/pages/agents/createOptions.ts diff --git a/ui/docs/app-extensions.md b/ui/docs/app-extensions.md index eaeb1da2eb..2bffbb8f15 100644 --- a/ui/docs/app-extensions.md +++ b/ui/docs/app-extensions.md @@ -249,6 +249,8 @@ point lives. | `app_shell_appLayout_contentArea_globalOverlay` | none | | `app_shell_appLayout_appSidebar_footer` | none | | `app_agents_agentsList_pageHeader_actions` | none | +| `app_agents_agentsList_overview_createActions` | `{ kind: "Agent" \| "AgentTemplate" \| "Harness" }` | +| `app_agents_agentsList_createModal_options` | none. Render a `CreateOptionRow` (`{ title, tagline, to, testId? }`), exported from `@/appExtensions`, so the row matches the built-in ones | | `app_agents_agentsList_agentListItem_badge` | `{ agentName: string; namespace: string }` | | `app_agents_agentChat_agentChatMessage_additionalActionsButton` | `{ messageId: string; role: "user" \| "agent"; text: string }` | | `app_agents_agentChat_snapshotDetails_footer` | `{ snapshotId: string; instanceId: string; label: string }` | diff --git a/ui/playwright/helpers/extensions.ts b/ui/playwright/helpers/extensions.ts index ee209dee62..ccaae48298 100644 --- a/ui/playwright/helpers/extensions.ts +++ b/ui/playwright/helpers/extensions.ts @@ -18,6 +18,8 @@ export const EXTENSION_POINT_IDS = [ "app_shell_appLayout_contentArea_globalOverlay", "app_shell_appLayout_appSidebar_footer", "app_agents_agentsList_pageHeader_actions", + "app_agents_agentsList_overview_createActions", + "app_agents_agentsList_createModal_options", "app_agents_agentsList_agentListItem_badge", "app_agents_agentChat_agentChatMessage_additionalActionsButton", "app_dashboard_dashboardOverview_summaryGrid_leadingCard", diff --git a/ui/playwright/tests/agent-templates/agent-templates.spec.ts b/ui/playwright/tests/agent-templates/agent-templates.spec.ts index d0ec66a587..d3acd80905 100644 --- a/ui/playwright/tests/agent-templates/agent-templates.spec.ts +++ b/ui/playwright/tests/agent-templates/agent-templates.spec.ts @@ -68,6 +68,7 @@ test("agent templates: a template is created, read, edited and deleted", async ( }); await test.step("4. the form says what a template is, and refuses an unusable one", async () => { + await page.getByTestId("agents-create").click(); await page.getByTestId("agents-new-template").click(); await page.waitForURL(/\/agent-templates\/new(\?|$)/); diff --git a/ui/playwright/tests/agents/agent-crud.spec.ts b/ui/playwright/tests/agents/agent-crud.spec.ts index 904effca8b..452ec244cb 100644 --- a/ui/playwright/tests/agents/agent-crud.spec.ts +++ b/ui/playwright/tests/agents/agent-crud.spec.ts @@ -19,6 +19,7 @@ for (const template of CASES) { test(`agents: CRUD with ${template === "inline" ? "an" : "a"} ${template} template and referenced harness`, async ({ page }) => { await test.step("1. create through the full-page form", async () => { await loadPage(page, routes.agents, { title: "Agents" }); + await page.getByTestId("agents-create").click(); await page.getByTestId("agents-new").click(); await expect(page).toHaveURL(/\/agents\/new$/); await expect(page.getByTestId("agent-form-namespace")).toContainText("kagent"); diff --git a/ui/playwright/tests/agents/agents.spec.ts b/ui/playwright/tests/agents/agents.spec.ts index b1dee0980b..cec02b0d61 100644 --- a/ui/playwright/tests/agents/agents.spec.ts +++ b/ui/playwright/tests/agents/agents.spec.ts @@ -230,9 +230,16 @@ test("agents: the landing page is three tabs, and the tab is in the address", as await test.step("4. the header creates each of the three resources", async () => { await page.getByRole("tab", { name: "Agents" }).click(); + await page.getByTestId("agents-create").click(); await expect(page.getByTestId("agents-new")).toBeVisible(); await expect(page.getByTestId("agents-new-template")).toBeVisible(); await expect(page.getByTestId("agents-new-harness")).toBeVisible(); + // Closed again: the modal's mask would make the diagram's "+" inert. + await page.keyboard.press("Escape"); + await expect(page.getByTestId("agents-new")).toBeHidden(); + for (const k of ["Agent", "AgentTemplate", "Harness"]) { + await expect(page.getByTestId(`concept-create-${k}`)).toBeVisible(); + } }); }); diff --git a/ui/playwright/tests/app-shell.spec.ts b/ui/playwright/tests/app-shell.spec.ts index 8c24e8f71d..583a12f500 100644 --- a/ui/playwright/tests/app-shell.spec.ts +++ b/ui/playwright/tests/app-shell.spec.ts @@ -92,6 +92,11 @@ test("app shell: chrome, navigation entries, and where creation lives", async ({ for (const { route, title, testId, form } of creates) { await loadPage(page, route, { title }); + // The agents page puts its three creates behind one "Create" button. + if (route === routes.agents) { + await expect(page.getByTestId("agents-create")).toBeVisible(); + await page.getByTestId("agents-create").click(); + } const create = page.getByTestId(testId); await expect(create, `${title} has no create control`).toBeVisible(); await create.click(); diff --git a/ui/playwright/tests/harnesses/harnesses.spec.ts b/ui/playwright/tests/harnesses/harnesses.spec.ts index 52d9dceae0..5a734e26ff 100644 --- a/ui/playwright/tests/harnesses/harnesses.spec.ts +++ b/ui/playwright/tests/harnesses/harnesses.spec.ts @@ -67,6 +67,7 @@ test("harnesses: a harness is created, read, edited and deleted", async ({ page }); await test.step("3. the form explains itself and marks what the cluster requires", async () => { + await page.getByTestId("agents-create").click(); await page.getByTestId("agents-new-harness").click(); await page.waitForURL(/\/harnesses\/new(\?|$)/); diff --git a/ui/src/appExtensions/extensionPoints.ts b/ui/src/appExtensions/extensionPoints.ts index 4e66d73915..23095008ba 100644 --- a/ui/src/appExtensions/extensionPoints.ts +++ b/ui/src/appExtensions/extensionPoints.ts @@ -14,6 +14,8 @@ export const EXTENSION_POINT_IDS = [ "app_shell_appLayout_contentArea_globalOverlay", "app_shell_appLayout_appSidebar_footer", "app_agents_agentsList_pageHeader_actions", + "app_agents_agentsList_overview_createActions", + "app_agents_agentsList_createModal_options", "app_agents_agentsList_agentListItem_badge", "app_agents_agentChat_agentChatMessage_additionalActionsButton", "app_agents_agentChat_snapshotDetails_footer", @@ -24,6 +26,9 @@ export const EXTENSION_POINT_IDS = [ "app_dashboard_dashboardOverview_summaryGrid_leadingCard", ] as const; +/** The resources the Agents page can create. The page's create options are keyed by this. */ +export type CreatableKind = "Agent" | "AgentTemplate" | "Harness"; + /** Union of valid point IDs. A typo anywhere is a compile error. */ export type ExtensionPointId = (typeof EXTENSION_POINT_IDS)[number]; @@ -39,6 +44,8 @@ type PropsFor>> = T; * take no context — their components render from the extension's own state. */ type ExtensionPointPropsMap = PropsFor<{ + /** One per create-able box in the overview diagram, told which kind it sits on. */ + app_agents_agentsList_overview_createActions: { kind: CreatableKind }; app_agents_agentsList_agentListItem_badge: { agentName: string; namespace: string; @@ -166,6 +173,8 @@ export const EXTENSION_POINT_RENDER_MODE: Record< app_shell_appLayout_contentArea_globalOverlay: "portal", app_shell_appLayout_appSidebar_footer: "inline", app_agents_agentsList_pageHeader_actions: "inline", + app_agents_agentsList_overview_createActions: "inline", + app_agents_agentsList_createModal_options: "inline", app_agents_agentsList_agentListItem_badge: "inline", app_agents_agentChat_agentChatMessage_additionalActionsButton: "inline", app_agents_agentChat_snapshotDetails_footer: "inline", diff --git a/ui/src/appExtensions/index.ts b/ui/src/appExtensions/index.ts index 18e401c26f..3ded3ec1b5 100644 --- a/ui/src/appExtensions/index.ts +++ b/ui/src/appExtensions/index.ts @@ -10,6 +10,10 @@ export { AppExtensionContext, NO_APP_EXTENSIONS } from "./context"; export { AppExtensionsProvider } from "./AppExtensionsProvider"; export { ExtensionProviders } from "./ExtensionProviders"; export { ExtensionSlot } from "./ExtensionSlot"; +// The one UI component re-exported here, so an extension building a Create-modal row +// matches the built-in rows without copying their styling. It stays safe from import +// cycles only while it imports nothing from this barrel: keep it that way. +export { CreateOptionRow } from "@/components/CreateOptionRow"; export type { ExtensionSlotProps } from "./ExtensionSlot"; export { diff --git a/ui/src/components/CreateOptionRow.tsx b/ui/src/components/CreateOptionRow.tsx new file mode 100644 index 0000000000..2a627d37d8 --- /dev/null +++ b/ui/src/components/CreateOptionRow.tsx @@ -0,0 +1,42 @@ +import { useTheme } from "@emotion/react"; +import { Typography } from "antd"; +import { Link } from "react-router-dom"; + +/** + * One row of the Agents page's Create modal. Exported through `@/appExtensions` so an + * extension mounting at `app_agents_agentsList_createModal_options` looks like the + * built-in rows instead of copying their styling. + * + * Must not import from `@/appExtensions`: that barrel re-exports this file, so doing so + * would make an import cycle. + */ +export function CreateOptionRow({ + title, + tagline, + to, + testId, +}: { + title: string; + tagline: string; + to: string; + testId?: string; +}) { + const theme = useTheme(); + return ( + +
{title}
+ {tagline} + + ); +} diff --git a/ui/src/pages/agents/AgentConcepts.tsx b/ui/src/pages/agents/AgentConcepts.tsx index 274eb7dbb2..a497c8fbd5 100644 --- a/ui/src/pages/agents/AgentConcepts.tsx +++ b/ui/src/pages/agents/AgentConcepts.tsx @@ -1,6 +1,9 @@ import { Card, Typography } from "antd"; import { useTheme, type Theme } from "@emotion/react"; -import { useSearchParams } from "react-router-dom"; +import { Plus } from "lucide-react"; +import { Link, useSearchParams } from "react-router-dom"; +import { ExtensionSlot } from "@/appExtensions"; +import { CREATE_OPTIONS, type CreateOption } from "./createOptions"; const { Text } = Typography; @@ -78,11 +81,13 @@ export function AgentConcepts() {
open("templates")} /> open("harnesses")} /> @@ -91,6 +96,7 @@ export function AgentConcepts() { open("agents")} /> @@ -113,67 +119,102 @@ function Box({ kind, detail, onOpen, + create, }: { kind: string; detail: string; onOpen?: () => void; + /** Adds a corner "+" that goes to this option's create form. */ + create?: CreateOption; }) { const theme = useTheme(); const interactive = Boolean(onOpen); return ( -
- {detail} - +
+ {kind} +
+ {detail} + + {create && ( +
+ + + + +
+ )} + ); } diff --git a/ui/src/pages/agents/AgentsLandingPage.tsx b/ui/src/pages/agents/AgentsLandingPage.tsx index 2ba854f378..a7372d5edd 100644 --- a/ui/src/pages/agents/AgentsLandingPage.tsx +++ b/ui/src/pages/agents/AgentsLandingPage.tsx @@ -1,14 +1,15 @@ -import { Button, Space, Tabs } from "antd"; +import { useState } from "react"; +import { Button, Modal, Space, Tabs } from "antd"; import { useTheme } from "@emotion/react"; import { Plus } from "lucide-react"; -import { Link, useSearchParams } from "react-router-dom"; -import { ExtensionSlot } from "@/appExtensions"; -import { paths } from "@/router/routes"; +import { useSearchParams } from "react-router-dom"; +import { CreateOptionRow, ExtensionSlot } from "@/appExtensions"; import { PageFrame } from "@/components/Structure/PageFrame"; import { AgentsTab } from "@/pages/AgentsPage"; import { AgentTemplatesTab } from "@/pages/AgentTemplatesPage"; import { AgentConcepts } from "./AgentConcepts"; import { HarnessesTab } from "./HarnessesTab"; +import { CREATE_OPTIONS } from "./createOptions"; const TABS = ["agents", "templates", "harnesses"] as const; @@ -29,6 +30,7 @@ type TabKey = (typeof TABS)[number]; export function AgentsLandingPage() { const theme = useTheme(); const [params, setParams] = useSearchParams(); + const [creating, setCreating] = useState(false); /* * No page-level refresh, deliberately. @@ -64,21 +66,14 @@ export function AgentsLandingPage() { {/* The point the agents list has always offered, kept where the controls now are rather than left behind in the tab they moved out of. */} - - - - - - - - - + } > @@ -88,6 +83,20 @@ export function AgentsLandingPage() { + setCreating(false)} + > +
+ {Object.values(CREATE_OPTIONS).map(({ kind, to, tagline, testId }) => ( + + ))} + +
+
+ = { + Agent: { + kind: "Agent", + to: paths.agentNew, + tagline: "Run a template on a harness. Chats belong to an agent.", + testId: "agents-new", + }, + AgentTemplate: { + kind: "AgentTemplate", + to: paths.agentTemplateNew, + tagline: "Reusable behavior: the model, prompt and tools.", + testId: "agents-new-template", + }, + Harness: { + kind: "Harness", + to: paths.harnessNew, + tagline: "Runtime policy: the image and worker pool agents run on.", + testId: "agents-new-harness", + }, +}; From 18421c1761169b122f1377a08015c8c8e67acb39 Mon Sep 17 00:00:00 2001 From: Torey Scheer Date: Wed, 11 Nov 2026 10:36:14 -0500 Subject: [PATCH 2/2] fix(ui): centre button labels against their icons Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Torey Scheer --- ui/src/theme/GlobalStyles.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/src/theme/GlobalStyles.tsx b/ui/src/theme/GlobalStyles.tsx index 870856a938..174a10309a 100644 --- a/ui/src/theme/GlobalStyles.tsx +++ b/ui/src/theme/GlobalStyles.tsx @@ -26,6 +26,13 @@ export function GlobalStyles() { color: inherit; text-decoration: none; } + /* + * antd's 1.57 line-height seats a label's glyphs below the icon's centre. + * A button is a fixed-height flex row, so a one-em line box centres both. + */ + .ant-btn { + line-height: 1; + } /* * The surfaces, given a very shallow gradient. *