Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
4a7a344
feat(plugin): add session idle hook
neriousy Oct 23, 2026
eee91df
refactor(plugin): continue idle sessions through admitted input
neriousy Oct 23, 2026
5cac47a
fix(core): skip the idle hook for control-only drains
neriousy Oct 23, 2026
8304ef1
feat(plugin): pass outstanding background work to the idle hook
neriousy Oct 26, 2026
96192c1
fix(core): run the idle hook after a move or reload at the idle boundary
neriousy Oct 26, 2026
7f261cc
docs(plugin): clarify idle hook restarts, failures and parked notices
neriousy Oct 26, 2026
1f73d3d
fix(core): hold session done notifications until background subagents…
neriousy Oct 26, 2026
68c8b4c
feat(core): add /goal to keep a session working until an objective is…
neriousy Oct 26, 2026
e00cabd
feat(core): show the goal kickoff as a notice and share the goal schema
neriousy Oct 26, 2026
633dd09
feat(core): hide goal continuations and pause goals that stop making …
neriousy Oct 26, 2026
bdf743e
feat(core): keep the goal through compaction
neriousy Oct 26, 2026
84e213a
feat(plugin): tell compaction hooks when the provider compacts natively
neriousy Oct 26, 2026
61a36d5
fix(core): harden goals for long-running sessions
neriousy Oct 26, 2026
999f501
fix(core): create the goal boot ID on first use, not at module load
neriousy Oct 26, 2026
198dca3
feat(tui): show the session goal under the prompt
neriousy Oct 26, 2026
519531d
feat(app): show the session goal in the composer lane
neriousy Oct 26, 2026
07aaa61
chore(sdk): regenerate client
neriousy Oct 26, 2026
a8c3ef3
test(core): use the gate function from dev
neriousy Oct 26, 2026
ba8858e
chore(protocol): regenerate OpenAPI for session.settled
neriousy Oct 26, 2026
a8db483
fix(core): only wait on background work that wakes the session
neriousy Oct 26, 2026
00ac13c
chore(app): drop unrelated blank lines
neriousy Oct 26, 2026
f52a04e
chore(app): restore the blank lines the spacing lint requires
neriousy Oct 26, 2026
1da7798
fix(core): keep a goal waiting while its subagents keep working
neriousy Oct 26, 2026
bc48ada
refactor(core): drop the session.settled event
neriousy Oct 26, 2026
07601ee
fix(app,tui): hold the done sound while a direct subagent is running
neriousy Oct 26, 2026
d3d42c6
test: drop the goal stories and low-value goal tests
neriousy Oct 26, 2026
ea3d0cf
test(client): drop the metadata update test
neriousy Oct 26, 2026
f719c49
refactor(core): drop the goal compaction note and the native hook flag
neriousy Oct 26, 2026
b76b6cc
test(app): drop the direct-children notification test
neriousy Oct 26, 2026
a9ac8b3
Merge remote-tracking branch 'origin/dev' into client-done-check
neriousy Oct 27, 2026
52e4a52
refactor(app): move the goal pill into a built-in GUI plugin
neriousy Oct 27, 2026
fbf9497
fix(app): use circle icons for the goal pill
neriousy Oct 27, 2026
e487d1f
fix(app): wrap long composer pill menu headings
neriousy Oct 27, 2026
a9d28f0
fix(app): show only the objective and actions in the goal menu
neriousy Oct 27, 2026
afb7525
feat(app): let composer pills show actions in a row
neriousy Oct 27, 2026
bf7b08e
Merge branch 'dev' into client-done-check
neriousy Oct 27, 2026
f899e5e
fix(app): hide the plugin pill row while no pill shows
neriousy Oct 27, 2026
6e89b2e
Merge remote-tracking branch 'origin/dev' into client-done-check
neriousy Oct 27, 2026
26af6ab
feat(plugin): let composer pills render their own popover content (#5…
opencode-agent[bot] Oct 27, 2026
62844f2
Merge branch 'dev' into client-done-check
neriousy Oct 27, 2026
e6a2753
refactor(plugin): mark the session idle hook experimental
neriousy Oct 28, 2026
3582fab
refactor(core): serve the goal over a plugin RPC instead of session m…
neriousy Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/app/component-tests/plugin-host.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1511,3 +1511,51 @@ story("composer pills show per session, open their rows, and fail only their own
await rows.nth(0).click()
await expect(page.locator("body")).toHaveAttribute("data-picked", "12")
})

