diff --git a/frontend/src/components/workspace/capabilities/capability-center.tsx b/frontend/src/components/workspace/capabilities/capability-center.tsx index 53d61c7d6d..87670fce95 100644 --- a/frontend/src/components/workspace/capabilities/capability-center.tsx +++ b/frontend/src/components/workspace/capabilities/capability-center.tsx @@ -1,9 +1,9 @@ "use client"; -import { BlocksIcon, SearchIcon, SparklesIcon } from "lucide-react"; +import { BlocksIcon, PuzzleIcon, SearchIcon, SparklesIcon } from "lucide-react"; import dynamic from "next/dynamic"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -26,6 +26,20 @@ const subscribeHydration = () => () => undefined; const clientHydrated = () => true; const serverHydrated = () => false; +// Scrolls only the strip. scrollIntoView would also move the page, pulling it +// back up if the strip resizes after the user has scrolled past it. +function revealActiveTab(strip: HTMLElement) { + const tab = strip.querySelector('[role="tab"][data-state="active"]'); + if (!tab) return; + const tabBox = tab.getBoundingClientRect(); + const stripBox = strip.getBoundingClientRect(); + if (tabBox.left < stripBox.left) { + strip.scrollLeft -= Math.ceil(stripBox.left - tabBox.left); + } else if (tabBox.right > stripBox.right) { + strip.scrollLeft += Math.ceil(tabBox.right - stripBox.right); + } +} + export function CapabilityCenter() { const hydrated = useSyncExternalStore( subscribeHydration, @@ -43,7 +57,20 @@ export function CapabilityCenter() { : requestedTab === "extensions" ? "extensions" : "plugins"; + // The detail view shows one extension, so there is no directory to filter. + const showSearch = !(tab === "extensions" && params.get("extension")); const [query, setQuery] = useState(""); + // The tab strip scrolls on narrow screens. Keep the active tab visible when + // the tab changes or the strip resizes, and leave manual swipes alone. + const tabsRef = useRef(null); + useEffect(() => { + const strip = tabsRef.current; + if (!strip) return; + // A new observation reports the current size at once, covering tab changes. + const observer = new ResizeObserver(() => revealActiveTab(strip)); + observer.observe(strip); + return () => observer.disconnect(); + }, [tab]); function changeTab(value: string) { setQuery(""); router.replace(`${pathname}?tab=${value}`, { scroll: false }); @@ -67,31 +94,38 @@ export function CapabilityCenter() { {t.capabilities.description}

