From 1d2e33f5d941c937a83dc64bf567f1b124a0dafa Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 23:13:22 +0300 Subject: [PATCH] fix(extensions): preserve skill detail refresh context --- .../extensions/skills/SkillsPanel.tsx | 22 +- .../extensions/skills/SkillsPanel.test.ts | 235 ++++++++++++++++++ 2 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 test/renderer/components/extensions/skills/SkillsPanel.test.ts diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index f2fd8792..faf7e193 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -25,6 +25,7 @@ import { SearchInput } from '../common/SearchInput'; import { SkillDetailDialog } from './SkillDetailDialog'; import { SkillEditorDialog } from './SkillEditorDialog'; import { SkillImportDialog } from './SkillImportDialog'; +import { resolveSkillProjectPath } from './skillProjectUtils'; import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState'; import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions'; @@ -109,6 +110,7 @@ export const SkillsPanel = ({ const [successMessage, setSuccessMessage] = useState(null); const [highlightedSkillId, setHighlightedSkillId] = useState(null); const selectedSkillIdRef = useRef(selectedSkillId); + const selectedSkillItemRef = useRef(null); selectedSkillIdRef.current = selectedSkillId; const mergedSkills = useMemo( @@ -116,6 +118,9 @@ export const SkillsPanel = ({ [projectSkills, userSkills] ); const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null; + selectedSkillItemRef.current = selectedSkillId + ? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null) + : null; useEffect(() => { if (!selectedSkillId) return; @@ -155,10 +160,19 @@ export const SkillsPanel = ({ if (!shouldRefresh) return; void fetchSkillsCatalog(projectPath ?? undefined); - if (selectedSkillIdRef.current) { - void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined).catch( - () => undefined - ); + const selectedSkillId = selectedSkillIdRef.current; + const selectedSkillItem = selectedSkillItemRef.current; + if (selectedSkillId) { + void fetchSkillDetail( + selectedSkillId, + selectedSkillItem + ? resolveSkillProjectPath( + selectedSkillItem.scope, + projectPath, + selectedSkillItem.projectRoot + ) + : (projectPath ?? undefined) + ).catch(() => undefined); } }); diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts new file mode 100644 index 00000000..b78e559d --- /dev/null +++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts @@ -0,0 +1,235 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SkillCatalogItem } from '@shared/types/extensions'; + +interface StoreState { + fetchSkillsCatalog: ReturnType; + fetchSkillDetail: ReturnType; + skillsCatalogLoadingByProjectPath: Record; + skillsCatalogErrorByProjectPath: Record; + skillsDetailsById: Record; + skillsUserCatalog: SkillCatalogItem[]; + skillsProjectCatalogByProjectPath: Record; +} + +const storeState = {} as StoreState; +const startWatchingMock = vi.fn(); +const stopWatchingMock = vi.fn(); +const onChangedMock = vi.fn(); +let skillsChangedHandler: ((event: { + scope: 'user' | 'project'; + projectPath: string | null; + path: string; + type: 'create' | 'change' | 'delete'; +}) => void) | null = null; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +vi.mock('@renderer/api', () => ({ + api: { + skills: { + startWatching: (...args: unknown[]) => startWatchingMock(...args), + stopWatching: (...args: unknown[]) => stopWatchingMock(...args), + onChanged: (...args: unknown[]) => onChangedMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + variant?: string; + size?: string; + className?: string; + }>) => + React.createElement( + 'button', + { + type, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/popover', () => ({ + Popover: ({ children }: React.PropsWithChildren<{ open?: boolean; onOpenChange?: (open: boolean) => void }>) => + React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: React.PropsWithChildren) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ + SearchInput: ({ + value, + onChange, + placeholder, + }: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + }) => + React.createElement('input', { + value, + placeholder, + onChange: (event: React.ChangeEvent) => onChange(event.target.value), + }), +})); + +vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({ + SkillDetailDialog: () => null, +})); + +vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({ + SkillEditorDialog: () => null, +})); + +vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({ + SkillImportDialog: () => null, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + AlertTriangle: Icon, + ArrowUpAZ: Icon, + ArrowUpDown: Icon, + BookOpen: Icon, + Check: Icon, + CheckCircle2: Icon, + Clock3: Icon, + Download: Icon, + Plus: Icon, + Search: Icon, + }; +}); + +import { SkillsPanel } from '@renderer/components/extensions/skills/SkillsPanel'; + +function makeUserSkill(): SkillCatalogItem { + return { + id: '/Users/me/.claude/skills/review-helper', + sourceType: 'filesystem', + name: 'Review Helper', + description: 'Helps with code review', + folderName: 'review-helper', + scope: 'user', + rootKind: 'claude', + projectRoot: null, + discoveryRoot: '/Users/me/.claude/skills', + skillDir: '/Users/me/.claude/skills/review-helper', + skillFile: '/Users/me/.claude/skills/review-helper/SKILL.md', + metadata: {}, + invocationMode: 'auto', + flags: { + hasScripts: false, + hasReferences: false, + hasAssets: false, + }, + isValid: true, + issues: [], + modifiedAt: 1, + }; +} + +describe('SkillsPanel', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined); + storeState.fetchSkillDetail = vi.fn().mockResolvedValue(undefined); + storeState.skillsCatalogLoadingByProjectPath = {}; + storeState.skillsCatalogErrorByProjectPath = {}; + storeState.skillsDetailsById = {}; + storeState.skillsUserCatalog = [makeUserSkill()]; + storeState.skillsProjectCatalogByProjectPath = { + '/tmp/project-a': [], + }; + startWatchingMock.mockReset(); + stopWatchingMock.mockReset(); + onChangedMock.mockReset(); + skillsChangedHandler = null; + startWatchingMock.mockResolvedValue('watch-1'); + onChangedMock.mockImplementation((handler: typeof skillsChangedHandler) => { + skillsChangedHandler = handler; + return () => { + skillsChangedHandler = null; + }; + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('refetches personal skill details without forcing the current project path', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const skill = storeState.skillsUserCatalog[0]!; + + await act(async () => { + root.render( + React.createElement(SkillsPanel, { + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + skillsSearchQuery: '', + setSkillsSearchQuery: vi.fn(), + skillsSort: 'name-asc', + setSkillsSort: vi.fn(), + selectedSkillId: skill.id, + setSelectedSkillId: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(startWatchingMock).toHaveBeenCalledWith('/tmp/project-a'); + expect(skillsChangedHandler).not.toBeNull(); + + await act(async () => { + skillsChangedHandler?.({ + scope: 'user', + projectPath: null, + path: `${skill.skillDir}/SKILL.md`, + type: 'change', + }); + await Promise.resolve(); + }); + + expect(storeState.fetchSkillsCatalog).toHaveBeenCalledWith('/tmp/project-a'); + expect(storeState.fetchSkillDetail).toHaveBeenCalledWith(skill.id, undefined); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +});