Add i18n foundation and translations (#1027)
* feat: add i18n foundation * feat: localize sessions sidebar * Localize multirun/scheduled tasks and fix dialog dropdown interactions * localize git sidebar surface and add zh-CN keys * feat(ui): localize context panel, diff/plan views, and context sidebar content * fix(config): resolve user config home via fs/home before embedded home * localize header/chat UI and complete model/worktree panel strings * localize worktree + github issue/pr dialog flows * localize settings sections and split settings i18n dictionaries * localize additional settings sections and sidebars * localize more settings pages and dialogs * fix settings select trigger localization * localize tunnel settings ui surface * localize additional settings sections * localize keyboard shortcuts labels in settings * localize terminal and utility dialogs surfaces * feat(i18n): localize remaining UI strings * Add Ukrainian locale * Add Spanish locale * Add Brazilian Portuguese locale * Polish locale translations
This commit is contained in:
committed by
GitHub
parent
87db2ea210
commit
7d7285655d
@@ -23,11 +23,11 @@ import {
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from './skillLocations';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface SkillsPageProps {
|
||||
view?: 'installed' | 'catalog';
|
||||
@@ -38,6 +38,7 @@ const SkillsCatalogStandalone: React.FC = () => (
|
||||
);
|
||||
|
||||
const SkillsInstalledPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
selectedSkillName,
|
||||
getSkillByName,
|
||||
@@ -92,6 +93,32 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
? newFileContent !== originalFileContent
|
||||
: newFileName.trim() !== '';
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadSkillDetails = async () => {
|
||||
if (isNewSkill && skillDraft) {
|
||||
@@ -131,22 +158,22 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
||||
|
||||
if (!skillName) {
|
||||
toast.error('Skill name is required');
|
||||
toast.error(t('settings.skills.page.toast.skillNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
||||
toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen');
|
||||
toast.error(t('settings.skills.page.toast.invalidSkillName'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
toast.error('Description is required');
|
||||
toast.error(t('settings.skills.page.toast.descriptionRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewSkill && skills.some((s) => s.name === skillName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.skillExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,13 +206,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewSkill ? 'Skill created successfully' : 'Skill updated successfully');
|
||||
toast.success(isNewSkill ? t('settings.skills.page.toast.skillCreated') : t('settings.skills.page.toast.skillUpdated'));
|
||||
} else {
|
||||
toast.error(isNewSkill ? 'Failed to create skill' : 'Failed to update skill');
|
||||
toast.error(isNewSkill ? t('settings.skills.page.toast.createSkillFailed') : t('settings.skills.page.toast.updateSkillFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving skill:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
toast.error(t('settings.skills.page.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -223,7 +250,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setNewFileContent(content || '');
|
||||
setOriginalFileContent(content || '');
|
||||
} catch {
|
||||
toast.error('Failed to load file content');
|
||||
toast.error(t('settings.skills.page.toast.loadFileContentFailed'));
|
||||
setNewFileContent('');
|
||||
setOriginalFileContent('');
|
||||
} finally {
|
||||
@@ -233,7 +260,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
const handleSaveFile = async () => {
|
||||
if (!newFileName.trim()) {
|
||||
toast.error('File name is required');
|
||||
toast.error(t('settings.skills.page.toast.fileNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -245,14 +272,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setPendingFiles(prev => prev.map(f =>
|
||||
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
|
||||
));
|
||||
toast.success(`File "${filePath}" updated`);
|
||||
toast.success(t('settings.skills.page.toast.fileUpdated', { path: filePath }));
|
||||
} else {
|
||||
if (pendingFiles.some(f => f.path === filePath)) {
|
||||
toast.error('A file with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.fileExists'));
|
||||
return;
|
||||
}
|
||||
setPendingFiles(prev => [...prev, { path: filePath, content: newFileContent }]);
|
||||
toast.success(`File "${filePath}" added`);
|
||||
toast.success(t('settings.skills.page.toast.fileAdded', { path: filePath }));
|
||||
}
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
@@ -260,7 +287,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!selectedSkillName) {
|
||||
toast.error('No skill selected');
|
||||
toast.error(t('settings.skills.page.toast.noSkillSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,7 +295,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
|
||||
|
||||
if (success) {
|
||||
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
|
||||
toast.success(isEditing ? t('settings.skills.page.toast.fileUpdated', { path: filePath }) : t('settings.skills.page.toast.fileCreated', { path: filePath }));
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
@@ -276,14 +303,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
} else {
|
||||
toast.error(isEditing ? 'Failed to update file' : 'Failed to create file');
|
||||
toast.error(isEditing ? t('settings.skills.page.toast.updateFileFailed') : t('settings.skills.page.toast.createFileFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = (filePath: string) => {
|
||||
if (isNewSkill) {
|
||||
setPendingFiles(prev => prev.filter(f => f.path !== filePath));
|
||||
toast.success(`File "${filePath}" removed`);
|
||||
toast.success(t('settings.skills.page.toast.fileRemoved', { path: filePath }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,14 +331,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`File "${deleteFilePath}" deleted`);
|
||||
toast.success(t('settings.skills.page.toast.fileDeleted', { path: deleteFilePath }));
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
if (detail) {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
setDeleteFilePath(null);
|
||||
} else {
|
||||
toast.error('Failed to delete file');
|
||||
toast.error(t('settings.skills.page.toast.deleteFileFailed'));
|
||||
}
|
||||
|
||||
setIsDeletingFile(false);
|
||||
@@ -322,8 +349,8 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a skill from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
<p className="typography-body">{t('settings.skills.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.page.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -333,7 +360,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="typography-body">Loading skill details...</p>
|
||||
<p className="typography-body">{t('settings.skills.page.loading.details')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -347,15 +374,19 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
|
||||
{isNewSkill ? 'New Skill' : selectedSkillName}
|
||||
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
|
||||
{selectedSkill?.source === 'claude' && (
|
||||
<span className="typography-micro font-normal bg-[var(--surface-muted)] text-muted-foreground px-1.5 py-0.5 rounded">
|
||||
Claude-compatible
|
||||
{t('settings.skills.page.badge.claudeCompatible')}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{selectedSkill ? `${locationLabel(selectedSkill.scope, selectedSkill.source)} skill` : 'Configure a new skill'}
|
||||
{selectedSkill
|
||||
? t('settings.skills.page.subtitle.skillLocation', {
|
||||
location: locationLabelText(locationValueFrom(selectedSkill.scope, selectedSkill.source)),
|
||||
})
|
||||
: t('settings.skills.page.subtitle.newSkill')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +395,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Basic Information
|
||||
{t('settings.skills.page.section.basicInformation')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -372,13 +403,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
{isNewSkill && (
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Skill Name & Location</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">Lowercase, numbers, hyphens</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.page.field.skillNameLocation')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.skillNameHint')}</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
placeholder="skill-name"
|
||||
placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
<Select
|
||||
@@ -396,7 +427,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<RiFolderLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{draftSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabel(draftScope, draftSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -405,9 +436,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -418,13 +449,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Description <span className="text-[var(--status-error)]">*</span></span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">The agent uses this to decide when to load the skill</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span></span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.descriptionHint')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of what this skill does..."
|
||||
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
||||
/>
|
||||
@@ -438,7 +469,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Instructions
|
||||
{t('settings.skills.page.section.instructions')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -446,7 +477,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Step-by-step instructions, guidelines, or reference content..."
|
||||
placeholder={t('settings.skills.page.field.instructionsPlaceholder')}
|
||||
className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
|
||||
/>
|
||||
</section>
|
||||
@@ -456,10 +487,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 px-1 flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Supporting Files
|
||||
{t('settings.skills.page.section.supportingFiles')}
|
||||
</h3>
|
||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add File
|
||||
<RiAddLine className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +501,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
if (filesToShow.length === 0) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground py-1.5">
|
||||
No supporting files. Use "Add File" to include reference materials.
|
||||
{t('settings.skills.page.supportingFiles.empty')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -487,7 +518,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<span className="typography-ui-label text-foreground truncate">{file.path}</span>
|
||||
{isNewSkill && (
|
||||
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
pending
|
||||
{t('settings.skills.page.badge.pending')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm"
|
||||
@@ -516,7 +547,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
|
||||
{isSaving ? t('settings.common.actions.saving') : isNewSkill ? t('settings.skills.page.actions.createSkill') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -533,9 +564,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Supporting File</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.page.deleteFileDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteFilePath}"?
|
||||
{t('settings.skills.page.deleteFileDialog.description', { path: deleteFilePath ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -545,10 +576,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
onClick={() => setDeleteFilePath(null)}
|
||||
disabled={isDeletingFile}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleConfirmDeleteFile} disabled={isDeletingFile}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -560,42 +591,42 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
|
||||
<DialogTitle>{editingFilePath ? t('settings.skills.page.fileDialog.titleEdit') : t('settings.skills.page.fileDialog.titleAdd')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingFilePath ? 'Modify the file content' : 'Create a new file in the skill directory'}
|
||||
{editingFilePath ? t('settings.skills.page.fileDialog.descriptionEdit') : t('settings.skills.page.fileDialog.descriptionAdd')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoadingFile ? (
|
||||
<div className="flex-1 flex items-center justify-center py-8">
|
||||
<span className="typography-meta text-muted-foreground">Loading file content...</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.page.loading.fileContent')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 flex-1 min-h-0 flex flex-col pt-2">
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
File Path
|
||||
{t('settings.skills.page.fileDialog.field.filePath')}
|
||||
</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="example.md or docs/reference.txt"
|
||||
placeholder={t('settings.skills.page.fileDialog.field.filePathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
|
||||
disabled={editingFilePath !== null}
|
||||
/>
|
||||
{!editingFilePath && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Relative path within the skill directory. Subdirectories will be created automatically.
|
||||
{t('settings.skills.page.fileDialog.field.filePathHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
|
||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||
Content
|
||||
{t('settings.skills.page.fileDialog.field.content')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="File content..."
|
||||
placeholder={t('settings.skills.page.fileDialog.field.contentPlaceholder')}
|
||||
outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
|
||||
className="h-full min-h-0 font-mono typography-meta"
|
||||
/>
|
||||
@@ -611,10 +642,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setEditingFilePath(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
|
||||
{editingFilePath ? 'Save Changes' : 'Create File'}
|
||||
{editingFilePath ? t('settings.common.actions.saveChanges') : t('settings.skills.page.actions.createFile')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -23,12 +23,14 @@ import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SkillsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
const [deleteDialogSkill, setDeleteDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
@@ -79,10 +81,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteSkill(deleteDialogSkill.name);
|
||||
if (success) {
|
||||
toast.success(`Skill "${deleteDialogSkill.name}" deleted successfully`);
|
||||
toast.success(t('settings.skills.sidebar.toast.skillDeleted', { name: deleteDialogSkill.name }));
|
||||
setDeleteDialogSkill(null);
|
||||
} else {
|
||||
toast.error('Failed to delete skill');
|
||||
toast.error(t('settings.skills.sidebar.toast.deleteSkillFailed'));
|
||||
}
|
||||
setIsDeletePending(false);
|
||||
};
|
||||
@@ -100,7 +102,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
// Get full skill detail to copy
|
||||
const detail = await getSkillDetail(skill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details for duplication');
|
||||
toast.error(t('settings.skills.sidebar.toast.duplicateLoadFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +130,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
if (!sanitizedName) {
|
||||
toast.error('Skill name is required');
|
||||
toast.error(t('settings.skills.page.toast.skillNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,14 +140,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
if (skills.some((s) => s.name === sanitizedName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.skillExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get full detail to copy
|
||||
const detail = await getSkillDetail(renameDialogSkill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details');
|
||||
toast.error(t('settings.skills.sidebar.toast.renameLoadFailed'));
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
@@ -165,10 +167,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
toast.success(`Skill renamed to "${sanitizedName}"`);
|
||||
setSelectedSkill(sanitizedName);
|
||||
} else {
|
||||
toast.error('Failed to remove old skill after rename');
|
||||
toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed'));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to rename skill');
|
||||
toast.error(t('settings.skills.sidebar.toast.renameFailed'));
|
||||
}
|
||||
|
||||
setRenameDialogSkill(null);
|
||||
@@ -206,10 +208,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Skills</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.skills.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.sidebar.total', { count: skills.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -224,15 +226,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
{skills.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No skills configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.skills.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Project Skills
|
||||
{t('settings.skills.sidebar.section.project')}
|
||||
</div>
|
||||
{groupedProjectSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
@@ -283,7 +285,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
{userSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Skills
|
||||
{t('settings.skills.sidebar.section.user')}
|
||||
</div>
|
||||
{groupedUserSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
@@ -344,9 +346,9 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.sidebar.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete skill "{deleteDialogSkill?.name}"?
|
||||
{t('settings.skills.sidebar.deleteDialog.description', { name: deleteDialogSkill?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -357,10 +359,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
onClick={() => setDeleteDialogSkill(null)}
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -370,15 +372,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
<Dialog open={renameDialogSkill !== null} onOpenChange={(open) => !open && setRenameDialogSkill(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for the skill "{renameDialogSkill?.name}"
|
||||
{t('settings.skills.sidebar.renameDialog.description', { name: renameDialogSkill?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder="New skill name..."
|
||||
placeholder={t('settings.skills.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -393,10 +395,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogSkill(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameSkill}>
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -426,6 +428,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
return (
|
||||
<div
|
||||
@@ -453,12 +456,12 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
</span>
|
||||
{skill.source === 'claude' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
claude
|
||||
{t('settings.skills.sidebar.badge.claude')}
|
||||
</span>
|
||||
)}
|
||||
{skill.source === 'agents' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
agents
|
||||
{t('settings.skills.sidebar.badge.agents')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -481,7 +484,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -491,7 +494,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -502,7 +505,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
@@ -77,6 +78,7 @@ interface AddCatalogDialogProps {
|
||||
}
|
||||
|
||||
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
@@ -126,7 +128,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
const handleScan = async () => {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,25 +163,25 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and scan again.');
|
||||
toast.error(t('settings.skills.catalog.add.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.add.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const count = result.items?.length || 0;
|
||||
setScanCount(count);
|
||||
if (count === 0) {
|
||||
toast.error('No skills found in this repository');
|
||||
toast.error(t('settings.skills.catalog.add.toast.noSkillsFound'));
|
||||
setScanOk(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentityOptions([]);
|
||||
setScanOk(true);
|
||||
toast.success(`Found ${count} skill(s)`);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count }));
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
@@ -188,22 +190,22 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
const trimmedSubpath = subpath.trim();
|
||||
|
||||
if (!trimmedLabel) {
|
||||
toast.error('Catalog name is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.catalogNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanOk) {
|
||||
toast.error('Scan the repository before adding this catalog');
|
||||
toast.error(t('settings.skills.catalog.add.toast.scanBeforeAdd'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
toast.error('This catalog already exists');
|
||||
toast.error(t('settings.skills.catalog.add.toast.catalogAlreadyExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,11 +222,11 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
try {
|
||||
await updateDesktopSettings({ skillCatalogs: updated });
|
||||
setExistingCatalogs(updated);
|
||||
toast.success('Catalog added');
|
||||
toast.success(t('settings.skills.catalog.add.toast.catalogAdded'));
|
||||
await loadCatalog({ refresh: true });
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save catalog');
|
||||
toast.error(error instanceof Error ? error.message : t('settings.skills.catalog.add.toast.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,20 +234,23 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add skills catalog</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.add.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a Git repository as a new catalog source. OpenChamber will scan it for folders containing <code className="font-mono">SKILL.md</code>.
|
||||
{t('settings.skills.catalog.add.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.add.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Catalog name</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.catalogName')}</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder={t('settings.skills.catalog.add.field.catalogNamePlaceholder')} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Repository</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.repository')}</label>
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
@@ -253,15 +258,15 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Public repos work everywhere. Private repos require SSH identity (Desktop/Web only).
|
||||
{t('settings.skills.catalog.add.field.repositoryHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Optional subpath</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => {
|
||||
@@ -269,19 +274,19 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="e.g. skills"
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="typography-ui-label text-[var(--status-warning)]">Authentication required</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">Select a Git identity (SSH key)</span>
|
||||
<span className="typography-ui-label text-[var(--status-warning)]">{t('settings.skills.catalog.shared.auth.title')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.catalog.shared.auth.description')}</span>
|
||||
</div>
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identityOptions.map((id) => (
|
||||
@@ -292,27 +297,27 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Configure identities in Settings - Git Identities.
|
||||
{t('settings.skills.catalog.shared.auth.footerHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scanCount !== null ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Scan result: {scanCount} skill(s) found
|
||||
{t('settings.skills.catalog.add.scanResult', { count: scanCount })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDuplicate ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
This catalog is already added.
|
||||
{t('settings.skills.catalog.add.duplicateMessage')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -322,14 +327,14 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
disabled={isScanning || !source.trim()}
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning...' : 'Scan'}
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
|
||||
>
|
||||
Add catalog
|
||||
{t('settings.skills.catalog.add.actions.addCatalog')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type SkillConflict = {
|
||||
skillName: string;
|
||||
@@ -37,6 +38,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
conflicts,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [decisions, setDecisions] = React.useState<Record<string, ConflictDecision>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -62,18 +64,18 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skills already exist</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.conflicts.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Some selected skills are already installed in this scope. Choose whether to skip or overwrite them.
|
||||
{t('settings.skills.catalog.conflicts.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.catalog.conflicts.count', { count: conflicts.length })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>{t('settings.skills.catalog.conflicts.actions.skipAll')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>{t('settings.skills.catalog.conflicts.actions.overwriteAll')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +88,14 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label truncate">{conflict.skillName}</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Installed in {conflict.scope} / {conflict.source || 'opencode'}
|
||||
{t('settings.skills.catalog.conflicts.installedIn', {
|
||||
scope: conflict.scope === 'project'
|
||||
? t('settings.skills.catalog.conflicts.scope.project')
|
||||
: t('settings.skills.catalog.conflicts.scope.user'),
|
||||
source: conflict.source === 'agents'
|
||||
? t('settings.skills.catalog.conflicts.source.agents')
|
||||
: t('settings.skills.catalog.conflicts.source.opencode'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,14 +104,18 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
|
||||
<span className="capitalize">
|
||||
{(decisions[conflict.skillName] || 'skip') === 'overwrite'
|
||||
? t('settings.skills.catalog.conflicts.decision.overwrite')
|
||||
: t('settings.skills.catalog.conflicts.decision.skip')}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="skip" className="pr-2 [&>span:first-child]:hidden">
|
||||
Skip
|
||||
{t('settings.skills.catalog.conflicts.decision.skip')}
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite" className="pr-2 [&>span:first-child]:hidden">
|
||||
Overwrite
|
||||
{t('settings.skills.catalog.conflicts.decision.overwrite')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -113,14 +126,14 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm(decisions)}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
Continue
|
||||
{t('settings.skills.catalog.conflicts.actions.continue')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -29,9 +29,9 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
@@ -45,6 +45,7 @@ interface InstallFromRepoDialogProps {
|
||||
type IdentityOption = { id: string; name: string };
|
||||
|
||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
|
||||
const installedSkills = useSkillsStore((s) => s.skills);
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
@@ -153,10 +154,36 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleScan = async () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,7 +196,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,11 +211,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try scanning again.');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,12 +232,12 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSelected(nextSelected);
|
||||
|
||||
setIdentities([]);
|
||||
toast.success(`Found ${nextItems.length} skill(s)`);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count: nextItems.length }));
|
||||
};
|
||||
|
||||
const doInstall = async (opts: { conflictDecisions?: Record<string, ConflictDecision> }) => {
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error('Select at least one skill to install');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.selectAtLeastOne'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,7 +267,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
if (result.ok) {
|
||||
const installedCount = result.installed?.length || 0;
|
||||
toast.success(installedCount > 0 ? `Installed ${installedCount} skill(s)` : 'Installation completed');
|
||||
toast.success(
|
||||
installedCount > 0
|
||||
? t('settings.skills.catalog.installFromRepo.toast.installedCount', { count: installedCount })
|
||||
: t('settings.skills.catalog.installFromRepo.toast.installCompleted')
|
||||
);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +285,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
@@ -268,11 +299,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try installing again.');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skills');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.installFailed'));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -280,20 +311,23 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Install from Git repository</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.installFromRepo.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan a repository for folders containing <code className="font-mono">SKILL.md</code>, then install selected skills.
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Repository</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.repository')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
@@ -304,27 +338,30 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
className="gap-2"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning…' : 'Scan'}
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
For GitHub shorthand, you can add a subpath like <code className="font-mono">owner/repo/skills</code>.
|
||||
{t('settings.skills.catalog.installFromRepo.repositoryHintPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">owner/repo/skills</code>
|
||||
{'.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => setSubpath(e.target.value)}
|
||||
placeholder="e.g. skills"
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Target location</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.targetLocation')}</label>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
@@ -336,7 +373,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<SelectTrigger size="lg" className="w-full gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -345,9 +382,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -358,9 +395,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Project</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.project')}</label>
|
||||
{projects.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No projects available</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.skills.catalog.shared.field.noProjects')}</p>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
@@ -368,7 +405,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<SelectValue placeholder="Choose project" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.shared.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
@@ -384,14 +421,14 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
{identities.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
|
||||
<div className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.auth.title')}</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">
|
||||
Select a Git identity (SSH key) that can access this repository.
|
||||
{t('settings.skills.catalog.installFromRepo.authDescription')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identities.map((id) => (
|
||||
@@ -403,7 +440,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</Select>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground mt-2">
|
||||
Configure identities in Settings → Git Identities.
|
||||
{t('settings.skills.catalog.shared.auth.footerHintArrow')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -413,8 +450,8 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="typography-body">No scan results yet</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Scan a repository to discover skills</p>
|
||||
<p className="typography-body">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -423,12 +460,12 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills…"
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>Select all</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>Select none</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>{t('settings.skills.catalog.installFromRepo.actions.selectAll')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>{t('settings.skills.catalog.installFromRepo.actions.selectNone')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -458,14 +495,17 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installed.scope}/{installed.source})
|
||||
{t('settings.skills.catalog.installFromRepo.badge.installed', {
|
||||
scope: installed.scope,
|
||||
source: installed.source,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">
|
||||
@@ -479,7 +519,10 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</ScrollableOverlay>
|
||||
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Selected: {selectedDirs.length} / {items.filter((i) => i.installable).length}
|
||||
{t('settings.skills.catalog.installFromRepo.selectedCount', {
|
||||
selected: selectedDirs.length,
|
||||
total: items.filter((i) => i.installable).length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -487,14 +530,14 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
|
||||
onClick={() => void doInstall({})}
|
||||
>
|
||||
{isInstalling ? 'Installing…' : 'Install selected'}
|
||||
{isInstalling ? t('settings.skills.catalog.shared.actions.installing') : t('settings.skills.catalog.installFromRepo.actions.installSelected')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
@@ -25,7 +26,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
@@ -38,6 +38,7 @@ interface InstallSkillDialogProps {
|
||||
}
|
||||
|
||||
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
||||
const { t } = useI18n();
|
||||
const { installSkills, isInstalling } = useSkillsCatalogStore();
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
@@ -65,6 +66,32 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
setBaseRequest(null);
|
||||
}, [open, activeProjectId]);
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const resolvedTargetProjectId = React.useMemo(() => {
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
@@ -122,7 +149,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}, { directory: request.directoryOverride ?? null });
|
||||
|
||||
if (result.ok) {
|
||||
toast.success('Skill installed successfully');
|
||||
toast.success(t('settings.skills.catalog.installSkill.toast.installed'));
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
@@ -142,11 +169,11 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
toast.error(result.error.message || 'Authentication required');
|
||||
toast.error(result.error.message || t('settings.skills.catalog.installSkill.toast.authRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skill');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installSkill.toast.installFailed'));
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
@@ -158,15 +185,19 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.installSkill.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into one of four target locations.
|
||||
{t('settings.skills.catalog.installSkill.descriptionPrefix')}
|
||||
{' '}
|
||||
<span className="font-semibold text-foreground">{item.skillName}</span>
|
||||
{' '}
|
||||
{t('settings.skills.catalog.installSkill.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Destination</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.catalog.installSkill.field.destination')}</span>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
@@ -178,7 +209,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<SelectTrigger className="w-fit gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -187,9 +218,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-5">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-5">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -199,9 +230,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Project</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.catalog.installSkill.field.project')}</span>
|
||||
{projects.length === 0 ? (
|
||||
<span className="typography-meta text-muted-foreground">No projects available</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.catalog.installSkill.field.noProjects')}</span>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
@@ -209,7 +240,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Choose project" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.installSkill.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
@@ -236,7 +267,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -252,7 +283,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
})
|
||||
}
|
||||
>
|
||||
{isInstalling ? 'Installing...' : 'Install'}
|
||||
{isInstalling ? t('settings.skills.catalog.installSkill.actions.installing') : t('settings.skills.catalog.installSkill.actions.install')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
import { AddCatalogDialog } from './AddCatalogDialog';
|
||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||
@@ -65,6 +66,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
};
|
||||
|
||||
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
sources,
|
||||
itemsBySource,
|
||||
@@ -154,8 +156,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<div className="h-10">
|
||||
<SortableTabsStrip
|
||||
items={[
|
||||
{ id: 'manual', label: 'Manual' },
|
||||
{ id: 'external', label: 'External' },
|
||||
{ id: 'manual', label: t('settings.skills.catalog.page.mode.manual') },
|
||||
{ id: 'external', label: t('settings.skills.catalog.page.mode.external') },
|
||||
]}
|
||||
activeId={mode}
|
||||
onSelect={(next) => onModeChange(next as 'manual' | 'external')}
|
||||
@@ -167,13 +169,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">Skills Catalog</h2>
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">{t('settings.skills.catalog.page.title')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Source & Search */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Source Repository</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.skills.catalog.page.section.sourceRepository')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
@@ -183,7 +185,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select source" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sources.map((src) => (
|
||||
@@ -206,7 +208,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title="Refresh"
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
|
||||
</Button>
|
||||
@@ -218,7 +220,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title="Remove Catalog"
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -229,7 +231,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
className="!font-normal gap-1"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add Catalog
|
||||
<RiAddLine className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -239,12 +241,14 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills..."
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="h-7 pl-8 w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground mt-1 block">
|
||||
{isLoadingCatalog ? 'Loading...' : `${filtered.length} skill(s) found`}
|
||||
{isLoadingCatalog
|
||||
? t('settings.skills.catalog.page.loading.catalog')
|
||||
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -253,7 +257,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
{/* Error State */}
|
||||
{lastCatalogError && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">Catalog error</div>
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
|
||||
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,13 +267,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">No skills found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<RiRefreshLine className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
|
||||
<p className="typography-meta">Loading skills...</p>
|
||||
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
@@ -285,12 +289,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<span className="typography-ui-label font-medium text-foreground truncate">{item.skillName}</span>
|
||||
{installed && (
|
||||
<span className="typography-micro text-[var(--status-success)] bg-[var(--status-success)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
installed ({installedScope || 'unknown'})
|
||||
{t('settings.skills.catalog.page.badge.installed', { scope: installedScope || t('settings.skills.catalog.page.badge.unknown') })}
|
||||
</span>
|
||||
)}
|
||||
{!item.installable && (
|
||||
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
not installable
|
||||
{t('settings.skills.catalog.page.badge.notInstallable')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,13 +302,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">No description provided</div>
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
|
||||
{item.clawdhub && (
|
||||
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
|
||||
{item.clawdhub.owner && (
|
||||
<span>by <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
@@ -337,7 +341,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,7 +360,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
|
||||
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -376,8 +380,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Catalog</DialogTitle>
|
||||
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
|
||||
<DialogTitle>{t('settings.skills.catalog.page.removeDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.skills.catalog.page.removeDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -386,10 +390,10 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(false)}
|
||||
disabled={isRemovingCatalog}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
|
||||
Remove Catalog
|
||||
{t('settings.skills.catalog.page.actions.removeCatalog')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user