From 8acf9a831b5d44cd71e0659202450c3c7d6eca22 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 10:10:56 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20plugin=20card?= =?UTF-8?q?=20actions=20in=20a=20fixed=20header=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin card header wrapped the name, description, interface details and metadata in one column next to the action buttons. A long description made that column full width, so flex-wrap dropped the buttons to a left-aligned row below. The header now holds only the name, version and badges plus the buttons, so the buttons stay top-right on desktop and wrap right-aligned on phones. Description, interface details and metadata render full width below. Adds InstalledList Desktop/Phone stories with the mix of cards that showed the bug. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- .../Sections/PluginsSettingsSection.tsx | 91 ++++++------ .../stories/App.pluginImports.stories.tsx | 132 +++++++++++++++++- 2 files changed, 177 insertions(+), 46 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 587f8fedbc6..5531576d087 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -2037,54 +2037,28 @@ export const PluginsSettingsSection: React.FC = () => { key={`${item.location}:${item.name}`} className="border-border-medium rounded-md border p-3" > -
-
-
- {/* break-all: names can be 64 separator-free chars. */} - - {item.name} - - {item.version && ( - v{item.version} - )} - {!item.managed && unmanaged} - {item.managed && !item.present && missing} - {check?.status === "update-available" && ( - update available - )} - {check?.status === "tag-moved" && tag moved} - {check?.status === "pinned" && pinned} - {check?.status === "error" && check failed} - -
- {item.description && ( -

{item.description}

- )} - {item.interface && } - {/* break-all: locations/sources can contain unbreakable - 64-char tokens (max-length plugin names) that would - otherwise overflow the card at phone widths. */} -

- {item.importedSkillCount ?? item.skillCount} of {item.skillCount} skills - imported · {item.importedMcpServerCount ?? item.mcpServerCount} of{" "} - {item.mcpServerCount} MCP servers imported · {item.location} -

- {formatSource(item) && ( -

- {formatSource(item)} - {item.lockedSha ? ` · ${item.lockedSha.slice(0, 12)}` : ""} -

- )} - {check?.status === "error" && check.message && ( -

- - {check.message} -

+ {/* The header holds only name, badges and actions: whether the actions + wrap then depends on the title alone, not on description length, so + every card puts them in the same place. */} +
+
+ {/* break-all: names can be 64 separator-free chars. */} + + {item.name} + + {item.version && v{item.version}} + {!item.managed && unmanaged} + {item.managed && !item.present && missing} + {check?.status === "update-available" && ( + update available )} + {check?.status === "tag-moved" && tag moved} + {check?.status === "pinned" && pinned} + {check?.status === "error" && check failed} +
- {item.managed && ( -
+
{ )}
+ {item.description && ( +

{item.description}

+ )} + {item.interface && } +
+ {/* break-all: locations/sources can contain unbreakable + 64-char tokens (max-length plugin names) that would + otherwise overflow the card at phone widths. */} +

+ {item.importedSkillCount ?? item.skillCount} of {item.skillCount} skills + imported · {item.importedMcpServerCount ?? item.mcpServerCount} of{" "} + {item.mcpServerCount} MCP servers imported · {item.location} +

+ {formatSource(item) && ( +

+ {formatSource(item)} + {item.lockedSha ? ` · ${item.lockedSha.slice(0, 12)}` : ""} +

+ )} +
+ {check?.status === "error" && check.message && ( +

+ + {check.message} +

+ )} + {/* Managed rows only: an unmanaged plugin in another container can share the manifest name, and rendering the confirmation under its row would visually attach a diff --git a/src/browser/stories/App.pluginImports.stories.tsx b/src/browser/stories/App.pluginImports.stories.tsx index ad078e3f8bc..09079928e08 100644 --- a/src/browser/stories/App.pluginImports.stories.tsx +++ b/src/browser/stories/App.pluginImports.stories.tsx @@ -3,7 +3,10 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta"; import { expandLeftSidebar } from "./helpers/uiState"; import { getSettingsDialog, openSettingsDialog } from "./storyPlayHelpers"; import { setupSettingsStory } from "@/browser/features/Settings/Sections/settingsStoryUtils"; -import type { AgentPluginInstallPreview } from "@/common/orpc/schemas/agentPlugins"; +import type { + AgentPluginInstallPreview, + AgentPluginListItem, +} from "@/common/orpc/schemas/agentPlugins"; import type { AgentPluginInstallEntry } from "@/common/config/schemas/agentPluginInstalls"; export default { ...appMeta, title: "App/PluginImports" }; @@ -127,6 +130,112 @@ function setupPluginSettings(installed = false, conflict = false) { return client; } +// The mix of description lengths, interface details and update states that made card actions +// jump between the header and a row under long descriptions. +const installedList: AgentPluginListItem[] = [ + { + name: "review-tools", + managed: true, + present: true, + location: "~/.xum/plugins/review-tools", + version: "1.0.0", + description: "Review and research tools", + source: preview.source, + lockedSha: "a".repeat(40), + skillCount: 2, + mcpServerCount: 1, + importedSkillCount: 2, + importedMcpServerCount: 1, + }, + { + name: "release-notes", + managed: true, + present: true, + location: "~/.xum/plugins/release-notes", + version: "2.3.1", + description: + "Drafts release notes from merged pull requests, groups them by area and audience, links each entry to its PR and flags breaking changes, migrations and deprecations that need a callout before you publish", + source: { + type: "git", + url: "https://github.com/example/release-notes.git", + ref: "v2.3.1", + refType: "tag", + }, + lockedSha: "b".repeat(40), + skillCount: 4, + mcpServerCount: 0, + importedSkillCount: 3, + importedMcpServerCount: 0, + }, + { + name: "linear-sync", + managed: true, + present: true, + location: "~/.xum/plugins/linear-sync", + version: "0.9.0", + description: "Keep Linear issues in sync with workspace progress", + interface: { + displayName: "Linear Sync", + developerName: "Example Labs", + shortDescription: "Two-way sync between agent todos and Linear issues", + capabilities: ["Read issues", "Write comments", "Update status"], + websiteURL: "https://example.com/linear-sync", + privacyPolicyURL: "https://example.com/privacy", + supportURL: "https://example.com/support", + }, + source: { + type: "git", + url: "https://github.com/example/linear-sync.git", + ref: "main", + refType: "branch", + }, + lockedSha: "c".repeat(40), + skillCount: 1, + mcpServerCount: 1, + importedSkillCount: 1, + importedMcpServerCount: 1, + }, + { + name: "pstack", + managed: true, + present: true, + location: "~/.xum/plugins/pstack", + version: "4.2.0", + description: "Skills, playbooks and principles for careful agent work", + source: { + type: "git", + url: "https://github.com/example/pstack.git", + ref: "main", + refType: "branch", + }, + lockedSha: "d".repeat(40), + skillCount: 38, + mcpServerCount: 0, + importedSkillCount: 38, + importedMcpServerCount: 0, + }, + { + name: "local-snippets", + managed: false, + present: true, + location: "~/.agents/plugins/local-snippets", + description: "Hand-written snippets found in the shared agents folder", + skillCount: 3, + mcpServerCount: 0, + }, +]; + +function setupInstalledPluginList() { + const client = setupPluginSettings(); + client.agentPlugins.list = () => Promise.resolve({ success: true, data: installedList }); + client.agentPlugins.checkUpdates = () => + Promise.resolve({ + success: true, + data: [{ name: "pstack", status: "update-available", remoteSha: "e".repeat(40) }], + }); + return client; +} + async function openPlugins(canvasElement: HTMLElement) { const canvas = within(await openSettingsDialog(canvasElement)); await userEvent.click(await canvas.findByRole("button", { name: "Plugins" })); @@ -256,3 +365,24 @@ export const SelectionConflict: AppStory = { await checkPhoneBounds(); }, }; + +export const InstalledListDesktop: AppStory = { + ...PreviewDesktop, + render: () => , + play: async ({ canvasElement }) => { + const canvas = await openPlugins(canvasElement); + // The Update button needs both the list and the update check to have rendered. + await canvas.findByRole("button", { name: "Update" }); + await checkPhoneBounds(); + }, +}; + +export const InstalledListPhone: AppStory = { + ...InstalledListDesktop, + play: async (context) => { + await expect(context.parameters.pixel.matrix.viewports).toContain("phone"); + await InstalledListDesktop.play?.(context); + }, + globals: { viewport: { value: "mobile1", isRotated: false } }, + parameters: { pixel: { matrix: { themes: ["dark"], viewports: ["phone"] } } }, +}; From 3148a8a18c3a21dbd26623fba7a95ac8891a0b18 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 10:25:25 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20left-align=20plugin?= =?UTF-8?q?=20card=20actions=20on=20narrow=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a plugin card's actions wrap under the title (phones), they were right-aligned and sat indented from the card text. Drop ml-auto and justify-end: the flex-1 name block already pushes the actions right on the name row, and once wrapped they start flush with the card text. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- .../features/Settings/Sections/PluginsSettingsSection.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 5531576d087..293dc803b91 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -2039,7 +2039,9 @@ export const PluginsSettingsSection: React.FC = () => { > {/* The header holds only name, badges and actions: whether the actions wrap then depends on the title alone, not on description length, so - every card puts them in the same place. */} + every card puts them in the same place. The flex-1 name block pushes the + actions to the right on the name row. Once they wrap they start at the + left, flush with the card text, so they need no ml-auto/justify-end. */}
{/* break-all: names can be 64 separator-free chars. */} @@ -2058,7 +2060,7 @@ export const PluginsSettingsSection: React.FC = () => {
{item.managed && ( -
+
Date: Fri, 30 Oct 2026 10:56:56 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20cover=20plugin=20ca?= =?UTF-8?q?rd=20action=20placement=20in=20existing=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the extra App installed-list stories (their phone play threw on the missing group) and assert action placement in the existing Settings stories instead: deploy-tools gets a long description, the desktop play checks Uninstall stays above it, and the phone play checks wrapped actions start flush with the plugin name. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Signed-off-by: Thomas Kosiewski --- .../PluginsSettingsSection.stories.tsx | 22 +++ .../stories/App.pluginImports.stories.tsx | 132 +----------------- 2 files changed, 23 insertions(+), 131 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx index 03ac7462451..84c5c4959c2 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -72,12 +72,16 @@ const MANAGED_ITEM: AgentPluginListItem = { mcpServerCount: 1, }; +const PINNED_DESCRIPTION = + "Deploy helpers for the infra team: rollout skills for staging and production, a release-notes drafter, and MCP servers that read cluster health and page the on-call engineer when a canary regresses."; + const PINNED_ITEM: AgentPluginListItem = { name: "deploy-tools", managed: true, present: true, location: "~/.mux/plugins/deploy-tools", version: "2.0.0", + description: PINNED_DESCRIPTION, source: { type: "git", url: "git@git.corp:infra/deploy-tools.git", @@ -205,6 +209,12 @@ async function openHooksReview(canvasElement: HTMLElement) { return canvas; } +function pluginCardOf(element: HTMLElement): HTMLElement { + const card = element.closest("div[class*='rounded-md']"); + if (card === null) throw new Error("Plugin card not found"); + return card; +} + const PluginsSectionStoryShell: FC<{ options: MockORPCClientOptions; children: ReactNode }> = ( props ) => { @@ -277,6 +287,12 @@ export const InstalledWithUpdateStates: Story = { await expectInterfaceAsText(canvasElement); // Update action appears only for rows whose tracking ref moved. await canvas.findAllByRole("button", { name: /Update/ }); + // A long description must not push the actions off the name row. + const uninstall = await canvas.findByRole("button", { name: "Uninstall deploy-tools" }); + const description = within(pluginCardOf(uninstall)).getByText(PINNED_DESCRIPTION); + if (uninstall.getBoundingClientRect().bottom > description.getBoundingClientRect().top) { + throw new Error("Plugin actions dropped below the description"); + } }, }; @@ -338,6 +354,12 @@ export const InstalledPhoneViewport: Story = { ) { throw new Error("Max-length capability badge overflows its card at phone width"); } + // Wrapped actions start flush with the card text, not at the right edge. + const manage = await canvas.findByRole("button", { name: "Manage components for grill" }); + const name = within(pluginCardOf(manage)).getByText("grill"); + if (Math.abs(manage.getBoundingClientRect().left - name.getBoundingClientRect().left) > 1) { + throw new Error("Wrapped plugin actions are not flush with the card text"); + } }, }; diff --git a/src/browser/stories/App.pluginImports.stories.tsx b/src/browser/stories/App.pluginImports.stories.tsx index 09079928e08..ad078e3f8bc 100644 --- a/src/browser/stories/App.pluginImports.stories.tsx +++ b/src/browser/stories/App.pluginImports.stories.tsx @@ -3,10 +3,7 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta"; import { expandLeftSidebar } from "./helpers/uiState"; import { getSettingsDialog, openSettingsDialog } from "./storyPlayHelpers"; import { setupSettingsStory } from "@/browser/features/Settings/Sections/settingsStoryUtils"; -import type { - AgentPluginInstallPreview, - AgentPluginListItem, -} from "@/common/orpc/schemas/agentPlugins"; +import type { AgentPluginInstallPreview } from "@/common/orpc/schemas/agentPlugins"; import type { AgentPluginInstallEntry } from "@/common/config/schemas/agentPluginInstalls"; export default { ...appMeta, title: "App/PluginImports" }; @@ -130,112 +127,6 @@ function setupPluginSettings(installed = false, conflict = false) { return client; } -// The mix of description lengths, interface details and update states that made card actions -// jump between the header and a row under long descriptions. -const installedList: AgentPluginListItem[] = [ - { - name: "review-tools", - managed: true, - present: true, - location: "~/.xum/plugins/review-tools", - version: "1.0.0", - description: "Review and research tools", - source: preview.source, - lockedSha: "a".repeat(40), - skillCount: 2, - mcpServerCount: 1, - importedSkillCount: 2, - importedMcpServerCount: 1, - }, - { - name: "release-notes", - managed: true, - present: true, - location: "~/.xum/plugins/release-notes", - version: "2.3.1", - description: - "Drafts release notes from merged pull requests, groups them by area and audience, links each entry to its PR and flags breaking changes, migrations and deprecations that need a callout before you publish", - source: { - type: "git", - url: "https://github.com/example/release-notes.git", - ref: "v2.3.1", - refType: "tag", - }, - lockedSha: "b".repeat(40), - skillCount: 4, - mcpServerCount: 0, - importedSkillCount: 3, - importedMcpServerCount: 0, - }, - { - name: "linear-sync", - managed: true, - present: true, - location: "~/.xum/plugins/linear-sync", - version: "0.9.0", - description: "Keep Linear issues in sync with workspace progress", - interface: { - displayName: "Linear Sync", - developerName: "Example Labs", - shortDescription: "Two-way sync between agent todos and Linear issues", - capabilities: ["Read issues", "Write comments", "Update status"], - websiteURL: "https://example.com/linear-sync", - privacyPolicyURL: "https://example.com/privacy", - supportURL: "https://example.com/support", - }, - source: { - type: "git", - url: "https://github.com/example/linear-sync.git", - ref: "main", - refType: "branch", - }, - lockedSha: "c".repeat(40), - skillCount: 1, - mcpServerCount: 1, - importedSkillCount: 1, - importedMcpServerCount: 1, - }, - { - name: "pstack", - managed: true, - present: true, - location: "~/.xum/plugins/pstack", - version: "4.2.0", - description: "Skills, playbooks and principles for careful agent work", - source: { - type: "git", - url: "https://github.com/example/pstack.git", - ref: "main", - refType: "branch", - }, - lockedSha: "d".repeat(40), - skillCount: 38, - mcpServerCount: 0, - importedSkillCount: 38, - importedMcpServerCount: 0, - }, - { - name: "local-snippets", - managed: false, - present: true, - location: "~/.agents/plugins/local-snippets", - description: "Hand-written snippets found in the shared agents folder", - skillCount: 3, - mcpServerCount: 0, - }, -]; - -function setupInstalledPluginList() { - const client = setupPluginSettings(); - client.agentPlugins.list = () => Promise.resolve({ success: true, data: installedList }); - client.agentPlugins.checkUpdates = () => - Promise.resolve({ - success: true, - data: [{ name: "pstack", status: "update-available", remoteSha: "e".repeat(40) }], - }); - return client; -} - async function openPlugins(canvasElement: HTMLElement) { const canvas = within(await openSettingsDialog(canvasElement)); await userEvent.click(await canvas.findByRole("button", { name: "Plugins" })); @@ -365,24 +256,3 @@ export const SelectionConflict: AppStory = { await checkPhoneBounds(); }, }; - -export const InstalledListDesktop: AppStory = { - ...PreviewDesktop, - render: () => , - play: async ({ canvasElement }) => { - const canvas = await openPlugins(canvasElement); - // The Update button needs both the list and the update check to have rendered. - await canvas.findByRole("button", { name: "Update" }); - await checkPhoneBounds(); - }, -}; - -export const InstalledListPhone: AppStory = { - ...InstalledListDesktop, - play: async (context) => { - await expect(context.parameters.pixel.matrix.viewports).toContain("phone"); - await InstalledListDesktop.play?.(context); - }, - globals: { viewport: { value: "mobile1", isRotated: false } }, - parameters: { pixel: { matrix: { themes: ["dark"], viewports: ["phone"] } } }, -};