From 3067b5a5bd4782cba3623dfdba1f3b804075842c Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Fri, 6 Nov 2026 14:00:04 +0800 Subject: [PATCH] fix(ui): tidy the extension detail layout and narrow-screen overflow (#7936) * fix(capabilities): tidy the extension detail layout and narrow-screen overflow The extension detail view led with a page-level reload button and sat the back button beside it 3px off: the two flowed inline and aligned by baseline, which the back button takes from its icon. It also kept a directory search that filters nothing there, a heading close to the page title, and status copy that read like body text. On narrow screens the tab strip widened the whole page, leaving a horizontal scrollbar. Lead with the back button and move reload to the opposite end, drop the search from the detail view, and give the detail an icon, package name and a status notice holding the installation guide. On the directory, reload now sits beside the hint that explains it. The tab strip scrolls inside itself and keeps the active tab in view, and reload collapses to an icon below `sm` with its accessible name intact. No copy changes. * fix(capabilities): realign the tab strip on resize without moving the page Review follow-up. The active tab was only realigned when the tab changed, so a rotation or window resize could leave it clipped. Realign whenever the strip resizes, and leave manual swipes alone. Scroll only the strip. scrollIntoView would also move the page, pulling it back up when a resize arrives after the user has scrolled past the strip. Also hide the strip's scrollbar in browsers without scrollbar-width support, and find the page scroller in the E2E test by computed style rather than by a class name. --- .../capabilities/capability-center.tsx | 87 ++++++--- .../capabilities/extension-gallery.tsx | 167 +++++++++++++----- frontend/tests/e2e/bookmark-plugin.spec.ts | 129 +++++++++++++- .../extension-gallery.dom.test.tsx | 130 ++++++++++++++ 4 files changed, 442 insertions(+), 71 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx 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, + ); + }); +});