mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): session-scoped plugin toasts (#49962)
This commit is contained in:
@@ -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)
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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" }])
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user