story("a composer pill with a menu opens the plugin's content under the host's heading", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountComposerPills, until, ComposerPill } = await import(fixture)

const host = mountComposerPills({
definitions: [
{
id: "acme",
windowEntry: async () => ({
default: (ctx: Context) =>
void ctx.add(ComposerPill, {
id: "goal",
// `menu` wins over the view's rows.
show: () => ({ label: "Goal", title: "Ship the release", rows: [{ id: "unused", label: "Unused" }] }),
menu: (input: { readonly session: { readonly directory: string }; close(): void }) => {
const button = document.createElement("button")
button.dataset.testid = "goal-pause"
button.textContent = `Pause ${input.session.directory}`
button.onclick = () => input.close()

return button
},
}),
}),
},
],
})

await until(() => host.status("acme") === "active")
}, fixture)

const pill = page.getByTestId("composer-pills").locator('[data-slot="session-plugin-pill"]')
await expect(pill).toHaveText("Goal")

await pill.click()
const popover = page.locator('[data-slot="session-plugin-pill-popover"]')
await expect(popover.getByText("Ship the release")).toBeVisible()
await expect(popover.locator('[data-slot="session-plugin-pill-row"]')).toHaveCount(0)
await expect(popover.getByTestId("goal-pause")).toHaveText("Pause /repo")

await popover.getByTestId("goal-pause").click()
await expect(popover).toHaveCount(0)

