diff --git a/apps/desktop/src/app/capabilities/plugins/plugins-tab.test.tsx b/apps/desktop/src/app/capabilities/plugins/plugins-tab.test.tsx index 172357d1fe..39af34ff3f 100644 --- a/apps/desktop/src/app/capabilities/plugins/plugins-tab.test.tsx +++ b/apps/desktop/src/app/capabilities/plugins/plugins-tab.test.tsx @@ -8,6 +8,7 @@ import { $pluginRecords } from '@/contrib/plugins-store' import { queryClient } from '@/lib/query-client' import { $agentPlugins, $agentPluginsStatus, type AgentPluginRow } from '@/store/agent-plugins' import { $confirmRequest, settleConfirm } from '@/store/confirm' +import { $notifications } from '@/store/notifications' import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request' import { $connection } from '@/store/session' @@ -85,10 +86,12 @@ vi.mock('@/contrib/runtime-loader', async importOriginal => ({ // #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 })) +const setToolsetEnabled = vi.fn(async (..._args: unknown[]) => ({ enabled: true, name: 'kanban', ok: true })) +const getToolsets = vi.fn(async (..._args: unknown[]): Promise<{ enabled: boolean; name: string }[]> => []) vi.mock('@/api/toolsets', async importOriginal => ({ ...(await importOriginal>()), + getToolsets: (...args: unknown[]) => getToolsets(...args), setToolsetEnabled: (...args: unknown[]) => setToolsetEnabled(...(args as Parameters)) })) @@ -102,7 +105,10 @@ beforeEach(() => { requestGateway.mockReset() requestGateway.mockImplementation(async () => ({ plugins: $agentPlugins.get() })) setEnvVar.mockClear() - setToolsetEnabled.mockClear() + setToolsetEnabled.mockReset() + setToolsetEnabled.mockResolvedValue({ enabled: true, name: 'kanban', ok: true }) + getToolsets.mockReset() + getToolsets.mockResolvedValue([]) }) afterEach(() => { @@ -421,6 +427,160 @@ describe('PluginsTab', () => { sha: entry.sha }) }) + + // #96969: the Desktop Kanban switch enabled only this app's UI panel; the + // agent-side kanban toolset stayed off, so the board existed but the agent + // had no kanban tools. The switch must flip both, scoped to the profile the + // page shows (the toolset is per-profile config), and refresh the Toolsets + // tab's cache so its row repaints. + it('enables the kanban agent toolset when the Desktop panel is switched on', async () => { + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + // Panel currently off: clicking the switch turns it on. + $pluginRecords.set({ + kanban: { id: 'kanban', name: 'Kanban', kind: 'bundled', status: 'disabled' } + }) + + renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' }) + + screen.getByRole('switch', { name: 'Desktop: Kanban' }).click() + + await waitFor(() => { + expect(setToolsetEnabled).toHaveBeenCalledWith('kanban', true, 'workbot') + }) + expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['toolsets-list'] })) + invalidate.mockRestore() + }) + + it('disables the kanban agent toolset when the Desktop panel is switched off', async () => { + // Panel currently on: clicking the switch turns it off. + $pluginRecords.set({ + kanban: { id: 'kanban', name: 'Kanban', kind: 'bundled', status: 'loaded' } + }) + + renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' }) + + screen.getByRole('switch', { name: 'Desktop: Kanban' }).click() + + await waitFor(() => { + expect(setToolsetEnabled).toHaveBeenCalledWith('kanban', false, 'workbot') + }) + }) + + describe('Desktop Kanban switch when the toolset write fails', () => { + // A real loader handle, so the switch's `checked` (desktop.status) and the + // persisted decision genuinely move when the panel is toggled. + const publishKanban = (status: 'disabled' | 'loaded') => + publishPlugin( + { id: 'kanban', name: 'Kanban', kind: 'bundled', status }, + { activate: () => patchPlugin('kanban', { status: 'loaded' }), deactivate: () => undefined } + ) + + const kanbanSwitch = () => screen.getByRole('switch', { name: 'Desktop: Kanban' }) + const checked = () => kanbanSwitch().getAttribute('aria-checked') + const notices = (kind: string) => $notifications.get().filter(n => n.kind === kind) + const toolsetReads = (enabled: boolean) => [{ enabled, name: 'kanban' }] + + // Resolves once the write attempt (and any re-read) has settled. + const settled = () => waitFor(() => expect(kanbanSwitch().disabled).toBe(false)) + + beforeEach(() => { + $pluginDecisions.set({}) + $notifications.set([]) + window.localStorage.clear() + }) + + afterEach(() => dropPlugin('kanban')) + + it('keeps panel and tools off when enabling is rejected, then retries', async () => { + publishKanban('disabled') + setToolsetEnabled.mockRejectedValueOnce(new Error('HTTP 500')) + getToolsets.mockResolvedValueOnce(toolsetReads(false)) + + renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' }) + fireEvent.click(kanbanSwitch()) + await waitFor(() => expect(notices('error')).toHaveLength(1)) + await settled() + + expect(checked()).toBe('false') + expect($pluginRecords.get().kanban.status).toBe('disabled') + expect($pluginDecisions.get()).not.toHaveProperty('kanban') + expect(window.localStorage.getItem('hermes.desktop.pluginDecisions.v2')).toBeNull() + expect(notices('success')).toHaveLength(0) + + // Same intended value again, backend healthy: both halves turn on. + fireEvent.click(kanbanSwitch()) + await waitFor(() => expect(checked()).toBe('true')) + + expect(setToolsetEnabled).toHaveBeenLastCalledWith('kanban', true, 'workbot') + expect($pluginDecisions.get().kanban).toBe(true) + expect(notices('success')).toHaveLength(1) + }) + + it('keeps panel and tools on when disabling is rejected, then retries', async () => { + publishKanban('loaded') + setToolsetEnabled.mockRejectedValueOnce(new Error('HTTP 500')) + getToolsets.mockResolvedValueOnce(toolsetReads(true)) + + renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' }) + fireEvent.click(kanbanSwitch()) + await waitFor(() => expect(notices('error')).toHaveLength(1)) + await settled() + + expect(checked()).toBe('true') + expect($pluginRecords.get().kanban.status).toBe('loaded') + expect($pluginDecisions.get()).not.toHaveProperty('kanban') + expect(notices('success')).toHaveLength(0) + + fireEvent.click(kanbanSwitch()) + await waitFor(() => expect(checked()).toBe('false')) + + expect(setToolsetEnabled).toHaveBeenLastCalledWith('kanban', false, 'workbot') + expect($pluginDecisions.get().kanban).toBe(false) + expect(notices('success')).toHaveLength(1) + }) + + it('follows the backend when a timed-out write actually committed', async () => { + publishKanban('disabled') + setToolsetEnabled.mockRejectedValueOnce(new Error('Request timed out')) + getToolsets.mockResolvedValueOnce(toolsetReads(true)) + + renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' }) + fireEvent.click(kanbanSwitch()) + await waitFor(() => expect(checked()).toBe('true')) + + expect(getToolsets).toHaveBeenCalledWith('workbot') + expect($pluginDecisions.get().kanban).toBe(true) + expect(notices('error')).toHaveLength(0) + }) + + it('leaves the panel alone and stays retryable when the outcome is unknown', async () => { + publishKanban('loaded') + setToolsetEnabled.mockRejectedValueOnce(new Error('Request timed out')) + getToolsets.mockRejectedValueOnce(new Error('Request timed out')) + + renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' }) + fireEvent.click(kanbanSwitch()) + await waitFor(() => expect(notices('error')).toHaveLength(1)) + await settled() + + expect(checked()).toBe('true') + expect($pluginRecords.get().kanban.status).toBe('loaded') + expect($pluginDecisions.get()).not.toHaveProperty('kanban') + expect(notices('success')).toHaveLength(0) + }) + }) + + it('leaves toolsets alone for desktop plugins with no agent toolset', () => { + $pluginRecords.set({ + media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded' } + }) + + renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' }) + + screen.getByRole('switch', { name: 'Desktop: Media Studio' }).click() + + expect(setToolsetEnabled).not.toHaveBeenCalled() + }) }) describe('PluginsTab catalog UX', () => { diff --git a/apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx b/apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx index a84ab646fe..43ce9086f5 100644 --- a/apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx +++ b/apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx @@ -2,7 +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 { getToolsets, 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' @@ -229,6 +229,7 @@ function PackageRow({ const settingsFields = agent?.settings_schema ?? [] const hasSettings = Boolean(agent?.key) && settingsFields.length > 0 const [settingsOpen, setSettingsOpen] = useState(false) + const [desktopBusy, setDesktopBusy] = useState(false) const desktopOn = desktop ? desktop.status !== 'disabled' : false const agentOn = agent?.status === 'enabled' const agentToggleable = Boolean(agent?.key) @@ -245,6 +246,55 @@ function PackageRow({ // package installed on a remote backend can never materialize here (#114079). const remoteBackend = useStore($connection)?.mode === 'remote' + // #96969: when the feature's agent-side tools live in a toolset (the + // built-in Kanban board has no agent-plugin half), the Desktop switch flips + // that per-profile opt-in with the panel, through the same + // PUT /api/tools/toolsets/{name} the Toolsets tab uses. The backend write + // goes first and the panel follows what the backend actually holds, so a + // failed write can't leave the two halves disagreeing. A rejected PUT may + // still have committed (a timeout leaves it unknown), so re-read instead of + // assuming a rollback; if even that fails the panel stays put and the + // switch is live again for a retry. + const toggleDesktop = async (id: string, on: boolean) => { + const toolset = DESKTOP_PLUGIN_TOOLSETS[id] + + if (!toolset) { + return setPluginEnabled(id, on) + } + + setDesktopBusy(true) + + try { + let toolsetOn: boolean | undefined + + try { + await setToolsetEnabled(toolset, on, profile) + toolsetOn = on + } catch (err) { + toolsetOn = await getToolsets(profile).then( + list => list.find(row => row.name === toolset)?.enabled, + () => undefined + ) + + if (toolsetOn !== on) { + notifyError(err, p.toolsetToggleFailed(pkg.name)) + } + } + + void queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY }) + + if (toolsetOn === on) { + await setPluginEnabled(id, on) + notify({ + kind: 'success', + message: on ? p.toolsetOn(pkg.name, scopeLabel) : p.toolsetOff(pkg.name, scopeLabel) + }) + } + } finally { + setDesktopBusy(false) + } + } + return ( <>
{ triggerHaptic('selection') - - 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)) - } - })() + void toggleDesktop(desktop.id, on) }} /> ) : pkg.desktopMissing ? ( diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts index 871095b03f..699db10273 100644 --- a/apps/desktop/src/i18n/de.ts +++ b/apps/desktop/src/i18n/de.ts @@ -2793,7 +2793,8 @@ export const deOverrides = { 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`, + toolsetToggleFailed: (name: string) => + `Die ${name}-Agent-Tools konnten nicht umgeschaltet werden; das Desktop-Panel bleibt unverändert`, legacyBackend: 'Dieses Backend ist älter als schlüsseladressierte Plugin-Schalter — aktualisieren Sie Hermes, um es hier zu verwalten.', portableBadge: 'tragbar', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f6e4690160..a869419674 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2489,7 +2489,8 @@ export const en: Translations = { 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`, + toolsetToggleFailed: (name: string) => + `Could not toggle the ${name} agent tools; the Desktop panel was left unchanged`, legacyBackend: 'This backend predates key-addressed plugin toggles — update Hermes to manage it here.', portableBadge: 'portable', serverStates: { diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts index bf59474885..6cd14544d5 100644 --- a/apps/desktop/src/i18n/es.ts +++ b/apps/desktop/src/i18n/es.ts @@ -2784,7 +2784,8 @@ export const esOverrides = { 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}`, + toolsetToggleFailed: (name: string) => + `No se pudieron cambiar las herramientas de agente de ${name}; el panel de Escritorio no se modificó`, legacyBackend: 'Este backend es anterior a los interruptores de plugins por clave: actualiza Hermes para gestionarlo aquí.', portableBadge: 'portátil', diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts index 10a02827c3..8ff030d8d5 100644 --- a/apps/desktop/src/i18n/fr.ts +++ b/apps/desktop/src/i18n/fr.ts @@ -2802,7 +2802,8 @@ export const frOverrides = { 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}`, + toolsetToggleFailed: (name: string) => + `Impossible de modifier les outils agent de ${name} ; le panneau Desktop reste inchangé`, legacyBackend: 'Ce backend est trop ancien pour gérer les plugins depuis cet écran ; mettez Hermes à jour.', portableBadge: 'portable', serverStates: {