Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/docs/app-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down
2 changes: 2 additions & 0 deletions ui/playwright/helpers/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(\?|$)/);

Expand Down
1 change: 1 addition & 0 deletions ui/playwright/tests/agents/agent-crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions ui/playwright/tests/agents/agents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});

Expand Down
5 changes: 5 additions & 0 deletions ui/playwright/tests/app-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions ui/playwright/tests/harnesses/harnesses.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(\?|$)/);

Expand Down
9 changes: 9 additions & 0 deletions ui/src/appExtensions/extensionPoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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];

Expand All @@ -39,6 +44,8 @@ type PropsFor<T extends Partial<Record<ExtensionPointId, object>>> = 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;
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions ui/src/appExtensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions ui/src/components/CreateOptionRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
to={to}
data-testid={testId}
css={{
display: "block",
padding: theme.space(3),
borderRadius: theme.radius.md,
border: `1px solid ${theme.color.border}`,
"&:hover": { borderColor: `${theme.color.primary}A6`, background: theme.color.bgElevated },
"&:focus-visible": { outline: `2px solid ${theme.color.primaryText}` },
}}
>
<div css={{ fontFamily: theme.font.mono, fontSize: 13, color: theme.color.text }}>{title}</div>
<Typography.Text css={{ color: theme.color.textMuted, fontSize: 12 }}>{tagline}</Typography.Text>
</Link>
);
}
141 changes: 91 additions & 50 deletions ui/src/pages/agents/AgentConcepts.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -78,11 +81,13 @@ export function AgentConcepts() {
<div css={{ display: "grid", gap: theme.space(2), flex: "1 1 260px", minWidth: 0 }}>
<Box
kind="AgentTemplate"
create={CREATE_OPTIONS.AgentTemplate}
detail="The model, prompt and tools."
onOpen={() => open("templates")}
/>
<Box
kind="Harness"
create={CREATE_OPTIONS.Harness}
detail="The runtime, image and worker pool."
onOpen={() => open("harnesses")}
/>
Expand All @@ -91,6 +96,7 @@ export function AgentConcepts() {
<MergeArrow theme={theme} />
<Box
kind="Agent"
create={CREATE_OPTIONS.Agent}
detail="Inline or shared behavior on a platform-managed harness."
onOpen={() => open("agents")}
/>
Expand All @@ -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 (
<button
type="button"
data-testid={`concept-${kind}`}
disabled={!interactive}
onClick={onOpen}
css={{
flex: "1 1 220px",
minWidth: 0,
textAlign: "left",
// Centred down the box, because a card that stretches to match the two stacked
// beside it would otherwise hold its text at the top of an empty rectangle.
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 2,
font: "inherit",
padding: `${theme.space(3)} ${theme.space(3)}`,
borderRadius: theme.radius.md,
border: `1px solid ${theme.color.border}`,
background: theme.color.bg,
transition: "background 140ms ease, border-color 140ms ease, transform 140ms ease",
cursor: interactive ? "pointer" : "default",
...(interactive
? {
"&:hover": {
borderColor: `${theme.color.primary}A6`,
background: theme.color.bgElevated,
},
// Pressed, and distinct from hovered: a press that looks like a hover
// reads as a click that did not register.
"&:active": {
background: `${theme.color.primary}33`,
transform: "translateY(1px)",
},
"&:focus-visible": { outline: `2px solid ${theme.color.primaryText}` },
}
: {}),
}}
>
<div
// The "+" is a sibling of the box button, not a child: a button inside a button is
// invalid, and a sibling keeps a click on the "+" from also opening the tab.
<div css={{ position: "relative", display: "flex", flex: "1 1 220px", minWidth: 0 }}>
<button
type="button"
data-testid={`concept-${kind}`}
disabled={!interactive}
onClick={onOpen}
css={{
fontFamily: theme.font.mono,
fontSize: 13,
// The derived one is marked, because it is the one that is not a resource and
// the confusion is people looking for where to create it.
color: theme.color.text,
flex: 1,
minWidth: 0,
textAlign: "left",
// Centred down the box, because a card that stretches to match the two stacked
// beside it would otherwise hold its text at the top of an empty rectangle.
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 2,
font: "inherit",
padding: `${theme.space(3)} ${theme.space(3)}`,
// Room for the corner "+" so the text never runs underneath it.
paddingRight: create ? theme.space(6) : theme.space(3),
borderRadius: theme.radius.md,
border: `1px solid ${theme.color.border}`,
background: theme.color.bg,
transition: "background 140ms ease, border-color 140ms ease, transform 140ms ease",
cursor: interactive ? "pointer" : "default",
...(interactive
? {
"&:hover": {
borderColor: `${theme.color.primary}A6`,
background: theme.color.bgElevated,
},
// Pressed, and distinct from hovered: a press that looks like a hover
// reads as a click that did not register.
"&:active": {
background: `${theme.color.primary}33`,
transform: "translateY(1px)",
},
"&:focus-visible": { outline: `2px solid ${theme.color.primaryText}` },
}
: {}),
}}
>
{kind}
</div>
<Text css={{ color: theme.color.textMuted, fontSize: 12 }}>{detail}</Text>
</button>
<div
css={{
fontFamily: theme.font.mono,
fontSize: 13,
// The derived one is marked, because it is the one that is not a resource and
// the confusion is people looking for where to create it.
color: theme.color.text,
}}
>
{kind}
</div>
<Text css={{ color: theme.color.textMuted, fontSize: 12 }}>{detail}</Text>
</button>
{create && (
<div css={{ position: "absolute", top: 6, right: 6, display: "flex", gap: 4 }}>
<ExtensionSlot
id="app_agents_agentsList_overview_createActions"
context={{ kind: create.kind }}
/>
<Link
to={create.to}
aria-label={`Create ${create.kind}`}
title={`Create ${create.kind}`}
data-testid={`concept-create-${kind}`}
css={{
display: "flex",
padding: 5,
borderRadius: theme.radius.md,
color: theme.color.textMuted,
// A faint tint of the muted text colour: light on dark themes, dark on light.
background: `${theme.color.textMuted}26`,
"&:hover": { color: theme.color.primaryText, background: `${theme.color.primary}33` },
"&:focus-visible": { outline: `2px solid ${theme.color.primaryText}` },
}}
>
<Plus size={14} />
</Link>
</div>
)}
</div>
);
}

Expand Down
Loading