From 58644b24c678f0e3d41802dcf84967e151bd6f6a Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 21:26:15 +0300 Subject: [PATCH 1/9] fix(agent-graph): harden pan and launch stepper visibility --- .../agent-graph/src/canvas/draw-agents.ts | 74 ++++++++++++++----- packages/agent-graph/src/ui/GraphControls.tsx | 19 +++-- packages/agent-graph/src/ui/GraphView.tsx | 62 +++++++++++----- .../renderer/ui/GraphProvisioningHud.tsx | 6 +- .../agent-graph/GraphProvisioningHud.test.ts | 19 +++-- 5 files changed, 127 insertions(+), 53 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 9a31800a..ed8db002 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -266,24 +266,49 @@ function drawLaunchStage( ctx.save(); switch (visualState) { case 'waiting': { - const ringR = r + 7 + Math.sin(time * 3.2) * 1.2; - const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const ringR = r + 8 + Math.sin(time * 3.2) * 1.4; + const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const dotOrbit = r + 11; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.5; + ctx.setLineDash([4, 5]); ctx.stroke(); + ctx.setLineDash([]); + for (let index = 0; index < 3; index += 1) { + const angle = time * 1.2 + (Math.PI * 2 * index) / 3; + ctx.beginPath(); + ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72); + ctx.fill(); + } break; } case 'spawning': { const ringR = r + 7; - const rotation = time * 2.4; + const rotation = time * 2.7; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.8; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x, y, ringR + 4, rotation + Math.PI, rotation + Math.PI + Math.PI * 0.4); + ctx.strokeStyle = hexWithAlpha('#fbbf24', 0.65); + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.stroke(); + + const glow = ctx.createRadialGradient(x, y, r * 0.5, x, y, ringR + 12); + glow.addColorStop(0, hexWithAlpha('#f59e0b', 0.18)); + glow.addColorStop(1, hexWithAlpha('#f59e0b', 0)); + ctx.beginPath(); + ctx.arc(x, y, ringR + 12, 0, Math.PI * 2); + ctx.fillStyle = glow; + ctx.fill(); break; } case 'runtime_pending': { @@ -291,27 +316,37 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48); - ctx.lineWidth = 1.75; + ctx.lineWidth = 1.9; + ctx.setLineDash([5, 4]); ctx.stroke(); + ctx.setLineDash([]); - const orbit = time * 1.6; - const dotR = 2.2; - const dotX = x + Math.cos(orbit) * ringR; - const dotY = y + Math.sin(orbit) * ringR; - ctx.beginPath(); - ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2); - ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9); - ctx.fill(); + const orbit = time * 1.8; + for (let index = 0; index < 2; index += 1) { + const angle = orbit + Math.PI * index; + const dotX = x + Math.cos(angle) * ringR; + const dotY = y + Math.sin(angle) * ringR; + ctx.beginPath(); + ctx.arc(dotX, dotY, 2.3, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha(index === 0 ? '#67e8f9' : '#38bdf8', 0.92); + ctx.fill(); + } break; } case 'settling': { const ringR = r + 6; - const arc = 0.65 + 0.08 * Math.sin(time * 2.2); + const arc = 0.72 + 0.08 * Math.sin(time * 2.2); const rotation = time * 1.25; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.18); + ctx.lineWidth = 1.4; + ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -321,9 +356,14 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.4; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x + ringR * 0.52, y - ringR * 0.5, 2.2, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#f87171', 0.92); + ctx.fill(); break; } } diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index a3bc581d..801b4a8b 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -49,6 +49,7 @@ export interface GraphControlsProps { teamColor?: string; isAlive?: boolean; topToolbarContent?: React.ReactNode; + interactionLocked?: boolean; } const TOPBAR_BUTTON_SIZE = 25; @@ -69,6 +70,7 @@ export function GraphControls({ isSidebarVisible = true, teamColor, topToolbarContent, + interactionLocked = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -104,6 +106,9 @@ export function GraphControls({ }, [isSettingsOpen]); const nameColor = teamColor ?? '#aaeeff'; + const chromeInteractivityClass = interactionLocked + ? 'pointer-events-none select-none' + : 'pointer-events-auto'; return ( <> @@ -111,7 +116,7 @@ export function GraphControls({
{onToggleSidebar ? (
{topToolbarContent ? ( -
+
{topToolbarContent}
) : null} @@ -175,7 +180,7 @@ export function GraphControls({
-
+
(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [interactionLocked, setInteractionLocked] = useState(false); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -136,6 +137,7 @@ export function GraphView({ color?: string | null; } | null>(null); const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); + const activePrimaryInteractionRef = useRef(false); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -280,6 +282,15 @@ export function GraphView({ selectionLockRef.current = null; }, []); + const setInteractionGuards = useCallback( + (active: boolean) => { + activePrimaryInteractionRef.current = active; + setInteractionLocked(active); + setInteractionSelectionDisabled(active); + }, + [setInteractionSelectionDisabled] + ); + const animate = useCallback(() => { if (!runningRef.current) return; @@ -413,6 +424,7 @@ export function GraphView({ dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; + setInteractionGuards(false); }, [interaction, isSurfaceActive, simulation]); const handleWheel = useCallback( @@ -432,11 +444,11 @@ export function GraphView({ if (e.button !== 0) return; // only left click e.preventDefault(); dragPreviewRef.current = null; - setInteractionSelectionDisabled(true); + setInteractionGuards(true); const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); return; } const rect = canvas.getBoundingClientRect(); @@ -482,13 +494,14 @@ export function GraphView({ getVisibleNodes, interaction, markUserInteracted, + setInteractionGuards, simulation.stateRef, ] ); const processActivePointerMove = useCallback( - (clientX: number, clientY: number, buttons: number) => { - if ((buttons & 1) === 0) { + (clientX: number, clientY: number) => { + if (!activePrimaryInteractionRef.current) { dragPreviewRef.current = null; return false; } @@ -545,7 +558,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; - setInteractionSelectionDisabled(false); + setInteractionGuards(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -556,7 +569,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { const nearest = simulation.resolveNearestOwnerSlot( @@ -585,7 +598,7 @@ export function GraphView({ return; } - setInteractionSelectionDisabled(false); + setInteractionGuards(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -624,12 +637,12 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { - if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) { + if (processActivePointerMove(e.clientX, e.clientY)) { return; } @@ -678,11 +691,8 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { - if ((event.buttons & 1) === 0) { - setInteractionSelectionDisabled(false); - return; - } if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && @@ -691,30 +701,47 @@ export function GraphView({ return; } event.preventDefault(); - processActivePointerMove(event.clientX, event.clientY, event.buttons); + processActivePointerMove(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && !edgeMouseDownRef.current ) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); return; } completePointerInteraction(event.clientX, event.clientY); }; + const clearInteraction = (): void => { + if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { + return; + } + interaction.handleMouseUp(); + camera.handlePanEnd(); + isPanningRef.current = false; + edgeMouseDownRef.current = null; + dragPreviewRef.current = null; + setInteractionGuards(false); + }; + window.addEventListener('mousemove', handleWindowMouseMove); window.addEventListener('mouseup', handleWindowMouseUp); + window.addEventListener('blur', clearInteraction); + window.addEventListener('dragstart', clearInteraction); return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); - setInteractionSelectionDisabled(false); + window.removeEventListener('blur', clearInteraction); + window.removeEventListener('dragstart', clearInteraction); + setInteractionGuards(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); + }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -911,6 +938,7 @@ export function GraphView({ teamColor={data.teamColor} isAlive={data.isAlive} topToolbarContent={renderTopToolbarContent?.()} + interactionLocked={interactionLocked} /> {renderHud ? ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 06dc290a..34574126 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -34,7 +34,7 @@ const HUD_STEPPER_STYLE: CSSProperties = { }; function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean { - return presentation != null; + return presentation != null && (presentation.isActive || presentation.isFailed); } export interface GraphProvisioningHudProps { @@ -80,8 +80,10 @@ export const GraphProvisioningHud = ({ <>
- {plugin.isInstalled && ( + {installSummaryLabel && ( - Installed + {installSummaryLabel} )} diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index ab8d94e9..7584fa74 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -2,7 +2,7 @@ * PluginsPanel — search, filter, sort and browse the plugin catalog. */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -142,6 +142,12 @@ export const PluginsPanel = ({ [catalog, selectedPluginId] ); + useEffect(() => { + if (selectedPluginId && !loading && !selectedPlugin) { + setSelectedPluginId(null); + } + }, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]); + const sortValue = `${pluginSort.field}:${pluginSort.order}`; const activeFilterCount = pluginFilters.categories.length + diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 44b0dbc1..7684925a 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -3,11 +3,12 @@ */ import type { + CliInstallationStatus, InstallScope, InstalledPluginEntry, PluginCapability, PluginCatalogItem, -} from '@shared/types/extensions'; +} from '@shared/types'; /** * Normalize a repository URL for dedup comparison. @@ -109,6 +110,61 @@ export function hasInstallationInScope( return installations.some((installation) => installation.scope === scope); } +/** + * Build a concise install-status label for plugin badges. + */ +export function getInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + if (scopes.length === 0) { + return null; + } + + if (scopes.length > 1) { + return `Installed in ${scopes.length} scopes`; + } + + switch (scopes[0]) { + case 'user': + return 'Installed globally'; + case 'project': + return 'Installed in project'; + case 'local': + return 'Installed locally'; + default: + return 'Installed'; + } +} + +/** + * Install actions require Claude auth, but uninstall only requires a working CLI. + */ +export function getExtensionActionDisableReason(options: { + isInstalled: boolean; + cliStatus: Pick | null; + cliStatusLoading: boolean; +}): string | null { + const { isInstalled, cliStatus, cliStatusLoading } = options; + if (cliStatusLoading) { + return 'Checking Claude CLI status...'; + } + + if (cliStatus === null) { + return 'Checking Claude CLI availability...'; + } + + if (cliStatus.installed === false) { + return 'Claude CLI required. Install it from the Dashboard.'; + } + + if (!isInstalled && !cliStatus.authLoggedIn) { + return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'; + } + + return null; +} + /** * Sanitize an MCP server display name into a CLI-safe server name. * Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService. diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index b2cc7347..7905fd99 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -5,7 +5,9 @@ import type { PluginCatalogItem } from '@shared/types/extensions'; import { buildPluginId, formatInstallCount, + getExtensionActionDisableReason, getCapabilityLabel, + getInstallationSummaryLabel, getPrimaryCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -168,6 +170,61 @@ describe('hasInstallationInScope', () => { }); }); +describe('getInstallationSummaryLabel', () => { + it('returns null when there are no installations', () => { + expect(getInstallationSummaryLabel([])).toBeNull(); + }); + + it('describes a single global installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'user' }])).toBe('Installed globally'); + }); + + it('describes a single project installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'project' }])).toBe('Installed in project'); + }); + + it('summarizes multiple scopes without pretending they are global', () => { + expect( + getInstallationSummaryLabel([ + { scope: 'project' }, + { scope: 'user' }, + ]), + ).toBe('Installed in 2 scopes'); + }); +}); + +describe('getExtensionActionDisableReason', () => { + it('requires auth only for install actions', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('not signed in'); + }); + + it('allows uninstall when CLI is present but auth is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toBeNull(); + }); + + it('still blocks actions when the CLI is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: false, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('Claude CLI required'); + }); +}); + describe('sanitizeMcpServerName', () => { it('lowercases and replaces spaces with dashes', () => { expect(sanitizeMcpServerName('My Server')).toBe('my-server'); From afad52f506f86ac84f1618cb6c77cef99651fc25 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:01:59 +0300 Subject: [PATCH 5/9] fix(extensions): prevent stale plugin catalog races --- .../extensions/plugins/PluginDetailDialog.tsx | 6 ++ .../extensions/plugins/PluginsPanel.tsx | 6 ++ src/renderer/store/slices/extensionsSlice.ts | 47 ++++++++++++---- test/renderer/store/extensionsSlice.test.ts | 55 +++++++++++++++++++ 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 9d501e81..74d76b76 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -87,6 +87,12 @@ export const PluginDetailDialog = ({ } }, [plugin, open, fetchPluginReadme]); + useEffect(() => { + if (open) { + setScope('user'); + } + }, [open, plugin?.pluginId]); + useEffect(() => { if (scope === 'project' && !projectScopeAvailable) { setScope('user'); diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 7584fa74..5480b7c8 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -148,6 +148,12 @@ export const PluginsPanel = ({ } }, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]); + useEffect(() => { + if (error && selectedPluginId) { + setSelectedPluginId(null); + } + }, [error, selectedPluginId, setSelectedPluginId]); + const sortValue = `${pluginSort.field}:${pluginSort.order}`; const activeFilterCount = pluginFilters.categories.length + diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index abea1a2f..e18437ff 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -127,7 +127,8 @@ export interface ExtensionsSlice { // Slice Creator // ============================================================================= -let pluginFetchInFlight: Promise | null = null; +let pluginFetchInFlight: { key: string; promise: Promise } | null = null; +let pluginCatalogRequestSeq = 0; let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -140,6 +141,10 @@ function hasAnyLoading(loadingMap: Record): boolean { return Object.values(loadingMap).some(Boolean); } +function getPluginCatalogKey(projectPath?: string): string { + return projectPath ?? '__user__'; +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -205,34 +210,52 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const requestKey = getPluginCatalogKey(projectPath); // Dedup concurrent requests - if (pluginFetchInFlight && !forceRefresh) { - await pluginFetchInFlight; + if (pluginFetchInFlight && !forceRefresh && pluginFetchInFlight.key === requestKey) { + await pluginFetchInFlight.promise; return; } + const requestSeq = ++pluginCatalogRequestSeq; set({ pluginCatalogLoading: true, pluginCatalogError: null }); const promise = (async () => { try { const result = await api.plugins!.getAll(projectPath, forceRefresh); - set({ - pluginCatalog: result, - pluginCatalogLoading: false, - pluginCatalogProjectPath: projectPath ?? null, + set(() => { + if (requestSeq !== pluginCatalogRequestSeq) { + return {}; + } + + return { + pluginCatalog: result, + pluginCatalogLoading: false, + pluginCatalogError: null, + pluginCatalogProjectPath: projectPath ?? null, + }; }); } catch (err) { - set({ - pluginCatalogLoading: false, - pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', + set(() => { + if (requestSeq !== pluginCatalogRequestSeq) { + return {}; + } + + return { + pluginCatalogLoading: false, + pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', + pluginCatalogProjectPath: projectPath ?? null, + }; }); } finally { - pluginFetchInFlight = null; + if (pluginFetchInFlight?.promise === promise) { + pluginFetchInFlight = null; + } } })(); - pluginFetchInFlight = promise; + pluginFetchInFlight = { key: requestKey, promise }; await promise; }, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 3240c33a..278f61e1 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -166,6 +166,61 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogError).toBe('boom'); expect(store.getState().pluginCatalogLoading).toBe(false); }); + + it('dedups concurrent requests for the same project key', async () => { + let resolveFetch!: (plugins: EnrichedPlugin[]) => void; + const inFlight = new Promise((resolve) => { + resolveFetch = resolve; + }); + (api.plugins!.getAll as ReturnType).mockImplementation(() => inFlight); + + const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + + expect(api.plugins!.getAll).toHaveBeenCalledTimes(1); + + resolveFetch([makePlugin({ pluginId: 'same@m' })]); + await Promise.all([firstFetch, secondFetch]); + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-a'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual(['same@m']); + }); + + it('keeps the newest project catalog when project changes mid-flight', async () => { + let resolveProjectA!: (plugins: EnrichedPlugin[]) => void; + let resolveProjectB!: (plugins: EnrichedPlugin[]) => void; + const projectAFetch = new Promise((resolve) => { + resolveProjectA = resolve; + }); + const projectBFetch = new Promise((resolve) => { + resolveProjectB = resolve; + }); + + (api.plugins!.getAll as ReturnType) + .mockImplementationOnce(() => projectAFetch) + .mockImplementationOnce(() => projectBFetch); + + const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); + const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(api.plugins!.getAll).toHaveBeenCalledTimes(2); + + resolveProjectB([makePlugin({ pluginId: 'project-b@m' })]); + await secondFetch; + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ + 'project-b@m', + ]); + + resolveProjectA([makePlugin({ pluginId: 'project-a@m' })]); + await firstFetch; + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ + 'project-b@m', + ]); + }); }); describe('fetchPluginReadme', () => { From 09b5b4626f1a4497c08f2caab9d158b7221cf12c Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:04:02 +0300 Subject: [PATCH 6/9] fix(extensions): tighten plugin catalog fallback state --- src/renderer/store/slices/extensionsSlice.ts | 16 +++++++++++--- test/renderer/store/extensionsSlice.test.ts | 23 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index e18437ff..9d21f833 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -237,15 +237,19 @@ export const createExtensionsSlice: StateCreator { + set((prev) => { if (requestSeq !== pluginCatalogRequestSeq) { return {}; } + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; + return { + pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], pluginCatalogLoading: false, pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', - pluginCatalogProjectPath: projectPath ?? null, + pluginCatalogProjectPath: nextProjectPath, }; }); } finally { @@ -263,7 +267,13 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; const state = get(); - if (pluginId in state.pluginReadmes || state.pluginReadmeLoading[pluginId]) return; + const cachedReadme = state.pluginReadmes[pluginId]; + if ( + (cachedReadme !== undefined && cachedReadme !== null) || + state.pluginReadmeLoading[pluginId] + ) { + return; + } set((prev) => ({ pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true }, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 278f61e1..906c89b9 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -167,6 +167,20 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogLoading).toBe(false); }); + it('clears stale catalog when a different project fetch fails', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + }); + (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalog).toEqual([]); + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginCatalogError).toBe('boom'); + }); + it('dedups concurrent requests for the same project key', async () => { let resolveFetch!: (plugins: EnrichedPlugin[]) => void; const inFlight = new Promise((resolve) => { @@ -243,6 +257,15 @@ describe('extensionsSlice', () => { expect(api.plugins!.getReadme).not.toHaveBeenCalled(); }); + + it('retries README fetch when the cached value is null', () => { + store.setState({ pluginReadmes: { 'test@m': null } }); + (api.plugins!.getReadme as ReturnType).mockResolvedValue(null); + + store.getState().fetchPluginReadme('test@m'); + + expect(api.plugins!.getReadme).toHaveBeenCalledWith('test@m'); + }); }); describe('mcpBrowse', () => { From 560174d98c1356ea3c48074f847e18a88e120710 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:05:28 +0300 Subject: [PATCH 7/9] fix(extensions): reset stale plugin action state on project switch --- src/renderer/store/slices/extensionsSlice.ts | 54 +++++++++++++++++++- test/renderer/store/extensionsSlice.test.ts | 46 +++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 9d21f833..cf6a8687 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -145,6 +145,36 @@ function getPluginCatalogKey(projectPath?: string): string { return projectPath ?? '__user__'; } +function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { + return new Set(catalog.map((plugin) => plugin.pluginId)); +} + +function clearPluginOperationState( + pluginIds: Set, + pluginInstallProgress: Record, + installErrors: Record +): { + pluginInstallProgress: Record; + installErrors: Record; +} { + if (pluginIds.size === 0) { + return { pluginInstallProgress, installErrors }; + } + + const nextPluginInstallProgress = { ...pluginInstallProgress }; + const nextInstallErrors = { ...installErrors }; + + for (const pluginId of pluginIds) { + delete nextPluginInstallProgress[pluginId]; + delete nextInstallErrors[pluginId]; + } + + return { + pluginInstallProgress: nextPluginInstallProgress, + installErrors: nextInstallErrors, + }; +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -224,16 +254,29 @@ export const createExtensionsSlice: StateCreator { try { const result = await api.plugins!.getAll(projectPath, forceRefresh); - set(() => { + set((prev) => { if (requestSeq !== pluginCatalogRequestSeq) { return {}; } + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; + const pluginIdsToClear = isSameProjectContext + ? new Set() + : new Set([...buildPluginIdSet(prev.pluginCatalog), ...buildPluginIdSet(result)]); + const nextOperationState = clearPluginOperationState( + pluginIdsToClear, + prev.pluginInstallProgress, + prev.installErrors + ); + return { pluginCatalog: result, pluginCatalogLoading: false, pluginCatalogError: null, - pluginCatalogProjectPath: projectPath ?? null, + pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, }; }); } catch (err) { @@ -244,12 +287,19 @@ export const createExtensionsSlice: StateCreator() : buildPluginIdSet(prev.pluginCatalog), + prev.pluginInstallProgress, + prev.installErrors + ); return { pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], pluginCatalogLoading: false, pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, }; }); } finally { diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 906c89b9..aa5eac58 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -181,6 +181,30 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogError).toBe('boom'); }); + it('clears plugin operation state when switching project context', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([ + makePlugin({ pluginId: 'project-b@m' }), + ]); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); + expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); + }); + it('dedups concurrent requests for the same project key', async () => { let resolveFetch!: (plugins: EnrichedPlugin[]) => void; const inFlight = new Promise((resolve) => { @@ -235,6 +259,28 @@ describe('extensionsSlice', () => { 'project-b@m', ]); }); + + it('clears plugin operation state when a different project fetch fails', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + + expect(store.getState().pluginCatalog).toEqual([]); + expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); + }); }); describe('fetchPluginReadme', () => { From 2b8062dfa3f3f5b35d76b67d417c1c3af596a264 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:07:22 +0300 Subject: [PATCH 8/9] fix(extensions): cancel stale plugin success timers --- src/renderer/store/slices/extensionsSlice.ts | 60 ++++++++++++++++---- test/renderer/store/extensionsSlice.test.ts | 42 ++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index cf6a8687..6c207740 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -129,6 +129,7 @@ export interface ExtensionsSlice { let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; +const pluginSuccessResetTimers = new Map>(); let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -175,6 +176,42 @@ function clearPluginOperationState( }; } +function clearPluginSuccessResetTimer(pluginId: string): void { + const timer = pluginSuccessResetTimers.get(pluginId); + if (!timer) { + return; + } + + clearTimeout(timer); + pluginSuccessResetTimers.delete(pluginId); +} + +function clearPluginSuccessResetTimers(pluginIds: Set): void { + for (const pluginId of pluginIds) { + clearPluginSuccessResetTimer(pluginId); + } +} + +function schedulePluginSuccessReset( + pluginId: string, + set: Parameters>[0] +): void { + clearPluginSuccessResetTimer(pluginId); + const timer = setTimeout(() => { + pluginSuccessResetTimers.delete(pluginId); + set((prev) => { + if (prev.pluginInstallProgress[pluginId] !== 'success') { + return {}; + } + + return { + pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, + }; + }); + }, SUCCESS_DISPLAY_MS); + pluginSuccessResetTimers.set(pluginId, timer); +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -269,6 +306,7 @@ export const createExtensionsSlice: StateCreator() : buildPluginIdSet(prev.pluginCatalog) + ); return { pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], @@ -679,6 +720,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, @@ -686,6 +728,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, installErrors: { ...prev.installErrors, [request.pluginId]: '' }, @@ -711,13 +754,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(request.pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(request.pluginId); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, @@ -735,6 +774,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, @@ -742,6 +782,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, })); @@ -763,12 +804,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(pluginId); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index aa5eac58..5d9479eb 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -142,6 +142,7 @@ describe('extensionsSlice', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -490,6 +491,25 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).toContain('active project'); }); + + it('clears older success reset timers before a new operation on the same plugin', async () => { + vi.useFakeTimers(); + store.setState({ cliStatus: makeReadyCliStatus() }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType) + .mockResolvedValueOnce({ state: 'success' }) + .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + }); }); describe('uninstallPlugin', () => { @@ -524,6 +544,28 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).toContain('active project'); }); + + it('does not restore idle state after project switch clears a pending success timer', async () => { + vi.useFakeTimers(); + store.setState({ + pluginCatalogProjectPath: '/tmp/project-a', + pluginCatalog: [makePlugin({ pluginId: 'test@m' })], + }); + (api.plugins!.getAll as ReturnType) + .mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })]) + .mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('test@m', 'user'); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + }); }); describe('installMcpServer', () => { From 45021524270611d4b7b4448827117ce0b19f5a42 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:09:52 +0300 Subject: [PATCH 9/9] fix(extensions): support local plugin scope actions --- .../install/PluginInstallService.ts | 12 +++-- .../extensions/plugins/PluginDetailDialog.tsx | 11 ++--- src/renderer/store/slices/extensionsSlice.ts | 10 ++--- src/shared/types/extensions/plugin.ts | 2 +- .../extensions/PluginInstallService.test.ts | 44 ++++++++++++++++++ test/renderer/store/extensionsSlice.test.ts | 45 +++++++++++++++++++ 6 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index 4a3a7711..0b994f9f 100644 --- a/src/main/services/extensions/install/PluginInstallService.ts +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -26,6 +26,10 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']); const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos const UNINSTALL_TIMEOUT_MS = 30_000; +function scopeRequiresProjectPath(scope?: string): boolean { + return scope === 'project' || scope === 'local'; +} + export class PluginInstallService { constructor(private readonly catalogService: PluginCatalogService) {} @@ -48,10 +52,10 @@ export class PluginInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project-scoped plugin installs', + error: `projectPath is required for ${scope}-scoped plugin installs`, }; } @@ -130,10 +134,10 @@ export class PluginInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project-scoped plugin uninstalls', + error: `projectPath is required for ${scope}-scoped plugin uninstalls`, }; } diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 74d76b76..8d6e05ac 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -48,7 +48,8 @@ interface PluginDetailDialogProps { const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, + { value: 'project', label: 'Project (shared)' }, + { value: 'local', label: 'Local (gitignored)' }, ]; export const PluginDetailDialog = ({ @@ -94,7 +95,7 @@ export const PluginDetailDialog = ({ }, [open, plugin?.pluginId]); useEffect(() => { - if (scope === 'project' && !projectScopeAvailable) { + if (scope !== 'user' && !projectScopeAvailable) { setScope('user'); } }, [projectScopeAvailable, scope]); @@ -186,7 +187,7 @@ export const PluginDetailDialog = ({ {opt.label} @@ -201,7 +202,7 @@ export const PluginDetailDialog = ({ installPlugin({ pluginId: plugin.pluginId, scope, - ...(scope === 'project' && pluginCatalogProjectPath + ...(scope !== 'user' && pluginCatalogProjectPath ? { projectPath: pluginCatalogProjectPath } : {}), }) @@ -210,7 +211,7 @@ export const PluginDetailDialog = ({ uninstallPlugin( plugin.pluginId, scope, - scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined + scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined ) } size="default" diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 6c207740..7932eeeb 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -225,7 +225,7 @@ const CLI_HEALTHCHECK_FAILED_MESSAGE = const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = - 'Project-scoped plugins require an active project in the Extensions tab.'; + 'Project- and local-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( set, @@ -688,7 +688,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, diff --git a/src/shared/types/extensions/plugin.ts b/src/shared/types/extensions/plugin.ts index 698f800b..5ddb4efc 100644 --- a/src/shared/types/extensions/plugin.ts +++ b/src/shared/types/extensions/plugin.ts @@ -70,7 +70,7 @@ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] { export interface PluginInstallRequest { pluginId: string; // canonical key — main resolves qualifiedName from catalog scope: InstallScope; - projectPath?: string; // required for 'project' scope + projectPath?: string; // required for repo-scoped installs ('project' or 'local') } // ── Filters (renderer-only concern) ──────────────────────────────────────── diff --git a/test/main/services/extensions/PluginInstallService.test.ts b/test/main/services/extensions/PluginInstallService.test.ts index b88c1b1e..8223d06d 100644 --- a/test/main/services/extensions/PluginInstallService.test.ts +++ b/test/main/services/extensions/PluginInstallService.test.ts @@ -85,6 +85,22 @@ describe('PluginInstallService', () => { ); }); + it('adds local scope flag and cwd for local installs', async () => { + mockExecCli.mockResolvedValue({ stdout: '', stderr: '' }); + + await service.install({ + pluginId: 'context7', + scope: 'local', + projectPath: '/tmp/test-project', + }); + + expect(mockExecCli).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['plugin', 'install', '-s', 'local', 'context7@claude-plugins-official'], + expect.objectContaining({ cwd: '/tmp/test-project' }), + ); + }); + it('returns error if plugin not found in catalog', async () => { catalog = createMockCatalog({ resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], @@ -129,6 +145,14 @@ describe('PluginInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope when projectPath is missing', async () => { + const result = await service.install({ pluginId: 'context7', scope: 'local' }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('local-scoped'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── uninstall ─────────────────────────────────────────────────────────────── @@ -159,6 +183,18 @@ describe('PluginInstallService', () => { ); }); + it('adds scope flag for local scope', async () => { + mockExecCli.mockResolvedValue({ stdout: '', stderr: '' }); + + await service.uninstall('context7', 'local', '/tmp/test-project'); + + expect(mockExecCli).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['plugin', 'uninstall', '-s', 'local', 'context7@claude-plugins-official'], + expect.objectContaining({ cwd: '/tmp/test-project' }), + ); + }); + it('returns error if plugin not in catalog', async () => { catalog = createMockCatalog({ resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'], @@ -187,5 +223,13 @@ describe('PluginInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope when projectPath is missing', async () => { + const result = await service.uninstall('context7', 'local'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('local-scoped'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 5d9479eb..058b32d6 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -492,6 +492,32 @@ describe('extensionsSlice', () => { expect(store.getState().installErrors['project@m']).toContain('active project'); }); + it('fills missing projectPath for local scope from the active Extensions project context', async () => { + store.setState({ + cliStatus: makeReadyCliStatus(), + pluginCatalogProjectPath: '/tmp/project-a', + }); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); + + expect(api.plugins!.install).toHaveBeenCalledWith({ + pluginId: 'local@m', + scope: 'local', + projectPath: '/tmp/project-a', + }); + }); + + it('fails fast for local scope when there is no active project path', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); + + await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); + + expect(api.plugins!.install).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); + expect(store.getState().installErrors['local@m']).toContain('active project'); + }); + it('clears older success reset timers before a new operation on the same plugin', async () => { vi.useFakeTimers(); store.setState({ cliStatus: makeReadyCliStatus() }); @@ -545,6 +571,25 @@ describe('extensionsSlice', () => { expect(store.getState().installErrors['project@m']).toContain('active project'); }); + it('fills missing projectPath for local uninstall from the active Extensions project context', async () => { + store.setState({ pluginCatalogProjectPath: '/tmp/project-a' }); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('local@m', 'local'); + + expect(api.plugins!.uninstall).toHaveBeenCalledWith('local@m', 'local', '/tmp/project-a'); + }); + + it('fails fast for local uninstall when there is no active project path', async () => { + store.setState({ pluginCatalogProjectPath: null }); + + await store.getState().uninstallPlugin('local@m', 'local'); + + expect(api.plugins!.uninstall).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); + expect(store.getState().installErrors['local@m']).toContain('active project'); + }); + it('does not restore idle state after project switch clears a pending success timer', async () => { vi.useFakeTimers(); store.setState({