feat(tui): session-scoped plugin toasts (#49962)

This commit is contained in:
Shoubhit Dash
2026-09-19 18:24:38 +05:30
committed by GitHub
parent b447627f5f
commit 4b00dd2713
5 changed files with 94 additions and 8 deletions
+2
View File
@@ -273,6 +273,8 @@ export interface ToastOptions {
readonly message: string
readonly variant?: ToastVariant
readonly duration?: number
/** When this session's family is not open, the title defaults to the session title and the toast offers to open it. */
readonly sessionID?: string
}
export interface Toast {
@@ -67,9 +67,7 @@ export default Plugin.define({
}
errored.add(sessionID)
notify(context, sessionID, event.data.error.message, "error")
const route = context.ui.router.current()
if (route.type === "session" && route.sessionID === sessionID)
context.ui.toast.show({ title: "Session failed", message: event.data.error.message, variant: "error" })
context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" })
ended(sessionID)
}),
]
+21 -1
View File
@@ -95,7 +95,27 @@ export function createPluginContext(input: {
const dialogApi = createDialogApi(host.dialog, provide)
const toastApi: Toast = {
show(options) {
host.toast.show({ ...options, variant: options.variant ?? "info" })
const toast = {
title: options.title,
message: options.message,
variant: options.variant ?? "info",
duration: options.duration,
}
const sessionID = options.sessionID
if (sessionID === undefined) {
host.toast.show(toast)
return
}
const route = host.route.data
if (route.type === "session" && host.data.session.root(route.sessionID) === host.data.session.root(sessionID)) {
host.toast.show(toast)
return
}
host.toast.show({
...toast,
title: toast.title ?? host.data.session.get(sessionID)?.title,
action: { label: "Open", run: () => host.route.navigate({ type: "session", sessionID }) },
})
},
}
// Unregistering after deactivation is a no-op: deactivate already resets
@@ -146,23 +146,27 @@ const permissionNotification: AttentionNotifyOptions = {
}
describe("internal notifications TUI plugin", () => {
test("shows execution failures in the viewed session without needing an assistant message", async () => {
test("shows execution failures as session-scoped toasts without needing an assistant message", async () => {
const harness = await setup()
harness.emit(executionStarted("started"))
harness.emit(executionFailed("failed"))
harness.emit(executionFailed("duplicate"))
expect(harness.toasts).toEqual([{ title: "Session failed", message: "boom", variant: "error" }])
expect(harness.toasts).toEqual([
{ sessionID: "session", title: "Session failed", message: "boom", variant: "error" },
])
harness.emit(executionStarted("retry"))
harness.emit(executionFailed("failed-again"))
expect(harness.toasts).toHaveLength(2)
})
test.each<Route>([{ type: "home" }, { type: "session", sessionID: "other" }])(
"keeps other sessions' failures out of the current composer (%j)",
"leaves routing of other sessions' failures to the session-scoped toast (%j)",
async (route) => {
const harness = await setup(route)
harness.emit(executionFailed("failed"))
expect(harness.toasts).toEqual([])
expect(harness.toasts).toEqual([
{ sessionID: "session", title: "Session failed", message: "boom", variant: "error" },
])
expect(harness.notifications).toHaveLength(1)
},
)
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test"
import type { Route } from "@opencode/plugin/tui/context"
import { createPluginContext, type Registry, type usePluginHost } from "../src/plugin/api"
type Host = ReturnType<typeof usePluginHost>
type Shown = Parameters<Host["toast"]["show"]>[0]
const sessions: Record<string, { id: string; title?: string; parentID?: string }> = {
parent: { id: "parent", title: "Parent session" },
child: { id: "child", title: "Child session", parentID: "parent" },
other: { id: "other", title: "Other session" },
}
const root = (sessionID: string): string => {
const parentID = sessions[sessionID]?.parentID
return parentID ? root(parentID) : sessionID
}
function setup(route: Route) {
const shown: Shown[] = []
const navigated: Route[] = []
const host = {
app: { version: "test", channel: "test" },
client: { api: {} },
keymap: {},
shortcuts: {},
keymapState: {},
sessionTabs: {},
toast: { show: (toast: Shown) => shown.push(toast) },
route: {
get data() {
return route
},
navigate: (destination: Route) => navigated.push(destination),
},
data: { session: { root, get: (sessionID: string) => sessions[sessionID] } },
} as unknown as Host
const registry: Registry = { has: () => false, set() {}, remove() {}, active: () => true }
const context = createPluginContext({ host, id: "test", options: undefined, owned: [], registry })
return { shown, navigated, toast: context.ui.toast }
}
test.each([
["parent", "parent"],
["parent", "child"],
["child", "parent"],
])("toast for %s's family shows as-is while %s is open", (target, routed) => {
const harness = setup({ type: "session", sessionID: routed })
harness.toast.show({ sessionID: target, message: "done" })
expect(harness.shown).toEqual([{ title: undefined, message: "done", variant: "info", duration: undefined }])
})
test.each<Route>([{ type: "home" }, { type: "session", sessionID: "parent" }])(
"toast for a session that is not open gains its title and an Open action (%j)",
(route) => {
const harness = setup(route)
harness.toast.show({ sessionID: "other", message: "done" })
expect(harness.shown[0]).toMatchObject({ title: "Other session", action: { label: "Open" } })
harness.shown[0].action?.run()
expect(harness.navigated).toEqual([{ type: "session", sessionID: "other" }])
},
)