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/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 587f8fedbc6..293dc803b91 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -2037,52 +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. 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. */} + + {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