fix(desktop): Plugins-page Desktop switch wires the kanban toolset

The Plugins page's Desktop-relevant toggle was UI-only: flipping it never
told the backend, so kanban stayed active regardless. The switch now also
calls setToolsetEnabled('kanban', on, profile) — the same
PUT /api/tools/toolsets/kanban route the Toolsets tab uses — invalidates
the toolsets query cache, and confirms with a toast (en/fr/de/es).

Fixes #96969
Co-authored-by: previous contributors' direction via #101582
This commit is contained in:
Brooklyn Nicholson authored and brooklyn! committed 2026-09-26 16:51:19 -05:00
1 parent 0ebaf162cc
commit cd30fdadf6
8 files changed
+66 -1

No files matched your search

@@ -83,6 +83,15 @@ vi.mock('@/contrib/runtime-loader', async importOriginal => ({
uninstallDiskPlugin: (id: string) => uninstallDiskPlugin(id)
}))
// #96969: a Desktop switch whose feature also ships an agent toolset must
// reach the same PUT /api/tools/toolsets/{name} the Toolsets tab uses.
const setToolsetEnabled = vi.fn(async () => ({ enabled: true, name: 'kanban', ok: true }))
vi.mock('@/api/toolsets', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
setToolsetEnabled: (...args: unknown[]) => setToolsetEnabled(...(args as Parameters<typeof setToolsetEnabled>))
}))
beforeEach(() => {
$pluginRecords.set({})
$agentPlugins.set([])
@@ -93,6 +102,7 @@ beforeEach(() => {
requestGateway.mockReset()
requestGateway.mockImplementation(async () => ({ plugins: $agentPlugins.get() }))
setEnvVar.mockClear()
setToolsetEnabled.mockClear()
})
afterEach(() => {
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { setEnvVar } from '@/api/config'
import { setToolsetEnabled } from '@/api/toolsets'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -11,8 +12,10 @@ import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/p
import { discoverRuntimePlugins, uninstallDiskPlugin } from '@/contrib/runtime-loader'
import type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { DESKTOP_PLUGIN_TOOLSETS } from '@/lib/desktop-toolsets'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Loader2, Monitor, Package, RefreshCw, Trash2 } from '@/lib/icons'
import { queryClient } from '@/lib/query-client'
import { cn } from '@/lib/utils'
import {
$agentPluginBusy,
@@ -41,6 +44,7 @@ import { useDeepLinkHighlight } from '../../settings/use-deep-link-highlight'
import { CatalogAlert } from '../catalog/catalog-alert'
import { CatalogBrowser } from '../catalog/catalog-browser'
import { type CatalogEntry, parseCatalog } from '../catalog/catalog-data'
import { TOOLSETS_QUERY_KEY } from '../toolsets/toolsets-data'
import { mergePluginPackages, type PackageKind, type PluginPackage } from './plugin-packages'
import { PluginSettingsForm } from './plugin-settings-form'
@@ -358,7 +362,33 @@ function PackageRow({
checked={desktopOn}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(desktop.id, on)
void (async () => {
await setPluginEnabled(desktop.id, on)
// #96969: when the feature's agent-side tools live in a
// toolset (the built-in Kanban board has no agent-plugin
// half), flip that opt-in with the panel through the same
// PUT /api/tools/toolsets/{name} the Toolsets tab uses —
// scoped to the profile this page shows, since the toolset
// is per-profile config. Refresh the Toolsets cache so its
// row repaints, and say what happened either way.
const toolset = DESKTOP_PLUGIN_TOOLSETS[desktop.id]
if (!toolset) {
return
}
try {
await setToolsetEnabled(toolset, on, profile)
void queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY })
notify({
kind: 'success',
message: on ? p.toolsetOn(pkg.name, scopeLabel) : p.toolsetOff(pkg.name, scopeLabel)
})
} catch (err) {
notifyError(err, p.toolsetToggleFailed(pkg.name))
}
})()
}}
/>
) : pkg.desktopMissing ? (
+3
View File
@@ -2791,6 +2791,9 @@ export const deOverrides = {
emptyHint: 'Durchsuchen Sie unten den Katalog und installieren Sie ein geprüftes Plugin mit einem Klick.',
loadFailed: 'Agent-Plugins konnten nicht geladen werden',
toggleFailed: name => `${name} konnte nicht umgeschaltet werden`,
toolsetOn: (name: string, profile: string) => `${name}-Agent-Tools für ${profile} aktiviert`,
toolsetOff: (name: string, profile: string) => `${name}-Agent-Tools für ${profile} deaktiviert`,
toolsetToggleFailed: (name: string) => `Die ${name}-Agent-Tools konnten nicht umgeschaltet werden`,
legacyBackend:
'Dieses Backend ist älter als schlüsseladressierte Plugin-Schalter — aktualisieren Sie Hermes, um es hier zu verwalten.',
portableBadge: 'tragbar',
+3
View File
@@ -2487,6 +2487,9 @@ export const en: Translations = {
emptyHint: 'Browse the catalog below and install a reviewed plugin with one click.',
loadFailed: 'Could not load agent plugins',
toggleFailed: (name: string) => `Could not toggle ${name}`,
toolsetOn: (name: string, profile: string) => `${name} agent tools enabled for ${profile}`,
toolsetOff: (name: string, profile: string) => `${name} agent tools disabled for ${profile}`,
toolsetToggleFailed: (name: string) => `Could not toggle the ${name} agent tools`,
legacyBackend: 'This backend predates key-addressed plugin toggles — update Hermes to manage it here.',
portableBadge: 'portable',
serverStates: {
+3
View File
@@ -2782,6 +2782,9 @@ export const esOverrides = {
emptyHint: 'Explora el catálogo de abajo e instala un plugin revisado con un clic.',
loadFailed: 'No se pudieron cargar los plugins del agente',
toggleFailed: (name: string) => `No se pudo cambiar ${name}`,
toolsetOn: (name: string, profile: string) => `Herramientas de agente de ${name} activadas para ${profile}`,
toolsetOff: (name: string, profile: string) => `Herramientas de agente de ${name} desactivadas para ${profile}`,
toolsetToggleFailed: (name: string) => `No se pudieron cambiar las herramientas de agente de ${name}`,
legacyBackend:
'Este backend es anterior a los interruptores de plugins por clave: actualiza Hermes para gestionarlo aquí.',
portableBadge: 'portátil',
+3
View File
@@ -2800,6 +2800,9 @@ export const frOverrides = {
emptyHint: 'Parcourez le catalogue ci-dessous pour installer un plugin vérifié en un clic.',
loadFailed: "Impossible de charger les plugins de l'agent",
toggleFailed: name => `Impossible de modifier l'état de ${name}`,
toolsetOn: (name: string, profile: string) => `Outils agent de ${name} activés pour ${profile}`,
toolsetOff: (name: string, profile: string) => `Outils agent de ${name} désactivés pour ${profile}`,
toolsetToggleFailed: (name: string) => `Impossible de modifier les outils agent de ${name}`,
legacyBackend: 'Ce backend est trop ancien pour gérer les plugins depuis cet écran ; mettez Hermes à jour.',
portableBadge: 'portable',
serverStates: {
+3
View File
@@ -2109,6 +2109,9 @@ export interface Translations {
emptyHint: string
loadFailed: string
toggleFailed: (name: string) => string
toolsetOn: (name: string, profile: string) => string
toolsetOff: (name: string, profile: string) => string
toolsetToggleFailed: (name: string) => string
legacyBackend: string
portableBadge: string
serverStates: {
+10
View File
@@ -22,3 +22,13 @@ const DESKTOP_HIDDEN_TOOLSETS = new Set([
export function isDesktopToolsetVisible(name: string): boolean {
return !DESKTOP_HIDDEN_TOOLSETS.has(name)
}
/** Desktop plugin id → the agent toolset carrying the same feature's
* agent-side tools. The Plugins page's Desktop switch toggles this app's UI
* panel; for these entries it also flips the toolset opt-in through the same
* `PUT /api/tools/toolsets/{name}` the Toolsets tab uses, so the panel is
* never UI-only (#96969). The kanban board is the built-in case: it has no
* agent-plugin package half, its agent tools live in the `kanban` toolset. */
export const DESKTOP_PLUGIN_TOOLSETS: Readonly<Record<string, string>> = {
kanban: 'kanban'
}