From 57c02cd04b42ae122eab6b1d4e4d615be77ae150 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 2 Sep 2026 17:59:18 -0400 Subject: [PATCH] fix(tui): refresh plugin dialog after updates (#46911) --- .../client/src/promise/generated/types.ts | 10 ----- .../test/fixtures/opencode-v2-openapi.json | 41 ------------------- packages/core/src/plugin.ts | 1 - packages/schema/AGENTS.md | 2 +- packages/schema/src/plugin.ts | 6 +-- .../src/feature-plugins/system/plugins.tsx | 33 +++++++-------- 6 files changed, 19 insertions(+), 74 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 2e61de115a3..e8036b9f541 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index 6c25f690e7b..3acdffbe064 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -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" }, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 414b4cbc764..d0521e72705 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -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, ) diff --git a/packages/schema/AGENTS.md b/packages/schema/AGENTS.md index 70383bb6158..8ef3387d4c3 100644 --- a/packages/schema/AGENTS.md +++ b/packages/schema/AGENTS.md @@ -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 diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 5cb790db52b..7087dfc17a6 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -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) } diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 19b2f8b1b68..0a59ae5bdcb 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -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() const [detail, setDetail] = createSignal() const [showInternal, setShowInternal] = createSignal(false) + const [pending, setPending] = createSignal([]) 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(() => { 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) => + : status(entry) === "failed" ? () => x : 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 }