diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 42d96ecd88..0ff5535d05 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -27,6 +27,7 @@ import { useTheme } from "@/composables/useTheme"; import { canDownloadAndInstallUpdate, useAppUpdater } from "@/composables/useAppUpdater"; import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge"; import { useComponentUpdates, type ComponentUpdateCategory } from "@/composables/useComponentUpdates"; +import { notifyComponentPluginsUpdated } from "@/lib/updates/componentUpdateEvents"; import { driverStoreUpdateBadgeCount, showMcpUpdateBadge } from "@/lib/updates/updateBadges"; import { markPendingComponentUpdatesAfterAppUpdate, resolveUpdateAllAction, runPendingComponentUpdatePlan, shouldCloseUpdateCenterAfterComponentUpdate, takePendingComponentUpdatesAfterAppRestart, type PendingComponentUpdatePlan } from "@/lib/updates/componentUpdateOrchestration"; import { isUpdatePreviewMockEnabled } from "@/lib/updates/updatePreviewMock"; @@ -314,7 +315,7 @@ const activeAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().lengt /** Runs waiting for a write confirmation — the panel-entry badge shows these * with a higher-priority indicator (parent PRD §4 line 71 / §9). */ const awaitingAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().filter((run) => run.status === "awaiting_write_confirmation").length : 0)); -const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged } = useMcpUpdateBadge({ +const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, applyMcpStatus } = useMcpUpdateBadge({ isDesktop, // Update availability remains visible when every auto-update switch is off; // the switches control installation, not whether the user can be reminded. @@ -1206,7 +1207,8 @@ async function checkAllUpdates() { manualCheckingAllUpdates.value = true; const startedAt = Date.now(); try { - await Promise.allSettled([checkUpdates({ silent: true }), componentUpdates.refresh()]); + const [, componentRefresh] = await Promise.allSettled([checkUpdates({ silent: true }), componentUpdates.refresh()]); + if (componentRefresh.status === "fulfilled" && componentRefresh.value) syncToolbarComponentUpdateState(); const remaining = 500 - (Date.now() - startedAt); if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining)); } finally { @@ -1221,13 +1223,19 @@ function openDriverStoreFromUpdate(target?: DriverStoreTab) { function handleToolbarUpdateClick() { showUpdateDialog.value = true; - if (!toolbarHasUpdateAvailable.value && !checkingAllUpdates.value) void checkAllUpdates(); + if (!checkingAllUpdates.value) void checkAllUpdates(); +} + +function syncToolbarComponentUpdateState() { + agentDriverUpdateCount.value = componentUpdates.driverUpdateCount.value; + applyMcpStatus(componentUpdates.mcpUpdateAvailable.value); } function reportComponentUpdateResult(result: Awaited>) { const updatedComponents = [result.drivers > 0 ? t("settings.updateDrivers") : "", result.jdbc ? t("settings.updateJdbc") : "", result.mcp ? t("settings.updateMcp") : "", result.plugins > 0 ? t("settings.updatePlugins") : ""].filter(Boolean); - // Only a clean refresh is authoritative; a failed registry check must not clear the toolbar count. - if (result.failed.length === 0) agentDriverUpdateCount.value = componentUpdates.driverUpdateCount.value; + // Only a clean refresh is authoritative; a failed registry check must not clear stale toolbar state. + if (result.failed.length === 0) syncToolbarComponentUpdateState(); + if (result.plugins > 0) notifyComponentPluginsUpdated(); if (updatedComponents.length) toast(t("updates.componentsAutoUpdated", { components: updatedComponents.join(t("updates.componentListSeparator")) })); if (result.skippedDrivers > 0) toast(t("updates.componentsAutoUpdateSkipped"), 6000); if (result.failed.length) toast(t("updates.componentsAutoUpdateFailed", { count: result.failed.length }), 6000); diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index 061574d89a..1441761120 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -19,6 +19,7 @@ import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments"; import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin"; import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, marketplaceHomepageUrl, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace"; import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch"; +import { COMPONENT_PLUGINS_UPDATED_EVENT } from "@/lib/updates/componentUpdateEvents"; import { formatBytes } from "@/lib/database/serverMetrics"; import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -245,6 +246,19 @@ async function refreshAfterBatch() { } } +async function refreshAfterExternalPluginUpdate() { + clearPluginIconCache(); + try { + installedPlugins.value = await api.listPlugins(); + } catch (cause) { + error.value = cause instanceof Error ? cause.message : String(cause); + } +} + +function handleComponentPluginsUpdated() { + void refreshAfterExternalPluginUpdate(); +} + function toggleBatchMode() { if (batchRunning.value) return; batchMode.value = !batchMode.value; @@ -737,10 +751,12 @@ watch(allowUnsigned, (value) => { }); onMounted(() => { void refresh(); + window.addEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, handleComponentPluginsUpdated); if (isTauriRuntime()) document.addEventListener("dbx:tauri-file-drop", onTauriPluginDrop); }); watch(marketplaceViewMode, (mode) => safeLocalStorageSet(MARKETPLACE_VIEW_MODE_STORAGE_KEY, mode)); onBeforeUnmount(() => { + window.removeEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, handleComponentPluginsUpdated); if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop); }); diff --git a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts index 7932f35642..35dc385681 100644 --- a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts +++ b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts @@ -5,6 +5,7 @@ import { readFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database"; import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace"; +import { COMPONENT_PLUGINS_UPDATED_EVENT } from "@/lib/updates/componentUpdateEvents"; const mocks = vi.hoisted(() => ({ listPlugins: vi.fn(), @@ -299,6 +300,19 @@ describe("PluginContributionsPanel installed plugin pin controls", () => { }); }); +describe("PluginContributionsPanel external component updates", () => { + it("refreshes installed plugins when the update center updates plugins", async () => { + expect(state.marketplaceListings.every((listing) => listing.status === "update")).toBe(true); + mocks.listPlugins.mockResolvedValueOnce([installed("a", "3.0.0"), installed("b", "3.0.0"), installed("c", "3.0.0")]); + + window.dispatchEvent(new Event(COMPONENT_PLUGINS_UPDATED_EVENT)); + await flushUi(); + + expect(mocks.listPlugins).toHaveBeenCalledOnce(); + expect(state.marketplaceListings.every((listing) => listing.status === "installed")).toBe(true); + }); +}); + const batches = ["install", "uninstall"] as const; type Batch = (typeof batches)[number]; const singles = ["marketplace", "uninstall", "rollback", "package", "url"] as const; diff --git a/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts b/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts index a0ab921827..a9566a2ca6 100644 --- a/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts +++ b/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts @@ -87,14 +87,17 @@ describe("useComponentUpdates", () => { expect(updates.totalUpdateCount.value).toBe(4); }); - it("clears the driver count after a successful update and refresh", async () => { + it("clears driver and MCP update state after a successful update and refresh", async () => { mocks.listInstalledAgents.mockResolvedValueOnce([{ db_type: "mysql", update_available: true }]).mockResolvedValueOnce([{ db_type: "mysql", update_available: false }]); + mocks.checkMcpServerStatus.mockResolvedValueOnce(mcpStatus).mockResolvedValueOnce({ ...mcpStatus, latest_version: "1.0.0", update_available: false }); const updates = useComponentUpdates({ isDesktop: true }); - await updates.installCategories(["drivers"]); + await updates.installCategories(["drivers", "mcp"]); expect(mocks.upgradeAllAgents).toHaveBeenCalledOnce(); + expect(mocks.installMcpServer).toHaveBeenCalledOnce(); expect(updates.driverUpdateCount.value).toBe(0); + expect(updates.mcpUpdateAvailable.value).toBe(false); }); it("installs every enabled component category after an app update", async () => { diff --git a/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts b/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts index f7ac7720c1..7e9d247530 100644 --- a/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts +++ b/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts @@ -26,6 +26,23 @@ describe("plugin center integration", () => { expect(driverStoreSource).not.toContain("PluginContributionsPanel"); }); + it("refreshes toolbar update state after manual and component update checks", () => { + expect(appSource).toContain('if (componentRefresh.status === "fulfilled" && componentRefresh.value) syncToolbarComponentUpdateState()'); + expect(appSource).toContain("if (result.failed.length === 0) syncToolbarComponentUpdateState()"); + expect(appSource).toContain("function syncToolbarComponentUpdateState()"); + }); + + it("refreshes all update sources in the background whenever the update center opens", () => { + expect(appSource).toContain("function handleToolbarUpdateClick() {\n showUpdateDialog.value = true;\n if (!checkingAllUpdates.value) void checkAllUpdates();\n}"); + }); + + it("refreshes the plugin center after component-level plugin updates", () => { + expect(appSource).toContain("notifyComponentPluginsUpdated()"); + expect(pluginCenterSource).toContain("COMPONENT_PLUGINS_UPDATED_EVENT"); + expect(pluginCenterSource).toContain("window.addEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, handleComponentPluginsUpdated)"); + expect(pluginCenterSource).toContain("installedPlugins.value = await api.listPlugins()"); + }); + it("formats structured backend errors for local and URL package installs", () => { const localInstall = pluginCenterSource.slice(pluginCenterSource.indexOf("async function installPlugin("), pluginCenterSource.indexOf("function isHttpPackageUrl(")); const urlInstall = pluginCenterSource.slice(pluginCenterSource.indexOf("async function installPluginFromUrl("), pluginCenterSource.indexOf("const urlProgressPercent")); diff --git a/apps/desktop/src/lib/updates/__tests__/componentUpdateEvents.spec.ts b/apps/desktop/src/lib/updates/__tests__/componentUpdateEvents.spec.ts new file mode 100644 index 0000000000..4e2924f1fc --- /dev/null +++ b/apps/desktop/src/lib/updates/__tests__/componentUpdateEvents.spec.ts @@ -0,0 +1,16 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; +import { COMPONENT_PLUGINS_UPDATED_EVENT, notifyComponentPluginsUpdated } from "@/lib/updates/componentUpdateEvents"; + +describe("component update events", () => { + it("notifies plugin surfaces after component-level plugin updates", () => { + const listener = vi.fn(); + window.addEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, listener); + + notifyComponentPluginsUpdated(); + + expect(listener).toHaveBeenCalledOnce(); + window.removeEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, listener); + }); +}); diff --git a/apps/desktop/src/lib/updates/componentUpdateEvents.ts b/apps/desktop/src/lib/updates/componentUpdateEvents.ts new file mode 100644 index 0000000000..26ffa2dd9e --- /dev/null +++ b/apps/desktop/src/lib/updates/componentUpdateEvents.ts @@ -0,0 +1,5 @@ +export const COMPONENT_PLUGINS_UPDATED_EVENT = "dbx:component-plugins-updated"; + +export function notifyComponentPluginsUpdated(): void { + window.dispatchEvent(new Event(COMPONENT_PLUGINS_UPDATED_EVENT)); +}