From 3fcdcb35e8869fed2751afef2a5ced3428c32674 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:10:41 -0600 Subject: [PATCH] feat(plugin): add settings rows, selects and menu rows to ctx.ui (#61527) --- .../component-tests/plugin-host.fixture.tsx | 24 +- .../app/component-tests/plugin-host.spec.ts | 98 +++++++- packages/app/src/runtime/plugin/ui.tsx | 97 +++++++- packages/gui-plugins/README.md | 7 +- packages/gui-plugins/src/pairing/page.tsx | 144 +++++------ packages/gui-plugins/src/review/settings.tsx | 42 ++-- packages/gui-plugins/src/updater/section.tsx | 70 ++---- packages/plugin/src/gui/context.ts | 4 +- packages/plugin/src/gui/host-apis.ts | 229 +++++++++++++++++- packages/plugin/src/gui/registries.ts | 8 +- .../docs/content/build/plugins/gui/index.mdx | 170 +++++++++++++ 11 files changed, 723 insertions(+), 170 deletions(-) diff --git a/packages/app/component-tests/plugin-host.fixture.tsx b/packages/app/component-tests/plugin-host.fixture.tsx index 51a1e6d20c7f..89eae7e07956 100644 --- a/packages/app/component-tests/plugin-host.fixture.tsx +++ b/packages/app/component-tests/plugin-host.fixture.tsx @@ -1,5 +1,6 @@ import type { PluginInfo, SessionMessageInfo } from "@opencode/client/promise" import { DialogProvider } from "@opencode/ui/context/dialog" +import { Menu } from "@opencode/ui/menu" import { Toast } from "@opencode/ui/toast" import { Panel, @@ -545,8 +546,9 @@ export function mountPanelWidths(input: { definitions: readonly Entry[]; active: /** * Mounts the real host and HostApis, and the parts of the session screen that render plugin content for the routed - * session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, and the - * `session.panel.end` slot, whose input the screen builds this way. `route(id)` routes a session the way the screen + * session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, the + * `session.panel.end` slot, whose input the screen builds this way, and the `session.location.menu` slot inside a + * "Project menu" the way the session header's project menu holds it. `route(id)` routes a session the way the screen * does: a new session object each time, delivered through the same reactive inputs, while the one screen object * `ctx.screen` returns follows the route. Its composer records which session each attachment reached. */ @@ -627,6 +629,24 @@ export function mountSessionRegion(input: { screen, }} /> + + {/* Without text, so the cases that read the region's text see only the plugins'. */} + + + + + + + { +story("ctx.ui renders the app's components in a contribution, a host menu and a dialog", async ({ page }) => { await page.evaluate(async (fixture) => { const { mountSessionRegion, until, createComponent, createSignal, Slot } = await import(fixture) @@ -322,6 +322,7 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di const ui = ctx.ui const [on, setOn] = createSignal(false) const [name, setName] = createSignal("") + const [period, setPeriod] = createSignal("day") const dialog = () => ctx.dialogs.open((handle) => @@ -374,6 +375,64 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di createComponent(ui.Keybind, { keys: ["Ctrl", "K"] }), createComponent(ui.Markdown, { text: "# Fixture heading\n\nSome *text*." }), createComponent(ui.Code, { content: "const fence = `\n```\n`", language: "ts" }), + createComponent(ui.SettingsLayout, { + title: "Fixture settings", + description: "Fixture page description", + get children() { + return createComponent(ui.SettingsSection, { + title: "Fixture section", + footer: "Fixture footer", + get children() { + return createComponent(ui.SettingsRow, { + id: "fixture-period", + title: "Period", + description: "How far back to count", + get children() { + return createComponent(ui.Select<"day" | "month">, { + label: "Period", + options: [ + { value: "day", label: "Day" }, + { value: "month", label: "Month" }, + ], + get value() { + return period() + }, + onChange: (value: "day" | "month") => { + setPeriod(value) + record(`select ${value}`) + }, + }) + }, + }) + }, + }) + }, + }), + ], + }) + + ctx.add(Slot, { + at: "session.location.menu", + render: () => [ + createComponent(ui.MenuSeparator, {}), + createComponent(ui.MenuItem, { + label: "Usage", + icon: "status", + keys: ["U"], + onSelect: () => record("usage"), + }), + createComponent(ui.MenuGroup, { + label: "Fixture menu group", + get children() { + return createComponent(ui.MenuSub, { + label: "Open in", + onOpenChange: (open: boolean) => record(`sub ${open}`), + get children() { + return createComponent(ui.MenuItem, { label: "Editor", onSelect: () => record("editor") }) + }, + }) + }, + }), ], }) }, @@ -427,6 +486,43 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di await expect(dialog.locator('[data-slot="dialog-close-button"]')).toHaveCount(1) await dialog.getByRole("button", { name: "Close fixture" }).click() await expect(dialog).toHaveCount(0) + + // Settings components render the host's settings markup, and search finds the row by its id. + await expect(root.locator(".settings-tab-header h2.settings-tab-title")).toHaveText("Fixture settings") + const section = root.getByRole("region", { name: "Fixture section" }) + await expect(section.locator("h3.settings-section-title")).toHaveText("Fixture section") + await expect(section).toContainText("Fixture footer") + const row = section.locator('[data-component="settings-list"] > [data-component="settings-row"]') + await expect(row).toHaveAttribute("data-action", "fixture-period") + await expect(row.locator('[data-slot="settings-row-description"]')).toHaveText("How far back to count") + + const select = row.locator('[data-slot="settings-row-control"] [data-component="select-v2"]') + // The trigger is named by the label and the selected choice. + await expect(select).toHaveAccessibleName("Period Day") + await select.click() + await page.getByRole("option", { name: "Month" }).click() + await expect(select).toHaveAccessibleName("Period Month") + + // Menu rows render inside the host's menu as its own rows: keyboard items, a group, and a submenu. + await root.getByRole("button", { name: "Project menu" }).click() + const project = page.getByRole("menu") + await expect(project.getByRole("separator")).toHaveCount(1) + const usage = project.getByRole("menuitem", { name: "Usage" }) + await expect(usage).toHaveAttribute("data-component", "menu-v2-item") + await expect(usage.locator('use[href="#opencode-v2-icon-status"]')).toHaveCount(1) + await expect(usage.locator('[data-component="keybind-v2"]')).toHaveText("U") + await usage.click() + await expect(project).toHaveCount(0) + + await root.getByRole("button", { name: "Project menu" }).click() + await expect(page.getByRole("menu").getByText("Fixture menu group")).toBeVisible() + await page.getByRole("menuitem", { name: "Open in" }).hover() + await page.getByRole("menu", { name: "Open in" }).getByRole("menuitem", { name: "Editor" }).click() + await expect(page.getByRole("menu")).toHaveCount(0) + await expect(page.locator("body")).toHaveAttribute( + "data-ui", + /^button;menu;submit acme;select month;usage;sub true;editor;/, + ) }) story( diff --git a/packages/app/src/runtime/plugin/ui.tsx b/packages/app/src/runtime/plugin/ui.tsx index 30ff092791be..3c4790ab84d6 100644 --- a/packages/app/src/runtime/plugin/ui.tsx +++ b/packages/app/src/runtime/plugin/ui.tsx @@ -8,6 +8,7 @@ import { IconButton } from "@opencode/ui/icon-button" import { Keybind } from "@opencode/ui/keybind" import { Loader } from "@opencode/ui/loader" import { Menu } from "@opencode/ui/menu" +import { Select } from "@opencode/ui/select" import { Switch } from "@opencode/ui/switch" import { TextInput } from "@opencode/ui/text-input" import { Tooltip } from "@opencode/ui/tooltip" @@ -100,17 +101,38 @@ export const ui: Ui = { return ( {entry.label} - {(item) => } + {(item) => } ) - return + return }} ), + MenuItem, + MenuSeparator: () => , + MenuGroup: (props) => ( + + {props.label} + {props.children} + + ), + MenuSub: (props) => ( + props.onOpenChange?.(open)}> + + {(icon) => } + {props.label} + + + + {props.children} + + + + ), Loader: (props) => ( ), + Select: (props) => ( +