// The content renders again on the next open.
await pill.click()
await expect(page.getByTestId("goal-pause")).toBeVisible()
})
8 changes: 8 additions & 0 deletions packages/app/e2e/utils/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
.add(
HttpApiEndpoint.post("rpcCall", "/api/rpc/:rpcID/:method", {
params: { rpcID: Schema.String, method: Schema.String },
payload: JsonPayload,
success: Json,
error: Unsupported,
}),
)
.add(
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
params: SessionParams,
Expand Down
9 changes: 9 additions & 0 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,15 @@ function mockHandlers(
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
})
},
// The goal plugin's pill reads each routed session's goal; no scenario sets one.
rpcCall: (ctx) =>
ctx.params.rpcID === "opencode.goal" && ctx.params.method === "get"
? Effect.succeed({ output: null })
: Effect.fail(
new MockUnsupported({
message: `The mock server has no ${ctx.params.rpcID}.${ctx.params.method} RPC`,
}),
),
sessionActive: () => {
const status = resolve(config.sessionStatus ?? {})

Expand Down
134 changes: 94 additions & 40 deletions packages/app/src/session/composer/plugin-pills.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { Popover } from "@kobalte/core/popover"
import { createMemo, For, Match, Show, Switch, untrack, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import {
ComposerPill,
type ComposerPillProps,
Expand All @@ -25,7 +27,8 @@ const tones: Record<ComposerPillTone, string> = {
danger: "text-v2-state-fg-danger",
}

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

return (
<Show when={view()}>
{(current) => (
<Show
when={current().rows}
<Switch
fallback={
<button
type="button"
Expand All @@ -89,42 +92,93 @@ function PluginPill(props: { pill: ComposerPill; input: ComposerPillProps; align
</button>
}
>
{(rows) => (
<Menu gutter={8} placement={placement()}>
<Menu.Trigger
as="button"
type="button"
data-slot="session-plugin-pill"
aria-label={current().title}
class={capsule}
<Match when={props.pill.menu}>
{(menu) => (
<Popover
open={state.open}
onOpenChange={(open) => setState("open", open)}
placement={placement()}
gutter={8}
>
<Content view={current()} />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content data-slot="session-plugin-pill-menu" aria-label={current().title ?? current().label}>
<Menu.Group>
<Menu.GroupLabel>{current().title ?? current().label}</Menu.GroupLabel>
<For each={rows()}>
{(row) => (
<Menu.Item
data-slot="session-plugin-pill-row"
disabled={!row.run}
onSelect={() => row.run?.()}
badge={row.detail}
>
<PillIcon name={row.icon} tone={row.tone} />
<span dir="auto" class="min-w-0 flex-1 truncate">
{row.label}
</span>
</Menu.Item>
)}
</For>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Show>
<Popover.Trigger
as="button"
type="button"
data-slot="session-plugin-pill"
aria-label={current().title}
class={capsule}
>
<Content view={current()} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-slot="session-plugin-pill-popover"
aria-label={current().title ?? current().label}
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"
>
<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">
<span dir="auto" class="line-clamp-3 break-words">
{current().title ?? current().label}
</span>
</div>
{/* Untracked: the content renders once per open; its own reactivity updates it in place. */}
{untrack(() =>
menu()({
get session() {
return props.input.session
},
get screen() {
return props.input.screen
},
close: () => setState("open", false),
}),
)}
</Popover.Content>
</Popover.Portal>
</Popover>
)}
</Match>
<Match when={current().rows}>
{(rows) => (
<Menu gutter={8} placement={placement()}>
<Menu.Trigger
as="button"
type="button"
data-slot="session-plugin-pill"
aria-label={current().title}
class={capsule}
>
<Content view={current()} />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content data-slot="session-plugin-pill-menu" aria-label={current().title ?? current().label}>
<Menu.Group>
<Menu.GroupLabel class="!h-auto max-w-80 !py-1.5">
<span dir="auto" class="line-clamp-3 break-words">
{current().title ?? current().label}
</span>
</Menu.GroupLabel>
<For each={rows()}>
{(row) => (
<Menu.Item
data-slot="session-plugin-pill-row"
disabled={!row.run}
onSelect={() => row.run?.()}
badge={row.detail}
>
<PillIcon name={row.icon} tone={row.tone} />
<span dir="auto" class="min-w-0 flex-1 truncate">
{row.label}
</span>
</Menu.Item>
)}
</For>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Match>
</Switch>
)}
</Show>
)
Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/shell/notifications/notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ export function openNotificationSession(tabs: NotificationTabs, server: ServerCo
tabs.select(tab)
}

// Only direct children: a grandchild reports to its own parent, so waiting on it could mean no sound at all. Running
// shells don't count, or a session with a dev server running would never play one.
function hasRunningChild(data: Data, sessionID: string) {
return data.session
.family(sessionID)
.some(
(child) =>
child !== sessionID &&
data.session.get(child)?.parentID === sessionID &&
data.session.status(child) === "running",
)
}

/** A session is unread when its latest terminal execution is newer than the last one any client viewed. */
function isSessionUnread(session?: Pick<SessionInfo, "time">) {
const time = session?.time
Expand Down Expand Up @@ -217,6 +230,9 @@ export function createServerNotificationState(input: {
}

const handleSessionIdle = (sessionID: string, eventID: string) => {
// A running subagent's report wakes the session again, so it isn't done yet.
if (hasRunningChild(input.data, sessionID)) return

void lookup(sessionID).then((session) => {
if (disposed.value) return

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ test.each([
},
},
},
data: { session: { get: () => session } },
data: { session: { get: () => session, family: () => [session.id], status: () => "idle" } },
coordinator: { system: async (_id: string, fn: () => Promise<void>) => fn() },
})

Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,11 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
case "session.metadata.updated":
// The event carries the whole record, so keys it drops must go too.
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "metadata", reconcile(event.data.metadata))
return
case "session.archived":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "time", "archived", event.created)
Expand Down
Loading
Loading