fix(tui): refresh plugin dialog after updates (#46911)

This commit is contained in:
Dax 2026-09-02 17:59:18 -04:00 committed by GitHub
parent 46515df4a3
commit 57c02cd04b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 19 additions and 74 deletions

View file

@ -985,15 +985,6 @@ export type ReferenceUpdated = {
data: {}
}
export type PluginAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "plugin.added"
location?: LocationRef
data: { id: string }
}
export type PluginUpdated = {
id: string
created: number
@ -2299,7 +2290,6 @@ export type V2Event =
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated

View file

@ -20378,44 +20378,6 @@
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.added": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["plugin.added"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": ["id"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.updated": {
"type": "object",
"properties": {
@ -23123,9 +23085,6 @@
{
"$ref": "#/components/schemas/permission.v2.replied"
},
{
"$ref": "#/components/schemas/plugin.added"
},
{
"$ref": "#/components/schemas/plugin.updated"
},

View file

@ -52,7 +52,6 @@ const layer = Layer.effect(
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)

View file

@ -6,7 +6,7 @@
- Preserve the dependency direction: `@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server`.
- Schema values should be serializable contract definitions, not service implementations or runtime registries.
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.added` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.updated` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
- The root barrel exports canonical current domain contracts. Specialized event modules, manifests, infrastructure modules, and V1 contracts use direct entrypoints instead of becoming first-class root exports.
## Current Versus V1

View file

@ -42,12 +42,8 @@ export const Info = Schema.Struct({
state: State,
}).annotate({ identifier: "Plugin.Info" })
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
})
const Updated = ephemeral({
type: "plugin.updated",
schema: {},
})
export const Event = { Added, Updated, Definitions: inventory(Added, Updated) }
export const Event = { Updated, Definitions: inventory(Updated) }

View file

@ -2,6 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { Spinner } from "../../component/spinner"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
@ -31,12 +32,17 @@ export function PluginsDialog(props: {
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [showInternal, setShowInternal] = createSignal(false)
const [pending, setPending] = createSignal<readonly string[]>([])
const [server, { refetch, mutate }] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
const updating = (entry: Entry) =>
pending().includes(entry.key) ||
(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true)
const updatable = (entry: Entry | undefined) => entry !== undefined && outdated(entry) && !updating(entry)
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
@ -86,15 +92,16 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: footer(entry),
footer: updating(entry) ? "updating" : footer(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: outdated(entry)
? props.context.theme.text.feedback.info.default
: props.context.theme.text.subdued,
gutter:
status(entry) === "failed"
gutter: updating(entry)
? (color) => <Spinner color={color} />
: status(entry) === "failed"
? () => <text fg={props.context.theme.text.feedback.error.default}>x</text>
: undefined,
}),
@ -131,20 +138,22 @@ export function PluginsDialog(props: {
}
const update = (entry: Entry | undefined) => {
if (entry?.runtime !== "server" || entry.plugin.source.type !== "package" || !updatable(entry)) return
const location = props.context.location ?? props.context.data.location.default()
setPending((keys) => [...keys, entry.key])
props.context.client.plugin
.update({
location: props.context.location ?? props.context.data.location.default(),
location,
targets: [entry.plugin.source.target],
})
.then(() =>
props.context.ui.toast.show({ variant: "success", message: `Updated plugin ${label(entry, props.context)}` }),
)
.then(() => props.context.client.plugin.awaitActivation({ location }))
.then(() => refetch())
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
})
})
.finally(() => setPending((keys) => keys.filter((key) => key !== entry.key)))
}
// The server only re-checks package sources on startup and then caches the
// result for a day, so a merge pushed after launch stays invisible until the
@ -287,21 +296,13 @@ function outdated(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.outdated === true
}
function updating(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true
}
function updatable(entry: Entry | undefined) {
return entry !== undefined && outdated(entry) && !updating(entry)
}
function footer(entry: Entry) {
const details = [
...(status(entry) === "active" ? [] : [status(entry)]),
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
? [displayVersion(entry.plugin.source.version)]
: []),
...(updating(entry) ? ["updating"] : outdated(entry) ? ["update available"] : []),
...(outdated(entry) ? ["update available"] : []),
]
return details.length ? details.join(", ") : undefined
}