fix: simplify plugin settings layout (#3914)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-24 18:57:14 +01:00
committed by GitHub
parent 9fae7ab500
commit 6b86b3482c
9 changed files with 354 additions and 75 deletions
+3 -1
View File
@@ -227,7 +227,9 @@ describe("PluginsSettingsPage", () => {
render(<PluginsSettingsPage />);
expect(screen.getByText(PLUGIN_DISPLAY_NAME)).toBeTruthy();
expect(screen.getByText(/acme-tools.*v1\.0\.0/)).toBeTruthy();
expect(screen.getByTestId(`plugin-row-${PLUGIN_ID}`).textContent).toMatch(
/acme-tools.*v1\.0\.0/,
);
expect(screen.getAllByText(/active/i).length).toBeGreaterThan(0);
expect(screen.getAllByTestId("plugin-unsigned-badge")).toHaveLength(1);
});
@@ -120,7 +120,7 @@ export function PluginRow({
return (
<div
data-testid={`plugin-row-${plugin.id}`}
className="group relative rounded-lg border border-border/70 bg-background p-4 transition-colors hover:border-border hover:bg-muted"
className="group relative min-w-0 py-4 transition-colors hover:bg-muted/40"
>
<PluginRowContent
plugin={plugin}
@@ -214,7 +214,7 @@ function PluginRowContent({
<div className="flex flex-wrap items-start justify-between gap-3">
<PluginRowIdentity plugin={plugin} needsSetup={needsSetup} update={update} />
<div className="flex items-center gap-2 shrink-0">
<div className="flex min-w-0 max-w-full items-center gap-2">
{canManage && (
<PluginRowActions
plugin={plugin}
@@ -320,7 +320,7 @@ function PluginRowIdentity({
}) {
const { t } = useTranslation();
return (
<div className="min-w-0 space-y-1">
<div className="min-w-0 max-w-full space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
{plugin.display_name}
@@ -349,9 +349,10 @@ function PluginRowIdentity({
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono truncate">
{plugin.id} · v{plugin.version}
<span className="font-mono truncate" title={plugin.id}>
{plugin.id}
</span>
<span className="shrink-0 font-mono">v{plugin.version}</span>
<PluginRepoLink url={plugin.repo_url} className="relative z-10" />
<PluginUpdateInfo pluginId={plugin.id} update={update} />
</div>
@@ -493,7 +494,7 @@ function PluginRowActions({
const { isMobile } = useResponsiveBreakpoint();
const updateEntry = update?.hasUpdate ? update.latest : undefined;
return (
<div className="relative z-10 flex flex-wrap items-center gap-2 shrink-0">
<div className="relative z-10 flex min-w-0 flex-wrap items-center gap-2">
{updateEntry && onUpdate && (
<PluginUpdateButton
pluginId={plugin.id}
@@ -22,7 +22,8 @@ import { PluginUpdateStatus } from "./plugin-update-status";
import { usePluginActions } from "./use-plugin-actions";
import { usePluginUpdateAction } from "./use-plugin-update-action";
import { settingsActionClassName } from "@/components/settings/settings-control";
import { SettingsGroup } from "@/components/settings/settings-group";
import { SettingsRow } from "@/components/settings/settings-group";
import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
/**
* Operator UI to browse, install, enable, disable, uninstall, and update kandev
@@ -60,8 +61,9 @@ export function PluginsSettings() {
saveStatus="idle"
onSave={() => undefined}
showSaveButton={false}
contentFrame="none"
>
<Tabs defaultValue="installed" className="space-y-6">
<Tabs defaultValue="installed" className="space-y-4">
<TabsList>
<TabsTrigger
value="installed"
@@ -84,7 +86,7 @@ export function PluginsSettings() {
)}
</TabsList>
<TabsContent value="installed" className="space-y-6">
<TabsContent value="installed" className="space-y-4">
<InstalledTab
list={list}
actions={actions}
@@ -141,46 +143,9 @@ function InstalledTab({
updateAction,
isFinePointer,
}: InstalledTabProps) {
const { t } = useTranslation();
return (
<SettingsGroup
title={t("plugins:installedPlugins")}
contentClassName="space-y-4 divide-y-0"
action={
canManage ? (
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
data-testid="plugins-sync-button"
variant="secondary"
disabled={actions.syncBusy}
onClick={actions.handleSync}
className={settingsActionClassName("cursor-pointer")}
>
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
{t("plugins:sync")}
</Button>
<Button
data-testid="plugins-check-updates-button"
variant="secondary"
disabled={updates.checking}
onClick={updates.checkForUpdates}
className={settingsActionClassName("cursor-pointer")}
>
<IconRefresh className={`h-4 w-4 ${updates.checking ? "animate-spin" : ""}`} />
{t("plugins:checkForUpdates")}
</Button>
<Button
data-testid="install-plugin-trigger"
onClick={actions.openInstall}
className={settingsActionClassName("cursor-pointer")}
>
{t("plugins:installPlugin")}
</Button>
</div>
) : undefined
}
>
<section className="min-w-0 space-y-3" aria-labelledby="installed-plugins-heading">
<InstalledPluginsToolbar actions={actions} updates={updates} canManage={canManage} />
{canManage && <GlobalAutoUpdateToggle settings={autoUpdate} />}
<PluginUpdateStatus
@@ -211,7 +176,61 @@ function InstalledTab({
updateAction={updateAction}
isFinePointer={isFinePointer}
/>
</SettingsGroup>
</section>
);
}
function InstalledPluginsToolbar({
actions,
updates,
canManage,
}: Pick<InstalledTabProps, "actions" | "updates" | "canManage">) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h3 id="installed-plugins-heading" className={SETTINGS_TYPOGRAPHY.sectionTitle}>
{t("plugins:installedPlugins")}
</h3>
{canManage && (
<>
<Button
data-testid="install-plugin-trigger"
onClick={actions.openInstall}
className={settingsActionClassName("ml-auto cursor-pointer md:ml-0")}
>
{t("plugins:installPlugin")}
</Button>
<div className="flex w-full flex-wrap items-center gap-1 md:ml-auto md:w-auto">
<Button
data-testid="plugins-sync-button"
variant="ghost"
disabled={actions.syncBusy}
onClick={actions.handleSync}
className={settingsActionClassName("cursor-pointer text-muted-foreground")}
>
<IconRefresh
className={`size-4 ${actions.syncBusy ? "animate-spin" : ""}`}
aria-hidden
/>
{t("plugins:sync")}
</Button>
<Button
data-testid="plugins-check-updates-button"
variant="ghost"
disabled={updates.checking}
onClick={updates.checkForUpdates}
className={settingsActionClassName("cursor-pointer text-muted-foreground")}
>
<IconRefresh
className={`size-4 ${updates.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("plugins:checkForUpdates")}
</Button>
</div>
</>
)}
</div>
);
}
@@ -227,25 +246,24 @@ function GlobalAutoUpdateToggle({
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/70 bg-background p-4">
<div className="min-w-0 space-y-1">
<label
htmlFor="plugins-auto-update-default"
className="text-sm font-medium text-foreground cursor-pointer"
>
{t("plugins:autoUpdateTitle")}
</label>
<p className="text-xs text-muted-foreground">{t("plugins:autoUpdateDescription")}</p>
</div>
<Switch
id="plugins-auto-update-default"
data-testid="plugins-auto-update-default"
checked={settings.autoUpdateDefault}
disabled={!settings.loaded}
onCheckedChange={settings.setDefault}
className="cursor-pointer"
/>
</div>
<SettingsRow
label={<span className="text-sm">{t("plugins:autoUpdateTitle")}</span>}
description={t("plugins:autoUpdateDescription")}
controlId="plugins-auto-update-default"
touchTarget="switch"
className="flex-row items-center gap-4 border-y border-border/60"
controlWrapperClassName="max-md:w-auto"
control={
<Switch
id="plugins-auto-update-default"
data-testid="plugins-auto-update-default"
checked={settings.autoUpdateDefault}
disabled={!settings.loaded}
onCheckedChange={settings.setDefault}
className="cursor-pointer"
/>
}
/>
);
}
@@ -297,7 +315,7 @@ function PluginList({
}
return (
<div className="space-y-3">
<div className="divide-y divide-border/60">
{items.map((plugin) => {
const rowUpdate: PluginRowUpdateState = {
latest: updates.latestById.get(plugin.id),
@@ -62,6 +62,24 @@ import {
const NAV_ITEM_ID = "e2e-hello";
const PLUGIN_ROUTE = "/plugins/e2e-hello";
// @covers AC-UI-CONTROL-SIZING-001.1, AC-UI-CONTROL-SIZING-001.3, AC-UI-CONTROL-SIZING-001.8
test("plugin settings toolbar stays compact on desktop", async ({ testPage }) => {
await testPage.goto("/settings/plugins");
const install = testPage.getByTestId("install-plugin-trigger");
const sync = testPage.getByTestId("plugins-sync-button");
const check = testPage.getByTestId("plugins-check-updates-button");
await expect(install).toBeVisible();
const boxes = await Promise.all([install.boundingBox(), sync.boundingBox(), check.boundingBox()]);
for (const box of boxes) {
expect(box).not.toBeNull();
expect(box!.height).toBeCloseTo(28, 0);
expect(box!.y).toBeCloseTo(boxes[0]!.y, 0);
}
await install.focus();
await testPage.keyboard.press("Enter");
await expect(testPage.getByTestId("install-plugin-dialog")).toBeVisible();
});
/** Every deliveries.jsonl `event_type` recorded so far, read straight off
* disk from the plugin's real KANDEV_PLUGIN_DATA_DIR (no in-process mock —
* this is the fixture Go binary's own gRPC OnEvent handler writing to its
@@ -7,6 +7,7 @@ import {
uploadPackage,
} from "../plugins/plugin-test-helpers";
// @covers AC-UI-CONTROL-SIZING-001.3, AC-UI-CONTROL-SIZING-001.4, AC-UI-CONTROL-SIZING-001.5, AC-UI-CONTROL-SIZING-001.8
test.describe("Mobile plugin updates", () => {
test.afterEach(async ({ apiClient }) => {
await uninstallPluginFixture(apiClient);
@@ -14,6 +15,7 @@ test.describe("Mobile plugin updates", () => {
test("checks and updates marketplace state from the mobile settings flow", async ({
testPage,
prCapture,
}) => {
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
@@ -81,6 +83,19 @@ test.describe("Mobile plugin updates", () => {
expect(buttonBox).not.toBeNull();
expect(buttonBox!.height).toBeGreaterThanOrEqual(44);
await prCapture.screenshot("plugin-settings", { caption: "Plugin settings on a phone" });
const syncBox = await testPage.getByTestId("plugins-sync-button").boundingBox();
const installBox = await testPage.getByTestId("install-plugin-trigger").boundingBox();
const headingBox = await testPage
.getByRole("heading", { name: "Installed plugins", exact: true })
.boundingBox();
expect(syncBox).not.toBeNull();
expect(installBox).not.toBeNull();
expect(headingBox).not.toBeNull();
expect(syncBox!.y).toBeCloseTo(buttonBox!.y, 0);
expect(installBox!.y).toBeLessThan(headingBox!.y + headingBox!.height);
expect(installBox!.width).toBeLessThan(testPage.viewportSize()!.width / 2);
await checkButton.tap();
await expect.poll(() => refreshRequests).toBe(1);
@@ -106,5 +121,27 @@ test.describe("Mobile plugin updates", () => {
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
for (const width of [320, 767, 768]) {
await testPage.setViewportSize({ width, height: 851 });
const sync = testPage.getByTestId("plugins-sync-button");
const install = testPage.getByTestId("install-plugin-trigger");
await expect(sync).toBeVisible();
await expect(install).toBeVisible();
const [syncBounds, checkBounds, installBounds] = await Promise.all([
sync.boundingBox(),
checkButton.boundingBox(),
install.boundingBox(),
]);
expect(syncBounds!.y).toBeCloseTo(checkBounds!.y, 0);
for (const box of [syncBounds!, checkBounds!, installBounds!]) {
expect(box.height).toBeGreaterThanOrEqual(44);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
}
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(width);
}
});
});