Skip to content

Commit 26af6ab

Browse files
feat(plugin): let composer pills render their own popover content (#59764)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
1 parent 6e89b2e commit 26af6ab

5 files changed

Lines changed: 197 additions & 101 deletions

File tree

‎packages/app/component-tests/plugin-host.spec.ts‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,9 +1452,6 @@ story("composer pills show per session, open their rows, and fail only their own
14521452
},
14531453
{ id: "13", label: "#13 Draft", icon: "branch" },
14541454
],
1455-
actions: [
1456-
{ id: "refresh", label: "Refresh", run: () => void (document.body.dataset.refreshed = "") },
1457-
],
14581455
}),
14591456
}),
14601457
}),
@@ -1511,14 +1508,54 @@ story("composer pills show per session, open their rows, and fail only their own
15111508
// A row without `run` only shows.
15121509
await expect(rows.nth(1)).toHaveAttribute("data-disabled", "")
15131510

1514-
// Actions sit in one row under the rows.
1515-
const actions = menu.locator('[data-slot="session-plugin-pill-actions"] [data-slot="session-plugin-pill-action"]')
1516-
await expect(actions).toHaveText(["Refresh"])
1517-
15181511
await rows.nth(0).click()
15191512
await expect(page.locator("body")).toHaveAttribute("data-picked", "12")
1513+
})
15201514

1521-
await pills.click()
1522-
await actions.first().click()
1523-
await expect(page.locator("body")).toHaveAttribute("data-refreshed", "")
1515+
story("a composer pill with a menu opens the plugin's content under the host's heading", async ({ page }) => {
1516+
await page.evaluate(async (fixture) => {
1517+
const { mountComposerPills, until, ComposerPill } = await import(fixture)
1518+
1519+
const host = mountComposerPills({
1520+
definitions: [
1521+
{
1522+
id: "acme",
1523+
windowEntry: async () => ({
1524+
default: (ctx: Context) =>
1525+
void ctx.add(ComposerPill, {
1526+
id: "goal",
1527+
// `menu` wins over the view's rows.
1528+
show: () => ({ label: "Goal", title: "Ship the release", rows: [{ id: "unused", label: "Unused" }] }),
1529+
menu: (input: { readonly session: { readonly directory: string }; close(): void }) => {
1530+
const button = document.createElement("button")
1531+
button.dataset.testid = "goal-pause"
1532+
button.textContent = `Pause ${input.session.directory}`
1533+
button.onclick = () => input.close()
1534+
1535+
return button
1536+
},
1537+
}),
1538+
}),
1539+
},
1540+
],
1541+
})
1542+
1543+
await until(() => host.status("acme") === "active")
1544+
}, fixture)
1545+
1546+
const pill = page.getByTestId("composer-pills").locator('[data-slot="session-plugin-pill"]')
1547+
await expect(pill).toHaveText("Goal")
1548+
1549+
await pill.click()
1550+
const popover = page.locator('[data-slot="session-plugin-pill-popover"]')
1551+
await expect(popover.getByText("Ship the release")).toBeVisible()
1552+
await expect(popover.locator('[data-slot="session-plugin-pill-row"]')).toHaveCount(0)
1553+
await expect(popover.getByTestId("goal-pause")).toHaveText("Pause /repo")
1554+
1555+
await popover.getByTestId("goal-pause").click()
1556+
await expect(popover).toHaveCount(0)
1557+
1558+
// The content renders again on the next open.
1559+
await pill.click()
1560+
await expect(page.getByTestId("goal-pause")).toBeVisible()
15241561
})

‎packages/app/src/session/composer/plugin-pills.tsx‎

