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:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
@@ -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>