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);
}
});
});
+84
View File
@@ -0,0 +1,84 @@
---
created: 2026-09-24
status: done
requirements:
- REQ-UI-CONTROL-SIZING-001
system_design:
- ../../specs/ui/system-design/control-sizing.md
legacy_specs: []
---
# Implementation Plan: Plugin settings layout
## Overview
Flatten the host plugin settings page and compact its actions while preserving
the existing plugin lifecycle and permission behavior. This is a presentation
refactor under the existing [control sizing contract](../../specs/ui/requirements/control-sizing.md).
The user authorized implementation, screenshots, and PR delivery in one session.
## Scope and technical approach
Use the unframed SettingsPageTemplate variant, a compact installed-list toolbar,
an inline SettingsRow for automatic updates, and divided PluginRow entries.
Keep the current hooks, translated copy, direct row navigation, errors, and
confirmation surfaces. No backend, persistence, plugin SDK, or release changes.
## ASCII UI preview
UI-01: Settings > Plugins > Installed, populated state. Current phone layout
stacks three full-width buttons and nests the list and preference in cards.
```text
Phone Desktop
[Installed] [Browse] [Installed] [Browse]
Installed plugins [Install plugin] Installed plugins [Install plugin] Sync Check for updates
Sync Check for updates Automatic updates description [switch]
Automatic updates [switch] Last-check status
Description ---------------------------------------------------------
Last-check status Plugin name / status / version / actions
-------------------------------- Description and categories
Plugin name / status / version Auto-update [switch]
Actions ---------------------------------------------------------
Description and categories Next plugin
Auto-update [switch]
--------------------------------
Next plugin
```
Structural requirements: retain labels and touch targets while removing nested
frames. The existing settings page owns scrolling and safe areas. Use shared
SettingsRow and control-sizing primitives; retain the existing phone uninstall
drawer. Spacing in the preview is illustrative. Empty/loading/error and pending
actions retain their current content and behavior. Long labels may wrap the
toolbar without causing horizontal scrolling.
## Tests
Existing page and PluginRow unit coverage protects actions and permissions.
Mobile Playwright checks aligned secondary actions, compact Install placement,
44px controls, responsive boundaries, and reachable row actions. Desktop
Playwright checks 28px toolbar controls and the installation lifecycle.
These cover AC-UI-CONTROL-SIZING-001.1, .3, .4, .5, .6, .7, and .8.
## Work orders
- [x] [Task 01: Flatten plugin settings](task-01-flatten-settings.md)
## Verification results
Completed on 2026-09-24. The mobile regression first failed with secondary
actions separated vertically by 52px. After implementation: 2 mobile and 17
desktop Playwright scenarios passed, as did 54 unit tests, typecheck, focused
ESLint, localization, public-doc and spec validators, and diff checks. See the
[work-order results](task-01-flatten-settings.md#results). Four fresh PR screenshots
cover Installed and Browse on phone and desktop with synthetic records; the
disposable capture test passed and was removed afterward.
The public plugin guide uses the new desktop Installed screenshot and no longer
repeats an illustration of the previous layout.
## Risks
Row overlay links must not intercept inline actions. Removing padding must not
clip long plugin IDs or touch targets. All plugin controls retain their existing
permission checks.
@@ -0,0 +1,118 @@
---
id: "01-flatten-settings"
title: "Flatten plugin settings"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-CONTROL-SIZING-001
acceptance_criteria:
- AC-UI-CONTROL-SIZING-001.1
- AC-UI-CONTROL-SIZING-001.3
- AC-UI-CONTROL-SIZING-001.4
- AC-UI-CONTROL-SIZING-001.5
- AC-UI-CONTROL-SIZING-001.6
- AC-UI-CONTROL-SIZING-001.7
- AC-UI-CONTROL-SIZING-001.8
system_design:
- ../../specs/ui/system-design/control-sizing.md
---
# Task 01: Flatten plugin settings
## Summary and scope
Remove redundant frames, place Install beside the heading, keep secondary
actions compact, and render installed plugins as a divided list. Own the host
settings layout, focused browser regressions, documentation, and PR screenshots.
Preserve every existing plugin action and permission boundary. API, SDK,
persistence, and plugin implementation changes are out of scope.
## Acceptance
- Install sits beside the heading; Sync and update checks share a compact row
on phones and join the heading on wider screens. Controls stay touch-safe.
- Automatic updates and plugin rows are no longer enclosed in nested cards;
names, versions, descriptions, state, and all actions remain available.
- Existing install, check, disable, settings-navigation, and uninstall flows
pass on mobile and desktop, with no horizontal overflow.
## ASCII UI preview
UI-01, excerpt of the [combined preview](plan.md#ascii-ui-preview):
```text
Phone Desktop
Installed plugins [Install plugin] Installed plugins [Install plugin] Sync Check updates
Sync Check for updates Automatic updates [switch]
Automatic updates [switch] -----------------------------------------------------
-------------------------------- Plugin name / state / version / controls
Plugin name / state / controls -----------------------------------------------------
--------------------------------
```
The existing settings surface owns vertical scrolling. Keep 44px phone/coarse
targets and 28px desktop toolbar controls per the listed acceptance criteria.
## Verification
```bash
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/settings/mobile-plugin-updates.spec.ts tests/plugins/mobile-plugin-settings-row.spec.ts --retries=0)
(cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/plugins/plugins.spec.ts --retries=0)
(cd apps/web && pnpm exec vitest run app/settings/plugins/page.test.tsx components/settings/plugins/plugin-row.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/settings/plugins/plugins-settings.tsx components/settings/plugins/plugin-row.tsx e2e/tests/settings/mobile-plugin-updates.spec.ts e2e/tests/plugins/plugins.spec.ts --max-warnings 0)
(cd apps/web && pnpm run i18n:check)
node scripts/validate-public-docs.mjs
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Files likely touched
- `apps/web/components/settings/plugins/plugins-settings.tsx`
- `apps/web/components/settings/plugins/plugin-row.tsx`
- `apps/web/e2e/tests/settings/mobile-plugin-updates.spec.ts`
- `apps/web/e2e/tests/plugins/plugins.spec.ts`
- `docs/public/plugins.md`
- `docs/screenshots/plugin-settings.png`
## Dependencies and parallelism
None. Sequential execution in the primary session.
## Inputs and risks
Read the linked control-sizing design, existing settings primitives, and plugin
E2E fixtures. Keep overlay links below inline controls; check translated label
wrapping and plugin IDs after removing padding.
## Results
Completed on 2026-09-24. All commands in Verification passed:
- Mobile Playwright: 2 passed, including 320px, 767px, and 768px containment and
touch-control checks in addition to the canonical phone viewport.
- Desktop Playwright: 17 passed, including 28px toolbar geometry, keyboard
activation, installation, synchronization, updates, persistence, and uninstall.
- Focused Vitest: 54 passed. The existing version assertion now checks the row's
combined text because ID and version occupy separate wrapping spans.
- Typecheck, focused ESLint, and all localization checks passed.
- Public docs: 47 published pages validated. Catalog validation checked 304
decisions and 1138 specifications; all specification files passed lint.
- Documentation coverage validator: covered; no reference errors.
- `git diff --check` passed.
RED evidence: the original mobile toolbar failed the same-row assertion with
Sync at y=314.5 and Check for updates at y=366.5. The final page preserves both
44px touch targets while placing these actions side by side.
A disposable capture spec passed against the production build with isolated,
synthetic plugin records. Four compressed screenshots show Installed and Browse
on phone and desktop; PR comparison assets are published on a separate media
branch. The rendered phone composition matches UI-01. The public plugin guide
describes the compact toolbar and flat list and includes the new desktop
Installed screenshot. Its duplicate illustration of the previous layout was
removed during PR review.
+6 -5
View File
@@ -167,14 +167,15 @@ Once installed, the plugin appears in the list with its category, a status
badge (`active`), a signing badge (`unsigned` today), and **Disable** and
**Uninstall** actions. Selecting the row anywhere opens that plugin's own
settings page; a `Setup required` badge marks a plugin whose manifest declares
a required setting that has no value yet:
![The Settings > Plugins page listing an installed, active plugin with its category, a Setup required badge, an unsigned badge, Disable/Uninstall actions, and a chevron opening the plugin's settings page.](../screenshots/plugin-settings-list.png)
a required setting that has no value yet.
The Installed tab also gives you an overview of automatic updates, installed
versions, available updates, and per-plugin controls:
versions, available updates, and per-plugin controls. **Install plugin** sits
beside the list heading. **Sync** and **Check for updates** share a compact
toolbar, with the automatic-update preference and plugin list below. On phones,
the secondary actions wrap below the heading and retain touch-sized targets:
![Settings > Plugins showing automatic updates and the installed plugin list with sync, update, enable, disable, uninstall, and settings controls.](../screenshots/plugin-settings.png)
![Settings > Plugins showing the compact install, sync, and update toolbar, automatic updates, and divided plugin rows with enable, disable, uninstall, and settings controls.](../screenshots/plugin-settings.png)
<details>
<summary>Filesystem sideload and synchronization</summary>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 42 KiB