-
- - setQuery(event.target.value)} - /> -
+ {showSearch && ( +
+ + setQuery(event.target.value)} + /> +
+ )} - + @@ -105,6 +139,7 @@ export function CapabilityCenter() { value="extensions" className="gap-2 px-1 pb-4 text-sm" > + {t.extensions.title} diff --git a/frontend/src/components/workspace/capabilities/extension-gallery.tsx b/frontend/src/components/workspace/capabilities/extension-gallery.tsx index 207c8229a8..48370b63c1 100644 --- a/frontend/src/components/workspace/capabilities/extension-gallery.tsx +++ b/frontend/src/components/workspace/capabilities/extension-gallery.tsx @@ -1,16 +1,99 @@ "use client"; -import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react"; +import { + ArrowLeftIcon, + ChevronRightIcon, + CircleAlertIcon, + CircleCheckIcon, + CircleMinusIcon, + ExternalLinkIcon, + InfoIcon, + RefreshCwIcon, +} from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { extensionDirectory } from "@/core/extensions/catalog"; +import { + extensionDirectory, + type ExtensionCatalogEntry, +} from "@/core/extensions/catalog"; import { useFrontendExtensions } from "@/core/extensions/hooks"; import { extensionIcon } from "@/core/extensions/registry"; import { useI18n } from "@/core/i18n/hooks"; +import type { Translations } from "@/core/i18n/locales/types"; import { PluginRow } from "./plugin-directory"; +function ExtensionIconTile({ entry }: { entry: ExtensionCatalogEntry }) { + const Icon = extensionIcon(entry.loaded?.extension?.icon ?? entry.icon); + return ( +
+ +
+ ); +} + +function ExtensionDetail({ + entry, + copy, +}: { + entry: ExtensionCatalogEntry; + copy: Translations["extensions"]; +}) { + const { loaded } = entry; + const notice = loaded?.error + ? { icon: , text: copy.moduleUnavailable } + : !loaded + ? { icon: , text: copy.catalogHint } + : loaded.settings.enabled === true + ? { icon: , text: copy.enabledManaged } + : { icon: , text: copy.disabledManaged }; + return ( +
+
+ +
+

+ {entry.title} +

+

+ {entry.description} +

+ {entry.package && ( +

+ {entry.package} +

+ )} +
+
+ + {notice.icon} + +

{notice.text}

+ {entry.guide && ( + + )} +
+
+
+ ); +} + export function ExtensionGallery({ query = "" }: { query?: string }) { const { t } = useI18n(); const publicQuery = useFrontendExtensions(); @@ -31,53 +114,52 @@ export function ExtensionGallery({ query = "" }: { query?: string }) { router.replace(`${pathname}?${next.toString()}`, { scroll: false }); } const status = source.isPending ? ( -

{t.extensions.loading}

+

+ {t.extensions.loading} +

) : source.isError ? ( -
+

{t.extensions.unavailable}

-
) : null; const reload = ( - ); if (selected) { const entry = entries.find((item) => item.id === selected); return ( -
- {reload} +
+
+ + {reload} +
{status} - {!entry ? ( -

{t.extensions.notInstalled}

+

+ {t.extensions.notInstalled} +

) : ( -
-

{entry.title}

-

{entry.description}

-

- {entry.loaded?.error - ? t.extensions.moduleUnavailable - : entry.loaded - ? entry.loaded.settings.enabled === true - ? t.extensions.enabledManaged - : t.extensions.disabledManaged - : t.extensions.catalogHint} -

- {entry.guide && ( - - )} -
+ )}
); @@ -89,25 +171,22 @@ export function ExtensionGallery({ query = "" }: { query?: string }) { ); return (
- {reload} +
+

+ {t.extensions.deploymentHint} +

+ {reload} +
{status} -

- {t.extensions.deploymentHint} -

{visible.map((entry) => { const loaded = entry.loaded; - const Icon = extensionIcon(loaded?.extension?.icon ?? entry.icon); return ( - -
- } + icon={} label={ loaded?.error ? t.extensions.moduleUnavailable diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts index ba1522de38..7148630c4d 100644 --- a/frontend/tests/e2e/bookmark-plugin.spec.ts +++ b/frontend/tests/e2e/bookmark-plugin.spec.ts @@ -4,12 +4,28 @@ import { mkdir } from "node:fs/promises"; import { createServer } from "node:net"; import path from "node:path"; -import { expect, test } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import { extensionCatalog } from "../../src/core/extensions/catalog"; import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api"; +// Found by computed style, so renaming the scroller's classes cannot break it. +function capabilityCenterScroller(page: Page) { + return page + .getByRole("heading", { name: "Capability Center" }) + .evaluateHandle((node) => { + let scroller = node.parentElement; + while (scroller && getComputedStyle(scroller).overflowY !== "auto") { + scroller = scroller.parentElement; + } + if (!scroller) { + throw new Error("Capability Center has no scrolling ancestor"); + } + return scroller; + }); +} + let gateway: ChildProcess; let gatewayURL: string; test.beforeAll(async ({ request }) => { @@ -578,6 +594,15 @@ for (const locale of ["en-US", "zh-CN"]) { { exact: true }, ), ).toBeVisible(); + await expect( + page.getByText("deerflow-extension-agent-teams", { exact: true }), + ).toBeVisible(); + // The search only filters the directory, so the detail view drops it. + await expect( + page.getByPlaceholder( + zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose", + ), + ).toHaveCount(0); await expect( page.getByRole("link", { name: zh ? "查看安装说明" : "Installation guide", @@ -663,6 +688,108 @@ for (const locale of ["en-US", "zh-CN"]) { }); } +test("extension detail fits a narrow viewport with its tab in view", async ({ + page, +}) => { + mockLangGraphAPI(page); + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto( + "/workspace/capabilities?tab=extensions&extension=community.agent-teams", + ); + await expect( + page.getByRole("heading", { name: "Agent teams", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("tab", { name: "Extensions", exact: true }), + ).toBeInViewport({ ratio: 1 }); + await expect( + page.getByRole("button", { + name: "Reload extensions (refresh page)", + exact: true, + }), + ).toBeVisible(); + const scroller = await capabilityCenterScroller(page); + expect( + await scroller.evaluate((node) => node.scrollWidth - node.clientWidth), + ).toBe(0); + const note = page.getByRole("note"); + const noteBox = await note.boundingBox(); + const textBox = await note + .getByText( + "This entry comes from the repository extension catalog. Ask your deployment administrator to confirm its installation and activation status.", + { exact: true }, + ) + .boundingBox(); + expect(textBox!.x + textBox!.width).toBeLessThanOrEqual( + noteBox!.x + noteBox!.width, + ); +}); + +test("capability tabs realign on resize without moving the page", async ({ + page, +}) => { + mockLangGraphAPI(page); + await page.setViewportSize({ width: 375, height: 480 }); + await page.goto("/workspace/capabilities?tab=extensions"); + const extensions = page.getByRole("tab", { + name: "Extensions", + exact: true, + }); + const strip = page + .getByRole("tablist") + .filter({ has: extensions }) + .locator(".."); + const clippedBy = async (tab: Locator) => { + const [tabBox, stripBox] = await Promise.all([ + tab.boundingBox(), + strip.boundingBox(), + ]); + return Math.max( + stripBox!.x - tabBox!.x, + tabBox!.x + tabBox!.width - stripBox!.x - stripBox!.width, + ); + }; + await expect(extensions).toBeInViewport({ ratio: 1 }); + + // A manual swipe stays where the user left it. + await strip.evaluate((node) => { + node.scrollLeft = 0; + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + expect(await strip.evaluate((node) => node.scrollLeft)).toBe(0); + expect(await clippedBy(extensions)).toBeGreaterThan(0); + + // Scrolled past the strip, a resize realigns it and leaves the page alone. + const scroller = await capabilityCenterScroller(page); + await scroller.evaluate((node) => { + node.scrollTop = node.scrollHeight; + }); + await expect(strip).not.toBeInViewport(); + await page.setViewportSize({ width: 320, height: 480 }); + await expect.poll(() => clippedBy(extensions)).toBeLessThan(1); + await expect(strip).not.toBeInViewport(); + + // The same holds for a tab clipped on the other side. + await page.setViewportSize({ width: 375, height: 480 }); + await page.goto("/workspace/capabilities?tab=plugins"); + const tools = page.getByRole("tab", { + name: "Tools & integrations", + exact: true, + }); + await expect(tools).toBeInViewport({ ratio: 1 }); + await strip.evaluate((node) => { + node.scrollLeft = node.scrollWidth; + }); + expect(await clippedBy(tools)).toBeGreaterThan(0); + await page.setViewportSize({ width: 320, height: 480 }); + await expect.poll(() => clippedBy(tools)).toBeLessThan(1); +}); + test("unregistered plugin pages stay unavailable", async ({ page }) => { mockLangGraphAPI(page); await page.goto("/workspace/extensions/missing.plugin/library"); diff --git a/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx b/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx new file mode 100644 index 0000000000..ea1f67937f --- /dev/null +++ b/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { cleanup, render, screen, within } from "@testing-library/react"; + +const galleryMock = rs.hoisted(() => ({ + search: "", + loaded: [] as unknown[], + replace: rs.fn(), +})); + +rs.mock("next/navigation", () => ({ + usePathname: () => "/workspace/capabilities", + useRouter: () => ({ replace: galleryMock.replace }), + useSearchParams: () => new URLSearchParams(galleryMock.search), +})); +rs.mock("@/core/extensions/hooks", () => ({ + useFrontendExtensions: () => ({ + data: galleryMock.loaded, + isPending: false, + isError: false, + refetch: rs.fn(), + }), +})); +rs.mock("@/core/i18n/hooks", () => ({ + useI18n: () => ({ locale: "en-US", t: enUS }), +})); + +import { ExtensionGallery } from "@/components/workspace/capabilities/extension-gallery"; +import { extensionCatalog } from "@/core/extensions/catalog"; +import { enUS } from "@/core/i18n/locales/en-US"; + +const copy = enUS.extensions; + +function contribution(overrides: Record) { + return { + namespace: "community.bookmarks", + module: null, + entry: null, + title: "Loaded title", + description: "Loaded description", + settings: { enabled: true }, + ...overrides, + }; +} + +beforeEach(() => { + galleryMock.search = ""; + galleryMock.loaded = []; + galleryMock.replace.mockClear(); +}); +afterEach(cleanup); + +describe("extension detail", () => { + it("shows identity, catalog status and the guide for a catalog entry", () => { + galleryMock.search = "tab=extensions&extension=community.agent-teams"; + render(); + + expect( + screen.getByRole("heading", { name: copy.catalog.agentTeams.title }), + ).toBeTruthy(); + expect(screen.getByText(copy.catalog.agentTeams.description)).toBeTruthy(); + expect(screen.getByText("deerflow-extension-agent-teams")).toBeTruthy(); + expect(screen.getByRole("button", { name: copy.all })).toBeTruthy(); + expect(screen.getByRole("button", { name: copy.reloadAll })).toBeTruthy(); + + const notice = screen.getByRole("note"); + expect(within(notice).getByText(copy.catalogHint)).toBeTruthy(); + expect( + within(notice) + .getByRole("link", { name: copy.installationGuide }) + .getAttribute("href"), + ).toMatch(/deerflow-extension-agent-teams#readme$/); + }); + + it.each([ + ["enabled", { settings: { enabled: true } }, copy.enabledManaged], + ["disabled", { settings: { enabled: false } }, copy.disabledManaged], + ])("reports a loaded %s extension as a note", (_name, overrides, text) => { + galleryMock.search = "tab=extensions&extension=community.bookmarks"; + galleryMock.loaded = [contribution(overrides)]; + render(); + + expect(within(screen.getByRole("note")).getByText(text)).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("raises a module failure as an alert", () => { + galleryMock.search = "tab=extensions&extension=community.bookmarks"; + galleryMock.loaded = [contribution({ error: "boom" })]; + render(); + + expect( + within(screen.getByRole("alert")).getByText(copy.moduleUnavailable), + ).toBeTruthy(); + expect(screen.queryByRole("note")).toBeNull(); + }); + + it("omits package and guide for an extension outside the catalog", () => { + galleryMock.search = "tab=extensions&extension=acme.private"; + galleryMock.loaded = [contribution({ namespace: "acme.private" })]; + render(); + + expect(screen.getByRole("heading", { name: "Loaded title" })).toBeTruthy(); + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.queryByText(/deerflow-extension-/)).toBeNull(); + }); + + it("keeps the way back when the extension is unknown", () => { + galleryMock.search = "tab=extensions&extension=missing.plugin"; + render(); + + expect(screen.getByText(copy.notInstalled)).toBeTruthy(); + expect(screen.getByRole("button", { name: copy.all })).toBeTruthy(); + expect(screen.queryByRole("note")).toBeNull(); + }); +}); + +describe("extension directory", () => { + it("keeps the reload action beside the deployment hint", () => { + galleryMock.search = "tab=extensions"; + render(); + + const reload = screen.getByRole("button", { name: copy.reloadAll }); + expect(reload.parentElement).toBe( + screen.getByText(copy.deploymentHint).parentElement, + ); + expect(screen.getAllByRole("article")).toHaveLength( + extensionCatalog.length, + ); + }); +});