From e1967860faf513d3a59fff7255e17e51d4cdd159 Mon Sep 17 00:00:00 2001 From: sawyer-shi Date: Wed, 29 Apr 2026 11:31:12 +0800 Subject: [PATCH 1/3] feat(plugins): add Stop All button to plugin installation tasks Added a Stop All button to the active/installing plugins list and mapped it to the delete_all endpoint to terminate stuck install tasks. Also added task.stopAll localization strings. --- .../plugin-tasks/components/plugin-task-list.tsx | 12 ++++++++++++ .../plugins/plugin-page/plugin-tasks/hooks.ts | 8 ++++++++ .../plugins/plugin-page/plugin-tasks/index.tsx | 8 ++++++++ web/i18n/en-US/plugin.json | 3 ++- web/i18n/zh-Hans/plugin.json | 1 + web/i18n/zh-Hant/plugin.json | 1 + web/service/use-plugins.ts | 8 ++++++++ 7 files changed, 40 insertions(+), 1 deletion(-) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx index cc2eed1dbb..be678df42f 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx @@ -15,6 +15,7 @@ type PluginTaskListProps = { onClearAll: () => void onClearErrors: () => void onClearSingle: (taskId: string, pluginId: string) => void + onStopAll: () => void } const PluginTaskList: FC = ({ @@ -25,6 +26,7 @@ const PluginTaskList: FC = ({ onClearAll, onClearErrors, onClearSingle, + onStopAll, }) => { const { t } = useTranslation() const language = useGetLanguage() @@ -43,6 +45,16 @@ const PluginTaskList: FC = ({ } defaultStatusText={t('task.installingHint', { ns: 'plugin' })} + headerAction={( + + )} /> )} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts index e780072904..00f2cbbb27 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts +++ b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts @@ -5,6 +5,7 @@ import { import { TaskStatus } from '@/app/components/plugins/types' import { useMutationClearTaskPlugin, + useMutationStopAllTaskPlugins, usePluginTaskList, } from '@/service/use-plugins' @@ -14,6 +15,7 @@ export const usePluginTaskStatus = () => { handleRefetch, } = usePluginTaskList() const { mutateAsync } = useMutationClearTaskPlugin() + const { mutateAsync: mutateStopAllAsync } = useMutationStopAllTaskPlugins() const allPlugins = pluginTasks.map(task => task.plugins.map((plugin) => { return { ...plugin, @@ -40,6 +42,11 @@ export const usePluginTaskStatus = () => { }) handleRefetch() }, [mutateAsync, handleRefetch]) + + const handleStopAllPlugins = useCallback(async () => { + await mutateStopAllAsync() + handleRefetch() + }, [mutateStopAllAsync, handleRefetch]) const totalPluginsLength = allPlugins.length const runningPluginsLength = runningPlugins.length const errorPluginsLength = errorPlugins.length @@ -65,5 +72,6 @@ export const usePluginTaskStatus = () => { isSuccess, isFailed, handleClearErrorPlugin, + handleStopAllPlugins, } } diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index 00fcb7e072..775eee2e0a 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -31,6 +31,7 @@ const PluginTasks = () => { isSuccess, isFailed, handleClearErrorPlugin, + handleStopAllPlugins, } = usePluginTaskStatus() const { getIconUrl } = useGetIcon() const canOpenMenu = isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess @@ -81,6 +82,12 @@ const PluginTasks = () => { [clearPluginsAndClose, errorPlugins], ) + + const handleStopAll = useCallback(async () => { + await handleStopAllPlugins() + setOpen(false) + }, [handleStopAllPlugins]) + const handleClearSingle = useCallback( (taskId: string, pluginId: string) => clearPluginsAndClose([{ taskId, plugin_unique_identifier: pluginId }]), [clearPluginsAndClose], @@ -127,6 +134,7 @@ const PluginTasks = () => { onClearAll={handleClearAll} onClearErrors={handleClearErrors} onClearSingle={handleClearSingle} + onStopAll={handleStopAll} /> diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index 85f8b3bbbd..4f7cc3033f 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -248,6 +248,7 @@ "task.installingWithError": "Installing {{installingLength}} plugins, {{successLength}} success, {{errorLength}} failed", "task.installingWithSuccess": "Installing {{installingLength}} plugins, {{successLength}} success.", "task.runningPlugins": "Installing Plugins", + "task.stopAll": "Stop all", "task.successPlugins": "Successfully Installed Plugins", "upgrade.close": "Close", "upgrade.description": "About to install the following plugin", @@ -256,4 +257,4 @@ "upgrade.upgrade": "Install", "upgrade.upgrading": "Installing...", "upgrade.usedInApps": "Used in {{num}} apps" -} +} \ No newline at end of file diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index 5d640b5723..3f0f2de238 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -248,6 +248,7 @@ "task.installingWithError": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功,{{errorLength}} 安装失败", "task.installingWithSuccess": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功", "task.runningPlugins": "正在安装的插件", + "task.stopAll": "停止所有", "task.successPlugins": "安装成功的插件", "upgrade.close": "关闭", "upgrade.description": "即将安装以下插件", diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json index 4e7d577aaa..2af4fe5c6f 100644 --- a/web/i18n/zh-Hant/plugin.json +++ b/web/i18n/zh-Hant/plugin.json @@ -248,6 +248,7 @@ "task.installingWithError": "安裝 {{installingLength}} 個插件,{{successLength}} 成功,{{errorLength}} 失敗", "task.installingWithSuccess": "安裝 {{installingLength}} 個插件,{{successLength}} 成功。", "task.runningPlugins": "Installing Plugins", + "task.stopAll": "停止所有", "task.successPlugins": "Successfully Installed Plugins", "upgrade.close": "關閉", "upgrade.description": "即將安裝以下插件", diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 6004ecde56..0f12c941c5 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -560,6 +560,14 @@ export const useMutationClearTaskPlugin = () => { }) } +export const useMutationStopAllTaskPlugins = () => { + return useMutation({ + mutationFn: () => { + return post<{ success: boolean }>('/workspaces/current/plugin/tasks/delete_all') + }, + }) +} + export const usePluginManifestInfo = (pluginUID: string) => { return useQuery({ enabled: !!pluginUID, From a4c68ee1fa5164f0a73a021973066543dc94c7ff Mon Sep 17 00:00:00 2001 From: sawyer-shi Date: Tue, 5 May 2026 15:58:55 +0800 Subject: [PATCH 2/3] test(plugins): cover stop all plugin tasks --- .../plugin-tasks/__tests__/index.spec.tsx | 53 ++++++++++++++++++- .../__tests__/plugin-task-list.spec.tsx | 51 +++++++----------- .../plugins/plugin-page/plugin-tasks/hooks.ts | 2 +- .../plugin-page/plugin-tasks/index.tsx | 1 - web/i18n/en-US/plugin.json | 2 +- 5 files changed, 71 insertions(+), 38 deletions(-) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 12fc796531..0cc376093b 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginSource, TaskStatus } from '@/app/components/plugins/types' // Import mocked modules -import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins' +import { useMutationClearTaskPlugin, useMutationStopAllTaskPlugins, usePluginTaskList } from '@/service/use-plugins' import PluginTaskList from '../components/plugin-task-list' import TaskStatusIndicator from '../components/task-status-indicator' import { usePluginTaskStatus } from '../hooks' @@ -14,6 +14,7 @@ import PluginTasks from '../index' vi.mock('@/service/use-plugins', () => ({ usePluginTaskList: vi.fn(), useMutationClearTaskPlugin: vi.fn(), + useMutationStopAllTaskPlugins: vi.fn(), })) vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ @@ -45,6 +46,7 @@ const createMockPlugin = (overrides: Partial = {}): PluginStatus = // Helper to setup mock hook returns const setupMocks = (plugins: PluginStatus[] = []) => { const mockMutateAsync = vi.fn().mockResolvedValue({}) + const mockStopAllMutateAsync = vi.fn().mockResolvedValue({}) const mockHandleRefetch = vi.fn() vi.mocked(usePluginTaskList).mockReturnValue({ @@ -58,7 +60,11 @@ const setupMocks = (plugins: PluginStatus[] = []) => { mutateAsync: mockMutateAsync, } as unknown as ReturnType) - return { mockMutateAsync, mockHandleRefetch } + vi.mocked(useMutationStopAllTaskPlugins).mockReturnValue({ + mutateAsync: mockStopAllMutateAsync, + } as unknown as ReturnType) + + return { mockMutateAsync, mockStopAllMutateAsync, mockHandleRefetch } } const getTaskMenuTrigger = () => @@ -273,6 +279,31 @@ describe('usePluginTaskStatus Hook', () => { }) }) }) + + describe('handleStopAllPlugins', () => { + it('should call stop all mutateAsync and handleRefetch', async () => { + const { mockStopAllMutateAsync, mockHandleRefetch } = setupMocks([ + createMockPlugin({ status: TaskStatus.running }), + ]) + + const TestComponent = () => { + const { handleStopAllPlugins } = usePluginTaskStatus() + return ( + + ) + } + + render() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(mockStopAllMutateAsync).toHaveBeenCalledWith() + expect(mockHandleRefetch).toHaveBeenCalled() + }) + }) + }) }) // ============================================================================ @@ -424,6 +455,7 @@ describe('PluginTaskList Component', () => { onClearAll: vi.fn(), onClearErrors: vi.fn(), onClearSingle: vi.fn(), + onStopAll: vi.fn(), } beforeEach(() => { @@ -495,6 +527,23 @@ describe('PluginTaskList Component', () => { expect(handleClearAll).toHaveBeenCalledTimes(1) }) + it('should call onStopAll when stop all button is clicked in running section', () => { + const handleStopAll = vi.fn() + const runningPlugins = [createMockPlugin({ status: TaskStatus.running })] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /task\.stopAll/i })) + + expect(handleStopAll).toHaveBeenCalledTimes(1) + }) + it('should call onClearErrors when clear all button is clicked in error section', () => { const handleClearErrors = vi.fn() const errorPlugins = [createMockPlugin({ status: TaskStatus.failed })] diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx index 07a651e2c5..8044ef9b83 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx @@ -57,6 +57,7 @@ describe('PluginTaskList', () => { onClearAll: vi.fn(), onClearErrors: vi.fn(), onClearSingle: vi.fn(), + onStopAll: vi.fn(), } beforeEach(() => { @@ -180,44 +181,28 @@ describe('PluginTaskList', () => { }) describe('Running section', () => { - it('should not render clear buttons for running plugins', () => { + it('should render stop all button for running plugins', () => { render() - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle - // Running section has no headerAction and no onClearSingle + expect(screen.getByRole('button', { name: /plugin\.task\.stopAll/ })).toBeInTheDocument() expect(screen.queryByText(/plugin\.task\.clearAll/)).not.toBeInTheDocument() }) + it('should call onStopAll when stop all button is clicked', () => { + const onStopAll = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /plugin\.task\.stopAll/ })) + + expect(onStopAll).toHaveBeenCalledTimes(1) + }) + it('should show installing hint as status text', () => { render() diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts index 00f2cbbb27..96508f3696 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts +++ b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts @@ -42,7 +42,7 @@ export const usePluginTaskStatus = () => { }) handleRefetch() }, [mutateAsync, handleRefetch]) - + const handleStopAllPlugins = useCallback(async () => { await mutateStopAllAsync() handleRefetch() diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index 775eee2e0a..5b5455ed88 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -82,7 +82,6 @@ const PluginTasks = () => { [clearPluginsAndClose, errorPlugins], ) - const handleStopAll = useCallback(async () => { await handleStopAllPlugins() setOpen(false) diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index 4f7cc3033f..fbedad1cc5 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -257,4 +257,4 @@ "upgrade.upgrade": "Install", "upgrade.upgrading": "Installing...", "upgrade.usedInApps": "Used in {{num}} apps" -} \ No newline at end of file +} From 6acafc3f527b40f1b07b797d8ed58e3d7e26e9c3 Mon Sep 17 00:00:00 2001 From: sawyer-shi Date: Fri, 8 May 2026 15:30:00 +0800 Subject: [PATCH 3/3] test(plugins): mock stop all task hook Update the new plugin task hook spec so CI web tests include the Stop All mutation mock. --- .../plugin-tasks/__tests__/hooks.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts index 3d5269593d..4efe0ad7e1 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts @@ -4,6 +4,7 @@ import { TaskStatus } from '@/app/components/plugins/types' import { usePluginTaskStatus } from '../hooks' const mockClearTask = vi.fn().mockResolvedValue({}) +const mockStopAllTask = vi.fn().mockResolvedValue({}) const mockRefetch = vi.fn() vi.mock('@/service/use-plugins', () => ({ @@ -28,6 +29,9 @@ vi.mock('@/service/use-plugins', () => ({ useMutationClearTaskPlugin: () => ({ mutateAsync: mockClearTask, }), + useMutationStopAllTaskPlugins: () => ({ + mutateAsync: mockStopAllTask, + }), })) describe('usePluginTaskStatus', () => { @@ -74,4 +78,13 @@ describe('usePluginTaskStatus', () => { }) expect(mockRefetch).toHaveBeenCalled() }) + + it('should handle stop all plugins', async () => { + const { result } = renderHook(() => usePluginTaskStatus()) + + await result.current.handleStopAllPlugins() + + expect(mockStopAllTask).toHaveBeenCalled() + expect(mockRefetch).toHaveBeenCalled() + }) })