Lines changed: 91 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Icon } from "@opencode/ui/icon"
22
import { Menu } from "@opencode/ui/menu"
3-
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
3+
import { Popover } from "@kobalte/core/popover"
4+
import { createMemo, For, Match, Show, Switch, untrack, type Accessor, type JSX } from "solid-js"
5+
import { createStore } from "solid-js/store"
46
import {
57
ComposerPill,
68
type ComposerPillProps,
@@ -72,12 +74,12 @@ function PluginPill(props: { pill: ComposerPill; input: ComposerPillProps; align
7274
const alignment = props.alignment
7375
const view = createMemo(() => props.pill.show(props.input))
7476
const placement = () => (({ center: "top", start: "top-start", end: "top-end" }) as const)[alignment()]
77+
const [state, setState] = createStore({ open: false })
7578

7679
return (
7780
<Show when={view()}>
7881
{(current) => (
79-
<Show
80-
when={current().rows || current().actions}
82+
<Switch
8183
fallback={
8284
<button
8385
type="button"
@@ -90,58 +92,93 @@ function PluginPill(props: { pill: ComposerPill; input: ComposerPillProps; align
9092
</button>
9193
}
9294
>
93-
<Menu gutter={8} placement={placement()}>
94-
<Menu.Trigger
95-
as="button"
96-
type="button"
97-
data-slot="session-plugin-pill"
98-
aria-label={current().title}
99-
class={capsule}
100-
>
101-
<Content view={current()} />
102-
</Menu.Trigger>
103-
<Menu.Portal>
104-
<Menu.Content data-slot="session-plugin-pill-menu" aria-label={current().title ?? current().label}>
105-
<Menu.Group>
106-
<Menu.GroupLabel class="!h-auto max-w-80 !py-1.5">
107-
<span dir="auto" class="line-clamp-3 break-words">
108-
{current().title ?? current().label}
109-
</span>
110-
</Menu.GroupLabel>
111-
<For each={current().rows}>
112-
{(row) => (
113-
<Menu.Item
114-
data-slot="session-plugin-pill-row"
115-
disabled={!row.run}
116-
onSelect={() => row.run?.()}
117-
badge={row.detail}
118-
>
119-
<PillIcon name={row.icon} tone={row.tone} />
120-
<span dir="auto" class="min-w-0 flex-1 truncate">
121-
{row.label}
122-
</span>
123-
</Menu.Item>
95+
<Match when={props.pill.menu}>
96+
{(menu) => (
97+
<Popover
98+
open={state.open}
99+
onOpenChange={(open) => setState("open", open)}
100+
placement={placement()}
101+
gutter={8}
102+
>
103+
<Popover.Trigger
104+
as="button"
105+
type="button"
106+
data-slot="session-plugin-pill"
107+
aria-label={current().title}
108+
class={capsule}
109+
>
110+
<Content view={current()} />
111+
</Popover.Trigger>
112+
<Popover.Portal>
113+
<Popover.Content
114+
data-slot="session-plugin-pill-popover"
115+
aria-label={current().title ?? current().label}
116+
class="w-80 max-w-[calc(100vw-56px)] rounded-[10px] border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-01 p-1 shadow-[var(--v2-elevation-floating)] outline-none"
117+
>
118+
<div class="px-2 pt-1 pb-1.5 text-[11px] font-[530] tracking-[0.05px] leading-[var(--line-height-tight)] text-v2-text-text-faint">
119+
<span dir="auto" class="line-clamp-3 break-words">
120+
{current().title ?? current().label}
121+
</span>
122+
</div>
123+
{/* Untracked: the content renders once per open; its own reactivity updates it in place. */}
124+
{untrack(() =>
125+
menu()({
126+
get session() {
127+
return props.input.session
128+
},
129+
get screen() {
130+
return props.input.screen
131+
},
132+
close: () => setState("open", false),
133+
}),
124134
)}
125-
</For>
126-
</Menu.Group>
127-
<Show when={current().actions?.length}>
128-
<div data-slot="session-plugin-pill-actions" class="flex justify-end gap-1 px-1 pt-1">
129-
<For each={current().actions}>
130-
{(action) => (
131-
<Menu.Item data-slot="session-plugin-pill-action" class="!px-2" onSelect={() => action.run()}>
132-
<PillIcon name={action.icon} />
133-
<span dir="auto" class="whitespace-nowrap">
134-
{action.label}
135-
</span>
136-
</Menu.Item>
137-
)}
138-
</For>
139-
</div>
140-
</Show>
141-
</Menu.Content>
142-
</Menu.Portal>
143-
</Menu>
144-
</Show>
135+
</Popover.Content>
136+
</Popover.Portal>
137+
</Popover>
138+
)}
139+
</Match>
140+
<Match when={current().rows}>
141+
{(rows) => (
142+
<Menu gutter={8} placement={placement()}>
143+
<Menu.Trigger
144+
as="button"
145+
type="button"
146+
data-slot="session-plugin-pill"
147+
aria-label={current().title}
148+
class={capsule}
149+
>
150+
<Content view={current()} />
151+
</Menu.Trigger>
152+
<Menu.Portal>
153+
<Menu.Content data-slot="session-plugin-pill-menu" aria-label={current().title ?? current().label}>
154+
<Menu.Group>
155+
<Menu.GroupLabel class="!h-auto max-w-80 !py-1.5">
156+
<span dir="auto" class="line-clamp-3 break-words">
157+
{current().title ?? current().label}
158+
</span>
159+
</Menu.GroupLabel>
160+
<For each={rows()}>
161+
{(row) => (
162+
<Menu.Item
163+
data-slot="session-plugin-pill-row"
164+
disabled={!row.run}
165+
onSelect={() => row.run?.()}
166+
badge={row.detail}
167+
>
168+
<PillIcon name={row.icon} tone={row.tone} />
169+
<span dir="auto" class="min-w-0 flex-1 truncate">
170+
{row.label}
171+
</span>
172+
</Menu.Item>
173+
)}
174+
</For>
175+
</Menu.Group>
176+
</Menu.Content>
177+
</Menu.Portal>
178+
</Menu>
179+
)}
180+
</Match>
181+
</Switch>
145182
)}
146183
</Show>
147184
)

‎packages/gui-plugins/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the
223223
| [`Server`](../plugin/src/gui/registries.ts) | window | A source of servers, such as SSH hosts |
224224
| [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists |
225225
| [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer |
226-
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows with a row of action buttons |
226+
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
227227
| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` |
228228
| [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` |
229229
| [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu |

‎packages/gui-plugins/src/goal/renderer.tsx‎

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { ComposerPill, type ComposerPillAction, type ComposerPillProps, type Plugin } from "@opencode/plugin/gui"
1+
import { ComposerPill, type ComposerPillProps, type Plugin } from "@opencode/plugin/gui"
22
import { SessionGoal } from "@opencode/schema/session-goal"
3+
import { Button } from "@opencode/ui/button"
34
import { showToast } from "@opencode/ui/toast"
5+
import { Show } from "solid-js"
46
import type Goal from "./index"
57

68
const status = {
@@ -10,10 +12,18 @@ const status = {
1012
} as const
1113

1214
/**
13-
* The session's `/goal` above its composer: the status as a pill, and a menu headed by the objective with the actions
14-
* that apply. The server's goal plugin keeps the goal in the session's metadata; a bare `/goal` shows the last reason.
15+
* The session's `/goal` above its composer: the status as a pill, and a popover headed by the objective with the
16+
* actions that apply. The server's goal plugin keeps the goal in the session's metadata; a bare `/goal` shows the last
17+
* reason.
1518
*/
1619
const setup: Plugin.Setup<typeof Goal> = (ctx) => {
20+
// A goal copied from the parent belongs to the parent, so only the session's own goal shows.
21+
const goal = (input: ComposerPillProps) => {
22+
const session = input.session.server.data.session.get(input.session.id)
23+
24+
return session && SessionGoal.fromMetadata(session.id, session.metadata)
25+
}
26+
1727
const command = (input: ComposerPillProps, text: "pause" | "resume" | "clear") =>
1828
void input.session.server.client.session
1929
.command({ sessionID: input.session.id, name: "goal", text })
@@ -28,24 +38,37 @@ const setup: Plugin.Setup<typeof Goal> = (ctx) => {
2838
ctx.add(ComposerPill, {
2939
id: "goal",
3040
show: (input) => {
31-
const session = input.session.server.data.session.get(input.session.id)
32-
// A goal copied from the parent belongs to the parent, so only the session's own goal shows.
33-
const goal = session && SessionGoal.fromMetadata(session.id, session.metadata)
34-
35-
if (!goal) return
41+
const current = goal(input)
3642

37-
const view = status[goal.status]
38-
const actions: ComposerPillAction[] = []
43+
if (!current) return
3944

40-
if (goal.status === "active")
41-
actions.push({ id: "pause", label: ctx.t("pause"), icon: "stop", run: () => command(input, "pause") })
45+
const view = status[current.status]
4246

43-
if (goal.status === "paused")
44-
actions.push({ id: "resume", label: ctx.t("resume"), icon: "refresh", run: () => command(input, "resume") })
45-
46-
actions.push({ id: "clear", label: ctx.t("clear"), icon: "outline-trash", run: () => command(input, "clear") })
47+
return { label: ctx.t(view.label), icon: view.icon, tone: view.tone, title: current.objective }
48+
},
49+
menu: (input) => {
50+
const run = (text: "pause" | "resume" | "clear") => {
51+
input.close()
52+
command(input, text)
53+
}
4754

48-
return { label: ctx.t(view.label), icon: view.icon, tone: view.tone, title: goal.objective, actions }
55+
return (
56+
<div class="flex justify-end gap-1 px-1 pt-1">
57+
<Show when={goal(input)?.status === "active"}>
58+
<Button size="small" variant="ghost" icon="stop" onClick={() => run("pause")}>
59+
{ctx.t("pause")}
60+
</Button>
61+
</Show>
62+
<Show when={goal(input)?.status === "paused"}>
63+
<Button size="small" variant="ghost" icon="refresh" onClick={() => run("resume")}>
64+
{ctx.t("resume")}
65+
</Button>
66+
</Show>
67+
<Button size="small" variant="ghost" icon="outline-trash" onClick={() => run("clear")}>
68+
{ctx.t("clear")}
69+
</Button>
70+
</div>
71+
)
4972
},
5073
})
5174
}

‎packages/plugin/src/gui/registries.ts‎

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -619,16 +619,10 @@ export interface ComposerPillRow {
619619
run?(): void
620620
}
621621

622-
/** A button in the row at the bottom of the menu a composer pill opens. */
623-
export interface ComposerPillAction {
624-
/** The button's id, unique within the pill. */
625-
readonly id: string
626-
/** The button's text. */
627-
readonly label: string
628-
/** The button's icon. Omit it for none. */
629-
readonly icon?: IconName
630-
/** Runs when the user clicks the button; the menu then closes. */
631-
run(): void
622+
/** What a composer pill's `menu` receives: the pill's session and a way to close the popover. Do not destructure. */
623+
export interface ComposerPillMenuProps extends ComposerPillProps {
624+
/** Closes the popover, for example after the user picks an action in it. Does nothing once it is closed. */
625+
close(): void
632626
}
633627

634628
/** What a composer pill shows above one session's composer. */
@@ -640,18 +634,13 @@ export interface ComposerPillView {
640634
/** Colors the capsule's icon. */
641635
readonly tone?: ComposerPillTone
642636
/**
643-
* Accessible name, and the heading of the menu the pill opens, when it differs from `label`. The heading wraps to at
644-
* most three lines, then truncates.
637+
* Accessible name, and the heading of the menu or popover the pill opens, when it differs from `label`. The heading
638+
* wraps to at most three lines, then truncates.
645639
*/
646640
readonly title?: string
647-
/** The rows of the menu the pill opens. Omit them, and `actions`, for a pill that runs `run` instead. */
641+
/** The menu the pill opens. Omit it for a pill that runs `run` instead. Unused when the pill has a `menu`. */
648642
readonly rows?: readonly ComposerPillRow[]
649-
/**
650-
* Buttons in one row at the bottom of the menu the pill opens, for actions on what the pill shows, such as pause and
651-
* clear. The pill opens its menu when it has rows or actions.
652-
*/
653-
readonly actions?: readonly ComposerPillAction[]
654-
/** Runs when the user clicks a pill without `rows` or `actions`. */
643+
/** Runs when the user clicks a pill without `rows` or a `menu`. */
655644
run?(): void
656645
}
657646

@@ -672,6 +661,16 @@ export interface ComposerPill {
672661
* @param input - The session and its screen. Do not destructure.
673662
*/
674663
show(input: ComposerPillProps): ComposerPillView | undefined
664+
/**
665+
* Your content for the popover the pill opens, for a pill that needs more than `rows`, such as status text and
666+
* buttons. The host owns the capsule and the popover around this content: its placement, focus, dismissal, and the
667+
* heading (`title`, else `label`). When set, clicking the pill opens the popover, and the view's `rows` and `run` are
668+
* unused. Renders each time the popover opens; its own reactivity updates it while open, also when another session is
669+
* routed. A render that throws renders nothing and records the error.
670+
*
671+
* @param input - The session, its screen, and `close`. Do not destructure.
672+
*/
673+
menu?(input: ComposerPillMenuProps): JSX.Element
675674
}
676675

677676
/** The places a `Slot` renders, with the input each passes to `render`. */

0 commit comments

Comments
 (0)