Skip to content

Commit 8de0323

Browse files
feat(core): add /goal with a client-side done check (#59437)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
1 parent f43563b commit 8de0323

39 files changed

Lines changed: 2463 additions & 69 deletions

File tree

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,3 +1511,51 @@ story("composer pills show per session, open their rows, and fail only their own
15111511
await rows.nth(0).click()
15121512
await expect(page.locator("body")).toHaveAttribute("data-picked", "12")
15131513
})
1514+
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()
1561+
})

‎packages/app/e2e/utils/mock-api.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,14 @@ const Group = HttpApiGroup.make("mock")
300300
)
301301
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
302302
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
303+
.add(
304+
HttpApiEndpoint.post("rpcCall", "/api/rpc/:rpcID/:method", {
305+
params: { rpcID: Schema.String, method: Schema.String },
306+
payload: JsonPayload,
307+
success: Json,
308+
error: Unsupported,
309+
}),
310+
)
303311
.add(
304312
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
305313
params: SessionParams,

‎packages/app/e2e/utils/mock-server.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,15 @@ function mockHandlers(
12741274
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
12751275
})
12761276
},
1277+
// The goal plugin's pill reads each routed session's goal; no scenario sets one.
1278+
rpcCall: (ctx) =>
1279+
ctx.params.rpcID === "opencode.goal" && ctx.params.method === "get"
1280+
? Effect.succeed({ output: null })
1281+
: Effect.fail(
1282+
new MockUnsupported({
1283+
message: `The mock server has no ${ctx.params.rpcID}.${ctx.params.method} RPC`,
1284+
}),
1285+
),
12771286
sessionActive: () => {
12781287
const status = resolve(config.sessionStatus ?? {})
12791288

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

Lines changed: 94 additions & 40 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,
@@ -25,7 +27,8 @@ const tones: Record<ComposerPillTone, string> = {
2527
danger: "text-v2-state-fg-danger",
2628
}
2729

28-
// Plugin pills in the composer lane, after the app's own: on the side away from the lane's aligned edge.
30+
// Plugin pills in the composer lane, after the app's own: on the side away from the lane's aligned edge. A pill with
31+
// nothing to show renders no element, so the row hides while it is empty and takes no gap in the lane.
2932
export function SessionPluginPills(props: {
3033
session: MountedSession
3134
screen: SessionScreen
@@ -52,7 +55,7 @@ export function SessionPluginPills(props: {
5255
<Show when={items().length > 0}>
5356
<div
5457
data-component="session-plugin-pills"
55-
class="flex min-w-0 shrink items-center gap-2"
58+
class="flex min-w-0 shrink items-center gap-2 [&:not(:has(*))]:hidden"
5659
classList={{ "-order-2": alignment() === "end", "order-2": alignment() !== "end" }}
5760
>
5861
<For each={items()}>
@@ -71,12 +74,12 @@ function PluginPill(props: { pill: ComposerPill; input: ComposerPillProps; align
7174
const alignment = props.alignment
7275
const view = createMemo(() => props.pill.show(props.input))
7376
const placement = () => (({ center: "top", start: "top-start", end: "top-end" }) as const)[alignment()]
77+
const [state, setState] = createStore({ open: false })
7478

7579
return (
7680
<Show when={view()}>
7781
{(current) => (
78-
<Show
79-
when={current().rows}
82+
<Switch
8083
fallback={
8184
<button
8285
type="button"
@@ -89,42 +92,93 @@ function PluginPill(props: { pill: ComposerPill; input: ComposerPillProps; align
8992
</button>
9093
}
9194
>
92-
{(rows) => (
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}
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}
100102
>
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>{current().title ?? current().label}</Menu.GroupLabel>
107-
<For each={rows()}>
108-
{(row) => (
109-
<Menu.Item
110-
data-slot="session-plugin-pill-row"
111-
disabled={!row.run}
112-
onSelect={() => row.run?.()}
113-
badge={row.detail}
114-
>
115-
<PillIcon name={row.icon} tone={row.tone} />
116-
<span dir="auto" class="min-w-0 flex-1 truncate">
117-
{row.label}
118-
</span>
119-
</Menu.Item>
120-
)}
121-
</For>
122-
</Menu.Group>
123-
</Menu.Content>
124-
</Menu.Portal>
125-
</Menu>
126-
)}
127-
</Show>
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+
}),
134+
)}
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>
128182
)}
129183
</Show>
130184
)

‎packages/app/src/shell/notifications/notification.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ export function openNotificationSession(tabs: NotificationTabs, server: ServerCo
2525
tabs.select(tab)
2626
}
2727

28+
// Only direct children: a grandchild reports to its own parent, so waiting on it could mean no sound at all. Running
29+
// shells don't count, or a session with a dev server running would never play one.
30+
function hasRunningChild(data: Data, sessionID: string) {
31+
return data.session
32+
.family(sessionID)
33+
.some(
34+
(child) =>
35+
child !== sessionID &&
36+
data.session.get(child)?.parentID === sessionID &&
37+
data.session.status(child) === "running",
38+
)
39+
}
40+
2841
/** A session is unread when its latest terminal execution is newer than the last one any client viewed. */
2942
function isSessionUnread(session?: Pick<SessionInfo, "time">) {
3043
const time = session?.time
@@ -217,6 +230,9 @@ export function createServerNotificationState(input: {
217230
}
218231

219232
const handleSessionIdle = (sessionID: string, eventID: string) => {
233+
// A running subagent's report wakes the session again, so it isn't done yet.
234+
if (hasRunningChild(input.data, sessionID)) return
235+
220236
void lookup(sessionID).then((session) => {
221237
if (disposed.value) return
222238

‎packages/app/test-browser/server-notification-state.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ test.each([
8282
},
8383
},
8484
},
85-
data: { session: { get: () => session } },
85+
data: { session: { get: () => session, family: () => [session.id], status: () => "idle" } },
8686
coordinator: { system: async (_id: string, fn: () => Promise<void>) => fn() },
8787
})
8888

‎packages/client/src/solid/data.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,11 @@ export function createData(config: CreateDataInput) {
780780
if (store.session.info[event.data.sessionID])
781781
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
782782
return
783+
case "session.metadata.updated":
784+
// The event carries the whole record, so keys it drops must go too.
785+
if (store.session.info[event.data.sessionID])
786+
setStore("session", "info", event.data.sessionID, "metadata", reconcile(event.data.metadata))
787+
return
783788
case "session.archived":
784789
if (store.session.info[event.data.sessionID])
785790
setStore("session", "info", event.data.sessionID, "time", "archived", event.created)

0 commit comments

Comments
 (0)