-
Notifications
You must be signed in to change notification settings - Fork 126
π€ feat: Agent Plugins install/update UX (managed installs, v1) #3820
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ThomasK33
wants to merge
24
commits into
main
Choose a base branch
from
agent-plugin-install-ux
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+5,380
β122
Open
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
94b126d
feat: add managed Agent Plugin install registry schema (plugins section)
ThomasK33 d24dc83
feat: AgentPluginInstallService + plugins oRPC namespace
ThomasK33 4f015c0
feat: Settings β Plugins section + palette command
ThomasK33 3b9d724
docs: describe Settings β Plugins install/update/uninstall flow
ThomasK33 b9c9a10
fix: address Codex review round 1
ThomasK33 edcdfa0
fix: address Codex review round 2
ThomasK33 a6503d2
fix: address Codex review round 3
ThomasK33 bc68e77
fix: address Codex review round 4
ThomasK33 5a2bd10
fix: address Codex review round 5
ThomasK33 0443e1a
fix: address Codex review round 6
ThomasK33 dc141c4
fix: address Codex review round 7
ThomasK33 b6daf6a
fix: address Codex review round 8
ThomasK33 f1fd47e
fix: address Codex review round 9
ThomasK33 b6965bc
fix: address Codex review round 10
ThomasK33 e576538
fix: address Codex review round 11
ThomasK33 8581764
fix: address Codex review round 12
ThomasK33 4b19552
fix: use a static type import in the pruning-failure test (lint)
ThomasK33 a22a9e0
fix: address Codex review round 13
ThomasK33 09fbbc2
fix: address Codex review round 14
ThomasK33 d005e8c
fix: address Codex review round 15
ThomasK33 28d8bc6
fix: address Codex review round 16
ThomasK33 c62f934
fix: address Codex review round 17
ThomasK33 a7e59c1
fix: address Codex review round 18
ThomasK33 9e9c94e
fix: address Codex review round 19 (P1)
ThomasK33 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
306 changes: 306 additions & 0 deletions
306
src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,306 @@ | ||
| import { useRef } from "react"; | ||
| import type { FC, ReactNode } from "react"; | ||
| import type { Meta, StoryObj } from "@storybook/react-vite"; | ||
| import { userEvent, within } from "@storybook/test"; | ||
|
|
||
| import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; | ||
| import { APIProvider, type APIClient } from "@/browser/contexts/API"; | ||
| import { ExperimentsProvider } from "@/browser/contexts/ExperimentsContext"; | ||
| import { ThemeProvider } from "@/browser/contexts/ThemeContext"; | ||
| import { createMockORPCClient, type MockORPCClientOptions } from "@/browser/stories/mocks/orpc"; | ||
| import type { AgentPluginListItem } from "@/common/orpc/schemas/agentPlugins"; | ||
|
|
||
| import { PluginsSettingsSection } from "./PluginsSettingsSection"; | ||
|
|
||
| const MANAGED_ITEM: AgentPluginListItem = { | ||
| name: "grill", | ||
| managed: true, | ||
| present: true, | ||
| location: "~/.mux/plugins/grill", | ||
| version: "1.2.0", | ||
| description: "Relentlessly grills your plans before you commit to them.", | ||
| source: { | ||
| type: "git", | ||
| url: "https://github.com/example/grill.git", | ||
| ref: "main", | ||
| refType: "branch", | ||
| }, | ||
| lockedSha: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | ||
| installedAt: "2026-08-01T12:00:00.000Z", | ||
| skillCount: 3, | ||
| mcpServerCount: 1, | ||
| }; | ||
|
|
||
| const PINNED_ITEM: AgentPluginListItem = { | ||
| name: "deploy-tools", | ||
| managed: true, | ||
| present: true, | ||
| location: "~/.mux/plugins/deploy-tools", | ||
| version: "2.0.0", | ||
| source: { | ||
| type: "git", | ||
| url: "git@git.corp:infra/deploy-tools.git", | ||
| ref: "v2.0.0", | ||
| refType: "tag", | ||
| }, | ||
| lockedSha: "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1", | ||
| installedAt: "2026-07-15T09:30:00.000Z", | ||
| skillCount: 0, | ||
| mcpServerCount: 2, | ||
| }; | ||
|
|
||
| const UNMANAGED_ITEM: AgentPluginListItem = { | ||
| name: "handmade", | ||
| managed: false, | ||
| present: true, | ||
| location: "~/.agents/plugins/handmade", | ||
| description: "Copied into the container by hand; Mux lists it read-only.", | ||
| skillCount: 1, | ||
| mcpServerCount: 0, | ||
| }; | ||
|
|
||
| const MISSING_ITEM: AgentPluginListItem = { | ||
| name: "vanished", | ||
| managed: true, | ||
| present: false, | ||
| location: "~/.mux/plugins/vanished", | ||
| version: "0.4.0", | ||
| source: { | ||
| type: "git", | ||
| url: "https://github.com/example/vanished.git", | ||
| ref: "main", | ||
| refType: "branch", | ||
| }, | ||
| lockedSha: "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2", | ||
| installedAt: "2026-06-01T00:00:00.000Z", | ||
| skillCount: 0, | ||
| mcpServerCount: 0, | ||
| }; | ||
|
|
||
| /** Valid max-length (64-char, separator-free) name: the worst case for narrow-width wrapping. */ | ||
| const MAX_LENGTH_NAME = "a".repeat(64); | ||
| const MAX_LENGTH_ITEM: AgentPluginListItem = { | ||
| name: MAX_LENGTH_NAME, | ||
| managed: true, | ||
| present: true, | ||
| location: `~/.mux/plugins/${MAX_LENGTH_NAME}`, | ||
| version: "1.0.0", | ||
| source: { | ||
| type: "git", | ||
| url: `https://github.com/example/${MAX_LENGTH_NAME}.git`, | ||
| ref: "main", | ||
| refType: "branch", | ||
| }, | ||
| lockedSha: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", | ||
| installedAt: "2026-08-01T12:00:00.000Z", | ||
| skillCount: 1, | ||
| mcpServerCount: 0, | ||
| }; | ||
|
|
||
| const PluginsSectionStoryShell: FC<{ options: MockORPCClientOptions; children: ReactNode }> = ({ | ||
| options, | ||
| children, | ||
| }) => { | ||
| const clientRef = useRef<APIClient | null>(null); | ||
| clientRef.current ??= createMockORPCClient(options); | ||
|
|
||
| return ( | ||
| <ThemeProvider> | ||
| <TooltipProvider> | ||
| <APIProvider client={clientRef.current}> | ||
| <ExperimentsProvider>{children}</ExperimentsProvider> | ||
| </APIProvider> | ||
| </TooltipProvider> | ||
| </ThemeProvider> | ||
| ); | ||
| }; | ||
|
|
||
| const meta: Meta<typeof PluginsSettingsSection> = { | ||
| title: "Features/Settings/Sections/PluginsSettingsSection", | ||
| component: PluginsSettingsSection, | ||
| parameters: { | ||
| layout: "fullscreen", | ||
| }, | ||
|
ThomasK33 marked this conversation as resolved.
|
||
| }; | ||
|
|
||
| export default meta; | ||
|
|
||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| export const Empty: Story = { | ||
| render: () => ( | ||
| <PluginsSectionStoryShell options={{ agentPlugins: { items: [] } }}> | ||
| <PluginsSettingsSection /> | ||
| </PluginsSectionStoryShell> | ||
| ), | ||
| play: async ({ canvasElement }) => { | ||
| const canvas = within(canvasElement); | ||
| await canvas.findByText("Installed plugins"); | ||
| await canvas.findByText("No plugins installed yet."); | ||
| }, | ||
| }; | ||
|
|
||
| export const InstalledWithUpdateStates: Story = { | ||
| render: () => ( | ||
| <PluginsSectionStoryShell | ||
| options={{ | ||
| agentPlugins: { | ||
| items: [MANAGED_ITEM, PINNED_ITEM, UNMANAGED_ITEM, MISSING_ITEM], | ||
| updateChecks: [ | ||
| { | ||
| name: "grill", | ||
| status: "update-available", | ||
| remoteSha: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", | ||
| }, | ||
| { name: "deploy-tools", status: "tag-moved" }, | ||
| { name: "vanished", status: "error", message: "Could not reach the remote." }, | ||
| ], | ||
| }, | ||
| }} | ||
| > | ||
| <PluginsSettingsSection /> | ||
| </PluginsSectionStoryShell> | ||
| ), | ||
| play: async ({ canvasElement }) => { | ||
| const canvas = within(canvasElement); | ||
|
|
||
| await canvas.findByText("grill"); | ||
| await canvas.findByText("update available"); | ||
| await canvas.findByText("tag moved"); | ||
| await canvas.findByText("unmanaged"); | ||
| await canvas.findByText("missing"); | ||
| // Update action appears only for rows whose tracking ref moved. | ||
| await canvas.findAllByRole("button", { name: /Update/ }); | ||
| }, | ||
| }; | ||
|
|
||
| /** | ||
| * Pinned phone viewport for the row layout: long repo paths, badge clusters, | ||
| * and the action group must not overflow the right edge or starve each other | ||
| * at narrow widths (AGENTS.md Storybook responsive rule). | ||
| */ | ||
| export const InstalledPhoneViewport: Story = { | ||
| globals: { | ||
| viewport: { value: "mobile1", isRotated: false }, | ||
| }, | ||
| parameters: { | ||
| layout: "fullscreen", | ||
| pixel: { | ||
| matrix: { themes: ["dark"], viewports: ["phone"] }, | ||
| }, | ||
| }, | ||
| render: () => ( | ||
| <PluginsSectionStoryShell | ||
| options={{ | ||
| agentPlugins: { | ||
| items: [MANAGED_ITEM, PINNED_ITEM, UNMANAGED_ITEM, MAX_LENGTH_ITEM], | ||
| updateChecks: [ | ||
| { | ||
| name: "grill", | ||
| status: "update-available", | ||
| remoteSha: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", | ||
| }, | ||
| ], | ||
| }, | ||
| }} | ||
| > | ||
| {/* Fixed phone width so the play's overflow assertion holds in the CI | ||
| test-runner too, which ignores viewport globals (AGENTS.md). */} | ||
| <div style={{ width: 390 }}> | ||
| <PluginsSettingsSection /> | ||
| </div> | ||
| </PluginsSectionStoryShell> | ||
| ), | ||
| play: async ({ canvasElement }) => { | ||
| const canvas = within(canvasElement); | ||
| await canvas.findByText("grill"); | ||
| await canvas.findByText("update available"); | ||
| await canvas.findByRole("button", { name: /Update/ }); | ||
| // Max-length separator-free names must wrap instead of overflowing the | ||
| // card's right edge at phone width. | ||
| const maxRow = await canvas.findByText(MAX_LENGTH_NAME); | ||
| const card = maxRow.closest("div[class*='rounded-md']"); | ||
| if (card instanceof HTMLElement && card.scrollWidth > card.clientWidth + 1) { | ||
| throw new Error("Max-length plugin row overflows its card at phone width"); | ||
| } | ||
| }, | ||
| }; | ||
|
|
||
| export const UninstallConfirmation: Story = { | ||
| render: () => ( | ||
| <PluginsSectionStoryShell options={{ agentPlugins: { items: [MANAGED_ITEM] } }}> | ||
| <PluginsSettingsSection /> | ||
| </PluginsSectionStoryShell> | ||
| ), | ||
| play: async ({ canvasElement }) => { | ||
| const canvas = within(canvasElement); | ||
|
|
||
| const uninstallButton = await canvas.findByRole("button", { name: /Uninstall grill/ }); | ||
| await userEvent.click(uninstallButton); | ||
|
|
||
| // Preserve-by-default: the plugin-data checkbox starts unchecked. | ||
| await canvas.findByText(/Also delete stored plugin data/); | ||
| const checkbox = await canvas.findByRole("checkbox"); | ||
| if (checkbox.getAttribute("data-state") !== "unchecked") { | ||
| throw new Error("Plugin-data checkbox must start unchecked (preserve by default)"); | ||
| } | ||
| }, | ||
| }; | ||
|
|
||
| export const AddPluginConsentPreview: Story = { | ||
| render: () => ( | ||
| <PluginsSectionStoryShell | ||
| options={{ | ||
| agentPlugins: { | ||
| items: [], | ||
| preview: { | ||
| source: { | ||
| type: "git", | ||
| url: "https://github.com/example/grill.git", | ||
| ref: "main", | ||
| refType: "branch", | ||
| }, | ||
| lockedSha: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | ||
| manifest: { | ||
| name: "grill", | ||
| version: "1.2.0", | ||
| description: "Relentlessly grills your plans.", | ||
| authorName: "Example Co", | ||
| license: "MIT", | ||
| }, | ||
| skills: [ | ||
| { name: "grill", description: "Stress-test a plan before committing to it." }, | ||
| { name: "grill-lite", description: "A gentler grilling for small changes." }, | ||
| ], | ||
| mcpServers: [ | ||
| { | ||
| serverName: "grill-db", | ||
| transport: "stdio", | ||
| summary: "node ~/.mux/plugins/grill/server.js --db ${PLUGIN_DATA}/state.sqlite", | ||
| }, | ||
| ], | ||
| warnings: ["Unknown top-level field 'hooks' ignored"], | ||
| targetPath: "~/.mux/plugins/grill", | ||
| }, | ||
| }, | ||
| }} | ||
| > | ||
| <PluginsSettingsSection /> | ||
| </PluginsSectionStoryShell> | ||
| ), | ||
| play: async ({ canvasElement }) => { | ||
| const canvas = within(canvasElement); | ||
|
|
||
| await userEvent.click(await canvas.findByRole("button", { name: /Add plugin/ })); | ||
| await userEvent.type(await canvas.findByLabelText(/Git URL or owner\/repo/), "example/grill"); | ||
| await userEvent.click(await canvas.findByRole("button", { name: /Preview/ })); | ||
|
|
||
| // Consent card: manifest + every skill + every MCP command line before install. | ||
| await canvas.findByText("Skills (2)"); | ||
| await canvas.findByText("grill-lite"); | ||
| await canvas.findByText("MCP servers (1)"); | ||
| await canvas.findByText(/server\.js --db/); | ||
| await canvas.findByText(/Unknown top-level field 'hooks' ignored/); | ||
| await canvas.findByRole("button", { name: /Install/ }); | ||
| }, | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.