Skip to content
Draft
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
35 changes: 35 additions & 0 deletions ui/playwright/tests/chat/panels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,38 @@ test("chat: a conversation that cannot be read gives the column back", async ({
await expect(page.getByTestId("chat-context-collapse")).toBeHidden();
await expect(page.getByTestId("chat-context-expand")).toBeHidden();
});

/**
* The context panel's template link, and the way back from the template page.
*
* The template page is shared by every conversation with an agent, so reaching it from a
* chat must not strand the reader on a page whose only exit is the template list.
*/
test("chat: the template opened from a conversation leads back to it", async ({ page }) => {
// Wide enough for the context panel, as above.
await page.setViewportSize({ width: 1600, height: 900 });
await page.goto(AGENT_CHAT);
const link = page.getByTestId("chat-agent-context-template");
await expect(link).toBeVisible({ timeout: 30_000 });
const chatUrl = page.url();

await test.step("1. the link says where it goes", async () => {
await link.hover();
await expect(page.getByRole("tooltip")).toContainText("AgentTemplate");
});

await test.step("2. the template page offers the way back, across its tabs", async () => {
await link.click();
const back = page.getByTestId("template-back");
await expect(back).toHaveText("Back to conversation", { timeout: 30_000 });

await page.getByRole("tab", { name: /Agents/ }).click();
await expect(back).toHaveText("Back to conversation");
});

await test.step("3. and it returns to the same conversation", async () => {
await page.getByTestId("template-back").click();
await expect(page).toHaveURL(chatUrl);
await expect(page.getByTestId("chat-agent-context")).toBeVisible({ timeout: 30_000 });
});
});
51 changes: 44 additions & 7 deletions ui/src/components/chat/AgentContextPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { render, screen } from "@testing-library/react";
import { ThemeProvider } from "@emotion/react";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { themeFor } from "@/theme/theme";
import type { AgentInstance } from "@/api";
import { AgentContextPanel } from "./AgentContextPanel";

const {useAgentTemplate, useAgent} = vi.hoisted(() => ({useAgentTemplate: vi.fn(), useAgent: vi.fn()}));

vi.mock("@/api", () => ({ useAgentTemplate, useAgent }));
vi.mock("@/api", () => ({
useAgentTemplate,
useAgent,
bareName: (ref: string) => ref.split("/").pop(),
}));

function renderPanel(tools: unknown[], modelNamespace?: string) {
function LocationState() {
const location = useLocation();
return <pre data-testid="location-state">{JSON.stringify(location.state)}</pre>;
}

function renderPanel(tools: unknown[], modelNamespace?: string, instance?: AgentInstance) {
useAgent.mockReturnValue({data: {resource: {spec: {template: {ref: {name: "assistant"}}, harnessRef: {name: "runner"}}}}});
useAgentTemplate.mockReturnValue({
data: {
Expand All @@ -26,16 +37,42 @@ function renderPanel(tools: unknown[], modelNamespace?: string) {

render(
<ThemeProvider theme={themeFor("dark")}>
<MemoryRouter>
<AgentContextPanel
agentRef={{ namespace: "kagent", name: "assistant" }}
/>
<MemoryRouter initialEntries={["/agents/abc/chat?mock=ok"]}>
<Routes>
<Route
path="/agents/:id/chat"
element={
<AgentContextPanel
instance={instance}
agentRef={{ namespace: "kagent", name: "assistant" }}
/>
}
/>
<Route path="/agent-templates/:namespace/:name" element={<LocationState />} />
</Routes>
</MemoryRouter>
</ThemeProvider>,
);
}

describe("AgentContextPanel", () => {
it("opens the template with a way back to this conversation", async () => {
renderPanel([], undefined, { agent: "kagent/assistant" } as AgentInstance);
await userEvent.click(screen.getByTestId("chat-agent-context-template"));
expect(JSON.parse(screen.getByTestId("location-state").textContent ?? "null")).toEqual({
from: "/agents/abc/chat?mock=ok",
fromLabel: "Back to conversation",
});
});

it("says the way back leads to the agent when no conversation is open", async () => {
renderPanel([]);
await userEvent.click(screen.getByTestId("chat-agent-context-template"));
expect(JSON.parse(screen.getByTestId("location-state").textContent ?? "null")).toMatchObject({
fromLabel: "Back to agent",
});
});

it("renders an MCP binding whose optional tools list is omitted", () => {
renderPanel([
{
Expand Down
40 changes: 29 additions & 11 deletions ui/src/components/chat/AgentContextPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { referenceLabel } from "@/api/domain/agentTemplates";
import { Alert, Skeleton, Space, Tag, Typography } from "antd";
import { Alert, Skeleton, Space, Tag, Tooltip, Typography } from "antd";
import { useTheme } from "@emotion/react";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { useAgent, useAgentTemplate, bareName, type AgentInstance } from "@/api";
import { buildPath, paths } from "@/router/routes";
import { backLinkState } from "@/router/backLink";

const { Text, Paragraph } = Typography;

Expand Down Expand Up @@ -48,6 +49,7 @@ export function AgentContextPanel({
agentRef?: { namespace: string; name?: string };
}) {
const theme = useTheme();
const location = useLocation();

const namespace = instance?.agent?.split("/")[0] ?? agentRef?.namespace ?? "";
const name = instance?.agent ? bareName(instance.agent) : agentRef?.name;
Expand All @@ -65,17 +67,33 @@ export function AgentContextPanel({
<div data-testid="chat-agent-context" css={{ display: "grid", gap: theme.space(4) }}>
<Field label="Template">
{templateRef && templateNamespace && templateName ? (
<Link
to={buildPath(paths.agentTemplateDetail, {
namespace: templateNamespace,
name: templateName,
})}
data-testid="chat-agent-context-template"
<Tooltip
title="Open the AgentTemplate this agent is built from"
trigger={["hover", "focus"]}
>
<Text css={{ fontFamily: theme.font.mono, fontSize: 12 }}>
<Link
to={buildPath(paths.agentTemplateDetail, {
namespace: templateNamespace,
name: templateName,
})}
// So the template page can lead back here rather than only to the
// template list. Without a conversation open, "here" is the agent's page.
state={backLinkState(
location,
instance ? "Back to conversation" : "Back to agent",
)}
data-testid="chat-agent-context-template"
css={{
justifySelf: "start",
fontFamily: theme.font.mono,
fontSize: 12,
color: theme.color.primaryText,
"&:hover, &:focus-visible": { textDecoration: "underline" },
}}
>
{templateRef}
</Text>
</Link>
</Link>
</Tooltip>
) : (
<Text css={{ color: theme.color.textMuted }}>{definition.data?.resource.spec.template.inline ? "Inline" : "Not reported"}</Text>
)}
Expand Down
35 changes: 35 additions & 0 deletions ui/src/components/mcp/ToolServerTools.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { cleanup, render, screen } from "@testing-library/react";
import { ThemeProvider } from "@emotion/react";
import { describe, expect, it } from "vitest";
import { themeFor } from "@/theme/theme";
import { ToolServerTools } from "./ToolServerTools";

function renderedNames(names: string[]) {
render(
<ThemeProvider theme={themeFor("light")}>
<ToolServerTools tools={names.map((name) => ({ name, description: "" }))} />
</ThemeProvider>,
);
return screen.getAllByTestId("tool-server-tool").map((item) => item.textContent);
}

describe("ToolServerTools", () => {
it("lists tools alphabetically, whatever order the server reported them in", () => {
expect(
renderedNames(["k8s_get_pods", "K8s_apply", "k8s_describe"]),
).toEqual(["K8s_apply", "k8s_describe", "k8s_get_pods"]);
});

it("orders numbered tools by number rather than character by character", () => {
expect(renderedNames(["tool_10", "tool_2"])).toEqual([
"tool_2",
"tool_10",
]);
});

it("gives names that differ only in case one fixed order", () => {
const first = renderedNames(["get_pods", "Get_pods"]);
cleanup();
expect(renderedNames(["Get_pods", "get_pods"])).toEqual(first);
});
});
17 changes: 16 additions & 1 deletion ui/src/components/mcp/ToolServerTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function ToolServerTools({ tools, highlight }: ToolServerToolsProps) {
gap: theme.space(3),
}}
>
{tools.map((tool) => (
{sortToolsByName(tools).map((tool) => (
<li
key={tool.name}
data-testid="tool-server-tool"
Expand Down Expand Up @@ -83,6 +83,21 @@ export function ToolServerTools({ tools, highlight }: ToolServerToolsProps) {
);
}

/**
* The tools in alphabetical order by name.
*
* The controller reports them in discovery order, which means nothing to someone
* scanning a long list for one tool. Numeric-aware, so `tool_2` sorts before `tool_10`.
*/
function sortToolsByName(tools: DiscoveredTool[]): DiscoveredTool[] {
return [...tools].sort(
(a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) ||
// Names that differ only in case still get one fixed order.
a.name.localeCompare(b.name, undefined, { numeric: true }),
);
}

/**
* Wraps occurrences of `term` in a `<mark>`.
*
Expand Down
19 changes: 16 additions & 3 deletions ui/src/pages/AgentTemplateDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "antd";
import type { ColumnsType } from "antd/es/table";
import toast from "react-hot-toast";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { Pencil } from "lucide-react";
import { PageFrame } from "@/components/Structure/PageFrame";
Expand All @@ -29,6 +29,7 @@ import {
type AgentTemplateDraft,
} from "@/components/agent-template-form/agentTemplateDraft";
import { agentTemplatesTab } from "@/router/routes";
import { useBackLink } from "@/router/backLink";
import {
apiClient,
useAgentConversations,
Expand All @@ -47,9 +48,13 @@ const TAB_PARAM = "tab";

export function AgentTemplateDetailsPage() {
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
const { namespace, name } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
// Back to wherever the reader came from — a conversation's context panel, say — and
// to the list when they opened this page directly.
const back = useBackLink({ to: agentTemplatesTab, label: "Back to templates" });
Comment thread
cjlg-soloio marked this conversation as resolved.
Resolved

const template = useAgentTemplate(namespace, name);
const invalidateTemplates = useInvalidateAgentTemplates();
Expand Down Expand Up @@ -106,7 +111,8 @@ export function AgentTemplateDetailsPage() {
const next = new URLSearchParams(searchParams);
if (tab === "details") next.delete(TAB_PARAM);
else next.set(TAB_PARAM, tab);
setSearchParams(next, { replace: true });
// Carry the router state across, or switching tabs drops the way back.
setSearchParams(next, { replace: true, state: location.state });
};

const missing = template.error !== undefined && isNotFound(template.error);
Expand Down Expand Up @@ -335,7 +341,14 @@ export function AgentTemplateDetailsPage() {
description="Agents referencing this template cannot prepare new configuration until it is restored or replaced. Existing conversations and last successful revisions are retained."
/>
) : null}
<Button onClick={() => navigate(agentTemplatesTab)}>Back to templates</Button>
<Button
// Returning replaces this entry, so the browser's Back button does not
// lead straight back here from the page the reader just returned to.
onClick={() => navigate(back.to, { replace: back.isReturn })}
data-testid="template-back"
>
{back.label}
</Button>
</Space>
}
>
Expand Down
46 changes: 46 additions & 0 deletions ui/src/router/backLink.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { backLinkState, useBackLink } from "./backLink";

const FALLBACK = { to: "/agents?tab=templates", label: "Back to templates" };

function Probe() {
const back = useBackLink(FALLBACK);
return <a href={back.to}>{back.label}</a>;
}

function renderAt(state: unknown) {
render(
<MemoryRouter initialEntries={[{ pathname: "/agent-templates/kagent/a", state }]}>
<Probe />
</MemoryRouter>,
);
return screen.getByRole("link");
}

describe("useBackLink", () => {
it("leads back to the page that opened this one, search included", () => {
const link = renderAt(
backLinkState({ pathname: "/agents/abc/chat", search: "?mock=ok" }, "Back to conversation"),
);
expect(link.textContent).toBe("Back to conversation");
expect(link.getAttribute("href")).toBe("/agents/abc/chat?mock=ok");
});

it("falls back to the list on a direct visit", () => {
const link = renderAt(undefined);
expect(link.textContent).toBe("Back to templates");
expect(link.getAttribute("href")).toBe(FALLBACK.to);
});

it.each([
{ from: "https://example.com/", fromLabel: "Back" },
{ from: "//example.com/", fromLabel: "Back" },
{ from: "/agents", fromLabel: "" },
{ from: 42, fromLabel: "Back" },
"not an object",
])("ignores state that is not an in-app origin: %j", (state) => {
expect(renderAt(state).textContent).toBe("Back to templates");
});
});
Loading