fix(extensions): gate codex skill overlays by runtime
This commit is contained in:
parent
8423656b97
commit
14a38212c2
7 changed files with 264 additions and 11 deletions
|
|
@ -52,6 +52,7 @@ interface SkillEditorDialogProps {
|
||||||
mode: EditorMode;
|
mode: EditorMode;
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
projectLabel: string | null;
|
projectLabel: string | null;
|
||||||
|
allowCodexRootKind: boolean;
|
||||||
detail: SkillDetail | null;
|
detail: SkillDetail | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (skillId: string | null) => void;
|
onSaved: (skillId: string | null) => void;
|
||||||
|
|
@ -70,6 +71,7 @@ export const SkillEditorDialog = ({
|
||||||
mode,
|
mode,
|
||||||
projectPath,
|
projectPath,
|
||||||
projectLabel,
|
projectLabel,
|
||||||
|
allowCodexRootKind,
|
||||||
detail,
|
detail,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -220,7 +222,7 @@ export const SkillEditorDialog = ({
|
||||||
setReviewLoading(false);
|
setReviewLoading(false);
|
||||||
setSaveLoading(false);
|
setSaveLoading(false);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
}, [detail, mode, open, projectPath]);
|
}, [allowCodexRootKind, detail, mode, open, projectPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -240,6 +242,12 @@ export const SkillEditorDialog = ({
|
||||||
}
|
}
|
||||||
}, [mode, open, projectPath, scope]);
|
}, [mode, open, projectPath, scope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) {
|
||||||
|
setRootKind('claude');
|
||||||
|
}
|
||||||
|
}, [allowCodexRootKind, mode, open, rootKind]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rawContentRef.current = rawContent;
|
rawContentRef.current = rawContent;
|
||||||
}, [rawContent]);
|
}, [rawContent]);
|
||||||
|
|
@ -291,6 +299,14 @@ export const SkillEditorDialog = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const canUseProjectScope = Boolean(projectPath);
|
const canUseProjectScope = Boolean(projectPath);
|
||||||
|
const visibleRootDefinitions = useMemo(
|
||||||
|
() =>
|
||||||
|
SKILL_ROOT_DEFINITIONS.filter(
|
||||||
|
(definition) =>
|
||||||
|
definition.rootKind !== 'codex' || allowCodexRootKind || detail?.item.rootKind === 'codex'
|
||||||
|
),
|
||||||
|
[allowCodexRootKind, detail?.item.rootKind]
|
||||||
|
);
|
||||||
const instructionsLocked = manualRawEdit || customMarkdownDetected;
|
const instructionsLocked = manualRawEdit || customMarkdownDetected;
|
||||||
const title = mode === 'create' ? 'Create skill' : 'Edit skill';
|
const title = mode === 'create' ? 'Create skill' : 'Edit skill';
|
||||||
const descriptionText =
|
const descriptionText =
|
||||||
|
|
@ -436,7 +452,7 @@ export const SkillEditorDialog = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SKILL_ROOT_DEFINITIONS.map((definition) => (
|
{visibleRootDefinitions.map((definition) => (
|
||||||
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
||||||
{definition.directoryName}
|
{definition.directoryName}
|
||||||
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ interface SkillImportDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
projectLabel: string | null;
|
projectLabel: string | null;
|
||||||
|
allowCodexRootKind: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImported: (skillId: string | null) => void;
|
onImported: (skillId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +65,7 @@ export const SkillImportDialog = ({
|
||||||
open,
|
open,
|
||||||
projectPath,
|
projectPath,
|
||||||
projectLabel,
|
projectLabel,
|
||||||
|
allowCodexRootKind,
|
||||||
onClose,
|
onClose,
|
||||||
onImported,
|
onImported,
|
||||||
}: SkillImportDialogProps): React.JSX.Element => {
|
}: SkillImportDialogProps): React.JSX.Element => {
|
||||||
|
|
@ -120,6 +122,16 @@ export const SkillImportDialog = ({
|
||||||
}
|
}
|
||||||
}, [open, projectPath, scope]);
|
}, [open, projectPath, scope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && rootKind === 'codex' && !allowCodexRootKind) {
|
||||||
|
setRootKind('claude');
|
||||||
|
}
|
||||||
|
}, [allowCodexRootKind, open, rootKind]);
|
||||||
|
|
||||||
|
const visibleRootDefinitions = SKILL_ROOT_DEFINITIONS.filter(
|
||||||
|
(definition) => definition.rootKind !== 'codex' || allowCodexRootKind
|
||||||
|
);
|
||||||
|
|
||||||
async function handleChooseFolder(): Promise<void> {
|
async function handleChooseFolder(): Promise<void> {
|
||||||
const selected = await api.config.selectFolders();
|
const selected = await api.config.selectFolders();
|
||||||
const first = selected[0];
|
const first = selected[0];
|
||||||
|
|
@ -280,7 +292,7 @@ export const SkillImportDialog = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SKILL_ROOT_DEFINITIONS.map((definition) => (
|
{visibleRootDefinitions.map((definition) => (
|
||||||
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
<SelectItem key={definition.rootKind} value={definition.rootKind}>
|
||||||
{definition.directoryName}
|
{definition.directoryName}
|
||||||
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
{definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
formatSkillRootKind,
|
formatSkillRootKind,
|
||||||
getSkillAudience,
|
getSkillAudience,
|
||||||
getSkillAudienceLabel,
|
getSkillAudienceLabel,
|
||||||
|
isCodexSkillOverlayAvailable,
|
||||||
} from '@shared/utils/skillRoots';
|
} from '@shared/utils/skillRoots';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -126,11 +127,16 @@ export const SkillsPanel = ({
|
||||||
() => [...projectSkills, ...userSkills],
|
() => [...projectSkills, ...userSkills],
|
||||||
[projectSkills, userSkills]
|
[projectSkills, userSkills]
|
||||||
);
|
);
|
||||||
|
const codexSkillOverlayAvailable = useMemo(
|
||||||
|
() => isCodexSkillOverlayAvailable(cliStatus),
|
||||||
|
[cliStatus]
|
||||||
|
);
|
||||||
const codexOnlySkillsCount = useMemo(
|
const codexOnlySkillsCount = useMemo(
|
||||||
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
|
() => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length,
|
||||||
[mergedSkills]
|
[mergedSkills]
|
||||||
);
|
);
|
||||||
const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount;
|
const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount;
|
||||||
|
const showCodexOnlyUi = codexSkillOverlayAvailable || codexOnlySkillsCount > 0;
|
||||||
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
|
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
|
||||||
selectedSkillItemRef.current = selectedSkillId
|
selectedSkillItemRef.current = selectedSkillId
|
||||||
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
|
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
|
||||||
|
|
@ -266,8 +272,10 @@ export const SkillsPanel = ({
|
||||||
</p>
|
</p>
|
||||||
<p className="max-w-2xl text-xs leading-5 text-text-muted">
|
<p className="max-w-2xl text-xs leading-5 text-text-muted">
|
||||||
Use personal skills for habits you want everywhere. Use project skills for workflows
|
Use personal skills for habits you want everywhere. Use project skills for workflows
|
||||||
that only make sense inside one codebase. Use `.codex` when a skill should stay
|
that only make sense inside one codebase.
|
||||||
Codex-only.
|
{codexSkillOverlayAvailable
|
||||||
|
? ' Use `.codex` when a skill should stay Codex-only.'
|
||||||
|
: ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -348,9 +356,11 @@ export const SkillsPanel = ({
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
{sharedSkillsCount} shared
|
{sharedSkillsCount} shared
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="font-normal">
|
{showCodexOnlyUi && (
|
||||||
{codexOnlySkillsCount} Codex only
|
<Badge variant="secondary" className="font-normal">
|
||||||
</Badge>
|
{codexOnlySkillsCount} Codex only
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -363,7 +373,9 @@ export const SkillsPanel = ({
|
||||||
['project', 'Project'],
|
['project', 'Project'],
|
||||||
['personal', 'Personal'],
|
['personal', 'Personal'],
|
||||||
['shared', 'Shared'],
|
['shared', 'Shared'],
|
||||||
['codex-only', 'Codex only'],
|
...(showCodexOnlyUi
|
||||||
|
? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][])
|
||||||
|
: []),
|
||||||
['needs-attention', 'Needs attention'],
|
['needs-attention', 'Needs attention'],
|
||||||
['has-scripts', 'Has scripts'],
|
['has-scripts', 'Has scripts'],
|
||||||
] as [SkillsQuickFilter, string][]
|
] as [SkillsQuickFilter, string][]
|
||||||
|
|
@ -616,6 +628,7 @@ export const SkillsPanel = ({
|
||||||
mode="create"
|
mode="create"
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
|
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||||
detail={null}
|
detail={null}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onSaved={(skillId) => {
|
onSaved={(skillId) => {
|
||||||
|
|
@ -631,6 +644,7 @@ export const SkillsPanel = ({
|
||||||
mode="edit"
|
mode="edit"
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
|
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||||
detail={editingDetail}
|
detail={editingDetail}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
|
|
@ -648,6 +662,7 @@ export const SkillsPanel = ({
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
|
allowCodexRootKind={codexSkillOverlayAvailable}
|
||||||
onClose={() => setImportOpen(false)}
|
onClose={() => setImportOpen(false)}
|
||||||
onImported={(skillId) => {
|
onImported={(skillId) => {
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
|
import {
|
||||||
|
getCliProviderExtensionCapability,
|
||||||
|
isCliExtensionCapabilityAvailable,
|
||||||
|
} from './providerExtensionCapabilities';
|
||||||
|
|
||||||
import type { TeamProviderId } from '@shared/types';
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
import type { CliInstallationStatus } from '@shared/types';
|
||||||
import type { SkillRootKind } from '@shared/types/extensions';
|
import type { SkillRootKind } from '@shared/types/extensions';
|
||||||
|
|
||||||
export type SkillAudience = 'shared' | 'codex';
|
export type SkillAudience = 'shared' | 'codex';
|
||||||
|
|
@ -59,3 +65,20 @@ export function isSkillAvailableForProvider(
|
||||||
): boolean {
|
): boolean {
|
||||||
return getSkillAudience(rootKind) === 'shared' || providerId === 'codex';
|
return getSkillAudience(rootKind) === 'shared' || providerId === 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCodexSkillOverlayAvailable(
|
||||||
|
cliStatus: Pick<CliInstallationStatus, 'flavor' | 'providers'> | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (cliStatus?.flavor !== 'agent_teams_orchestrator') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexProvider = cliStatus.providers.find((provider) => provider.providerId === 'codex');
|
||||||
|
if (!codexProvider?.supported) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCliExtensionCapabilityAvailable(
|
||||||
|
getCliProviderExtensionCapability(codexProvider, 'skills')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,20 @@ function makeDetail(rawContent: string): SkillDetail {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeCodexDetail(rawContent: string): SkillDetail {
|
||||||
|
const detail = makeDetail(rawContent);
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
item: {
|
||||||
|
...detail.item,
|
||||||
|
rootKind: 'codex',
|
||||||
|
discoveryRoot: '/tmp/project/.codex/skills',
|
||||||
|
skillDir: '/tmp/project/.codex/skills/custom-skill',
|
||||||
|
skillFile: '/tmp/project/.codex/skills/custom-skill/SKILL.md',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('SkillEditorDialog', () => {
|
describe('SkillEditorDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
|
@ -214,6 +228,7 @@ This file uses a freeform layout without generated sections.
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
projectPath: '/tmp/project',
|
projectPath: '/tmp/project',
|
||||||
projectLabel: 'Project',
|
projectLabel: 'Project',
|
||||||
|
allowCodexRootKind: true,
|
||||||
detail,
|
detail,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onSaved: vi.fn(),
|
onSaved: vi.fn(),
|
||||||
|
|
@ -272,6 +287,7 @@ This file uses a freeform layout without generated sections.
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
projectPath: '/tmp/project',
|
projectPath: '/tmp/project',
|
||||||
projectLabel: 'Project',
|
projectLabel: 'Project',
|
||||||
|
allowCodexRootKind: true,
|
||||||
detail: null,
|
detail: null,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onSaved: vi.fn(),
|
onSaved: vi.fn(),
|
||||||
|
|
@ -298,6 +314,7 @@ This file uses a freeform layout without generated sections.
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
projectPath: '/tmp/project',
|
projectPath: '/tmp/project',
|
||||||
projectLabel: 'Project',
|
projectLabel: 'Project',
|
||||||
|
allowCodexRootKind: true,
|
||||||
detail: null,
|
detail: null,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onSaved: vi.fn(),
|
onSaved: vi.fn(),
|
||||||
|
|
@ -326,6 +343,7 @@ This file uses a freeform layout without generated sections.
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
projectPath: '/tmp/project',
|
projectPath: '/tmp/project',
|
||||||
projectLabel: 'Project',
|
projectLabel: 'Project',
|
||||||
|
allowCodexRootKind: true,
|
||||||
detail: null,
|
detail: null,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onSaved: vi.fn(),
|
onSaved: vi.fn(),
|
||||||
|
|
@ -360,4 +378,74 @@ This file uses a freeform layout without generated sections.
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides the codex root option in create mode when codex runtime is unavailable', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(SkillEditorDialog, {
|
||||||
|
open: true,
|
||||||
|
mode: 'create',
|
||||||
|
projectPath: '/tmp/project',
|
||||||
|
projectLabel: 'Project',
|
||||||
|
allowCodexRootKind: false,
|
||||||
|
detail: null,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSaved: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const selects = host.querySelectorAll('select');
|
||||||
|
const rootSelect = selects[1] as HTMLSelectElement;
|
||||||
|
expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the codex root visible when editing an existing codex-only skill', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const detail = makeCodexDetail(`---
|
||||||
|
name: Codex Skill
|
||||||
|
description: Codex markdown skill
|
||||||
|
---
|
||||||
|
|
||||||
|
# Codex Skill
|
||||||
|
`);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(SkillEditorDialog, {
|
||||||
|
open: true,
|
||||||
|
mode: 'edit',
|
||||||
|
projectPath: '/tmp/project',
|
||||||
|
projectLabel: 'Project',
|
||||||
|
allowCodexRootKind: false,
|
||||||
|
detail,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSaved: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const selects = host.querySelectorAll('select');
|
||||||
|
const rootSelect = selects[1] as HTMLSelectElement;
|
||||||
|
expect(rootSelect.value).toBe('codex');
|
||||||
|
expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -167,6 +168,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -232,6 +234,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -268,6 +271,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: '/tmp/project-a',
|
projectPath: '/tmp/project-a',
|
||||||
projectLabel: 'Project A',
|
projectLabel: 'Project A',
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -284,6 +288,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -332,6 +337,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -364,6 +370,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: false,
|
open: false,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -390,6 +397,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -438,6 +446,7 @@ describe('SkillImportDialog', () => {
|
||||||
open: true,
|
open: true,
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
projectLabel: null,
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onImported: vi.fn(),
|
onImported: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -471,4 +480,33 @@ describe('SkillImportDialog', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides the codex root option when codex runtime is unavailable', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(SkillImportDialog, {
|
||||||
|
open: true,
|
||||||
|
projectPath: null,
|
||||||
|
projectLabel: null,
|
||||||
|
allowCodexRootKind: false,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onImported: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const selects = host.querySelectorAll('select');
|
||||||
|
const rootSelect = selects[1] as HTMLSelectElement;
|
||||||
|
expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { CliInstallationStatus } from '@shared/types';
|
||||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface StoreState {
|
interface StoreState {
|
||||||
|
|
@ -12,6 +13,7 @@ interface StoreState {
|
||||||
skillsDetailsById: Record<string, unknown>;
|
skillsDetailsById: Record<string, unknown>;
|
||||||
skillsUserCatalog: SkillCatalogItem[];
|
skillsUserCatalog: SkillCatalogItem[];
|
||||||
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
|
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
|
||||||
|
cliStatus: CliInstallationStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeState = {} as StoreState;
|
const storeState = {} as StoreState;
|
||||||
|
|
@ -107,11 +109,19 @@ vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({
|
vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({
|
||||||
SkillEditorDialog: () => null,
|
SkillEditorDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) =>
|
||||||
|
React.createElement('div', {
|
||||||
|
'data-testid': 'skill-editor-dialog',
|
||||||
|
'data-allow-codex-root-kind': String(allowCodexRootKind),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({
|
vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({
|
||||||
SkillImportDialog: () => null,
|
SkillImportDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) =>
|
||||||
|
React.createElement('div', {
|
||||||
|
'data-testid': 'skill-import-dialog',
|
||||||
|
'data-allow-codex-root-kind': String(allowCodexRootKind),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('lucide-react', () => {
|
vi.mock('lucide-react', () => {
|
||||||
|
|
@ -170,6 +180,22 @@ describe('SkillsPanel', () => {
|
||||||
storeState.skillsProjectCatalogByProjectPath = {
|
storeState.skillsProjectCatalogByProjectPath = {
|
||||||
'/tmp/project-a': [],
|
'/tmp/project-a': [],
|
||||||
};
|
};
|
||||||
|
storeState.cliStatus = {
|
||||||
|
flavor: 'claude',
|
||||||
|
displayName: 'Claude CLI',
|
||||||
|
supportsSelfUpdate: true,
|
||||||
|
showVersionDetails: true,
|
||||||
|
showBinaryPath: true,
|
||||||
|
installed: true,
|
||||||
|
installedVersion: '1.0.0',
|
||||||
|
binaryPath: '/usr/local/bin/claude',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
updateAvailable: false,
|
||||||
|
authLoggedIn: true,
|
||||||
|
authStatusChecking: false,
|
||||||
|
authMethod: 'oauth',
|
||||||
|
providers: [],
|
||||||
|
};
|
||||||
startWatchingMock.mockReset();
|
startWatchingMock.mockReset();
|
||||||
stopWatchingMock.mockReset();
|
stopWatchingMock.mockReset();
|
||||||
onChangedMock.mockReset();
|
onChangedMock.mockReset();
|
||||||
|
|
@ -232,4 +258,39 @@ describe('SkillsPanel', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides codex-only create and import affordances when codex runtime is unavailable', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
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: null,
|
||||||
|
setSelectedSkillId: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).not.toContain('Codex only');
|
||||||
|
for (const node of host.querySelectorAll('[data-testid="skill-editor-dialog"]')) {
|
||||||
|
expect(node.getAttribute('data-allow-codex-root-kind')).toBe('false');
|
||||||
|
}
|
||||||
|
const importDialog = host.querySelector('[data-testid="skill-import-dialog"]');
|
||||||
|
expect(importDialog?.getAttribute('data-allow-codex-root-kind')).toBe('false');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue