feat: redesign settings pages to match canonical flat UI patterns (#493)

* refactor(settings): new IA shell + projects section + skills catalog discoverability

* chore(settings): split providers list by scope; show user before project

* fix: navigation flow in mobile Settings

* feat: redesign settings pages to use modern elevated surface patterns

* feat: replace helper text with tooltips in settings

* ui: redesign update dialog and fix external link routing

- Restructures UpdateDialog to focus on changelog readability with a wider max-w-4xl canvas
- Highlights @username contributor mentions with theme primary color
- Strips excessive vertical padding and right-aligns compact action buttons
- Disables streamdown's internal link safety dialog in favor of direct Tauri shell routing

* feat: refactor Git identities into dedicated Git settings page

* feat: unify sidebar background styling across VS Code and web/mobile

* fix: adjust button styling and layout for mobile settings pages

* feat: add MCP settings page and sidebar

* feat: hide models in provider view (thanks to @nguyenngothuong)

* feat: add "Add new provider" option to model selector dropdown

* fix: local evroc logo + provider dropdown icons

* fix: increase width of provider menu

* fix: dark theme background color for better contrast

* feat: update @opencode-ai/sdk dependency to v1.2.10

* fix: restore session sorting to only use updated time

* fix: added settings for sessions deletion dialog

* fix: adjust padding on settings pages for better layout

* fix: standardize select dropdown height across UI

* fix: agent selector UI and notification settings

* fix: remove redundant helper text from settings pages

* fix: update UI layout for description fields

* fix: remove border-none and shadow-none from textarea classes

* fix: enable context menu on sidebar items

* feat: refactor UI controls and layout patterns across settings pages

* fix: use headerless blocks when page title already provides context

* fix: remove subtask option from command settings

* fix: refactor mcp page settings

* fix: reduce spacing in skills configuration pages

* feat: refactor voice settings

* feat: refactor settings sidebar sections
This commit is contained in:
Bohdan Triapitsyn
2026-02-24 03:28:30 +02:00
committed by GitHub
parent d2d39c48ac
commit d2358c2c03
90 changed files with 8062 additions and 7128 deletions
@@ -1,12 +1,10 @@
import React from 'react';
import type { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiSaveLine, RiUser3Line } from '@remixicon/react';
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Select,
@@ -23,10 +21,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import {
SKILL_LOCATION_OPTIONS,
locationLabel,
@@ -35,13 +30,15 @@ import {
type SkillLocationValue,
} from './skillLocations';
const LazyCodeMirrorEditor = React.lazy(async () => {
const module = await import('@/components/ui/CodeMirrorEditor');
return { default: module.CodeMirrorEditor };
});
export interface SkillsPageProps {
view?: 'installed' | 'catalog';
}
export const SkillsPage: React.FC = () => {
const { currentTheme } = useThemeSystem();
const SkillsCatalogStandalone: React.FC = () => (
<SkillsCatalogPage mode="external" onModeChange={() => {}} showModeTabs={false} />
);
const SkillsInstalledPage: React.FC = () => {
const {
selectedSkillName,
getSkillByName,
@@ -58,155 +55,47 @@ export const SkillsPage: React.FC = () => {
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
type SkillsMode = 'manual' | 'external';
const [mode, setMode] = React.useState<SkillsMode>('manual');
React.useEffect(() => {
if (!isNewSkill && mode !== 'manual') {
setMode('manual');
}
}, [isNewSkill, mode]);
React.useEffect(() => {
if (!hasStaleSelection) {
return;
}
// Clear persisted selection if it points to a non-existent skill.
setSelectedSkill(null);
}, [hasStaleSelection, setSelectedSkill]);
const modeTabs = isNewSkill ? (
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={setMode}
animate={false}
/>
) : null;
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
const [description, setDescription] = React.useState('');
const [instructions, setInstructions] = React.useState('');
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]); // For new skills
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]);
const [isSaving, setIsSaving] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
// Track original values to detect changes
const [originalDescription, setOriginalDescription] = React.useState('');
const [originalInstructions, setOriginalInstructions] = React.useState('');
// File dialog state
const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false);
const [newFileName, setNewFileName] = React.useState('');
const [newFileContent, setNewFileContent] = React.useState('');
const [editingFilePath, setEditingFilePath] = React.useState<string | null>(null); // null = adding, string = editing
const [editingFilePath, setEditingFilePath] = React.useState<string | null>(null);
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection
const [originalFileContent, setOriginalFileContent] = React.useState('');
const [deleteFilePath, setDeleteFilePath] = React.useState<string | null>(null);
const [isDeletingFile, setIsDeletingFile] = React.useState(false);
const [instructionsEditorHeight, setInstructionsEditorHeight] = React.useState(320);
const [instructionsLanguage, setInstructionsLanguage] = React.useState<Extension | null>(null);
const [supportingFileLanguage, setSupportingFileLanguage] = React.useState<Extension | null>(null);
React.useEffect(() => {
let cancelled = false;
const loadInstructionsLanguage = async () => {
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
if (cancelled) return;
setInstructionsLanguage(languageByExtension('SKILL.md'));
};
void loadInstructionsLanguage();
return () => {
cancelled = true;
};
}, []);
React.useEffect(() => {
let cancelled = false;
const loadSupportingFileLanguage = async () => {
const targetPath = newFileName.trim();
if (!targetPath) {
setSupportingFileLanguage(null);
return;
}
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
if (cancelled) return;
setSupportingFileLanguage(languageByExtension(targetPath));
};
void loadSupportingFileLanguage();
return () => {
cancelled = true;
};
}, [newFileName]);
const instructionsEditorExtensions = React.useMemo(() => {
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
if (instructionsLanguage) {
extensions.push(instructionsLanguage);
}
return extensions;
}, [currentTheme, instructionsLanguage]);
const supportingFileEditorExtensions = React.useMemo(() => {
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
if (supportingFileLanguage) {
extensions.push(supportingFileLanguage);
}
return extensions;
}, [currentTheme, supportingFileLanguage]);
const handleStartInstructionsResize = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = instructionsEditorHeight;
const onMouseMove = (moveEvent: MouseEvent) => {
const deltaY = moveEvent.clientY - startY;
const viewportMax = typeof window !== 'undefined' ? Math.floor(window.innerHeight * 0.75) : 800;
const nextHeight = Math.max(220, Math.min(viewportMax, startHeight + deltaY));
setInstructionsEditorHeight(nextHeight);
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, [instructionsEditorHeight]);
// Detect if skill-level fields have changed
const hasSkillChanges = isNewSkill
? (draftName.trim() !== '' || description.trim() !== '' || instructions.trim() !== '' || pendingFiles.length > 0)
: (description !== originalDescription || instructions !== originalInstructions);
// Detect if file content has changed
const hasFileChanges = editingFilePath
? newFileContent !== originalFileContent
: newFileName.trim() !== ''; // For new files, just need a name
: newFileName.trim() !== '';
// Load skill details when selection changes
React.useEffect(() => {
if (mode === 'external') {
return;
}
const loadSkillDetails = async () => {
if (isNewSkill && skillDraft) {
// Prefill from draft (for new or duplicated skills)
setDraftName(skillDraft.name || '');
setDraftScope(skillDraft.scope || 'user');
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
@@ -221,7 +110,6 @@ export const SkillsPage: React.FC = () => {
try {
const detail = await getSkillDetail(selectedSkillName);
if (detail) {
// Get actual content from the API response
const md = detail.sources.md;
setDescription(md.description || '');
setInstructions(md.instructions || '');
@@ -238,7 +126,7 @@ export const SkillsPage: React.FC = () => {
};
loadSkillDetails();
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail, mode]);
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
const handleSave = async () => {
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
@@ -248,7 +136,6 @@ export const SkillsPage: React.FC = () => {
return;
}
// Validate skill name format
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');
return;
@@ -259,7 +146,6 @@ export const SkillsPage: React.FC = () => {
return;
}
// Check for duplicate name when creating new skill
if (isNewSkill && skills.some((s) => s.name === skillName)) {
toast.error('A skill with this name already exists');
return;
@@ -274,7 +160,6 @@ export const SkillsPage: React.FC = () => {
instructions: instructions.trim() || undefined,
scope: isNewSkill ? draftScope : undefined,
source: isNewSkill ? draftSource : undefined,
// Include pending files when creating new skill
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
};
@@ -282,14 +167,13 @@ export const SkillsPage: React.FC = () => {
if (isNewSkill) {
success = await createSkill(config);
if (success) {
setSkillDraft(null); // Clear draft after successful creation
setPendingFiles([]); // Clear pending files
setSelectedSkill(skillName); // Select the newly created skill
setSkillDraft(null);
setPendingFiles([]);
setSelectedSkill(skillName);
}
} else {
success = await updateSkill(skillName, config);
if (success) {
// Update original values to reflect saved state
setOriginalDescription(description.trim());
setOriginalInstructions(instructions.trim());
}
@@ -320,7 +204,6 @@ export const SkillsPage: React.FC = () => {
setEditingFilePath(filePath);
setNewFileName(filePath);
// For new skills, get content from pending files
if (isNewSkill) {
const pendingFile = pendingFiles.find(f => f.path === filePath);
const content = pendingFile?.content || '';
@@ -330,7 +213,6 @@ export const SkillsPage: React.FC = () => {
return;
}
// For existing skills, load content from server
if (!selectedSkillName) return;
setIsLoadingFile(true);
@@ -359,16 +241,13 @@ export const SkillsPage: React.FC = () => {
const filePath = newFileName.trim();
const isEditing = editingFilePath !== null;
// For new skills, add/update pending files
if (isNewSkill) {
if (isEditing) {
// Update existing pending file
setPendingFiles(prev => prev.map(f =>
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
));
toast.success(`File "${filePath}" updated`);
} else {
// Check for duplicate
if (pendingFiles.some(f => f.path === filePath)) {
toast.error('A file with this name already exists');
return;
@@ -381,7 +260,6 @@ export const SkillsPage: React.FC = () => {
return;
}
// For existing skills, write directly to disk
if (!selectedSkillName) {
toast.error('No skill selected');
return;
@@ -394,7 +272,6 @@ export const SkillsPage: React.FC = () => {
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
setIsFileDialogOpen(false);
setEditingFilePath(null);
// Refresh skill details to get updated file list
const detail = await getSkillDetail(selectedSkillName);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
@@ -405,14 +282,12 @@ export const SkillsPage: React.FC = () => {
};
const handleDeleteFile = (filePath: string) => {
// For new skills, remove from pending files
if (isNewSkill) {
setPendingFiles(prev => prev.filter(f => f.path !== filePath));
toast.success(`File "${filePath}" removed`);
return;
}
// For existing skills, delete from disk
if (!selectedSkillName) {
return;
}
@@ -443,12 +318,6 @@ export const SkillsPage: React.FC = () => {
setIsDeletingFile(false);
};
if (isNewSkill && mode === 'external') {
return <SkillsCatalogPage mode={mode} onModeChange={setMode} />;
}
// Show empty state when nothing is selected or selection is stale
if ((!selectedSkillName && !skillDraft) || hasStaleSelection) {
return (
<div className="flex h-full items-center justify-center">
@@ -473,225 +342,185 @@ export const SkillsPage: React.FC = () => {
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{isNewSkill ? modeTabs : null}
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewSkill ? 'New Skill' : selectedSkillName}
</h1>
{selectedSkill && (
<p className="typography-meta text-muted-foreground">
{locationLabel(selectedSkill.scope, selectedSkill.source)} skill
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
</p>
)}
</div>
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
<p className="typography-meta text-muted-foreground/80">
Configure skill identity and description
</p>
</div>
{isNewSkill && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Skill Name & Location
</label>
<div className="flex items-center gap-2">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder="skill-name"
className="flex-1 text-foreground placeholder:text-muted-foreground"
/>
<Select
value={locationValueFrom(draftScope, draftSource)}
onValueChange={(v) => {
const next = locationPartsFrom(v as SkillLocationValue);
setDraftScope(next.scope);
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger className="!h-9 w-auto gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
{draftSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
<span>{locationLabel(draftScope, draftSource)}</span>
</SelectTrigger>
<SelectContent align="end">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<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>
</div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="typography-meta text-muted-foreground">
Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen.
</p>
</div>
)}
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Description <span className="text-destructive">*</span>
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does..."
rows={2}
className="max-h-32 resize-none"
/>
<p className="typography-meta text-muted-foreground">
The agent uses this to decide when to load the skill
</p>
</div>
</div>
{/* Instructions */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Instructions</h2>
<p className="typography-meta text-muted-foreground/80">
Detailed instructions for the agent when this skill is loaded
</p>
</div>
<div
className="relative min-h-[220px] max-h-[75vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden flex flex-col"
style={{ height: `${instructionsEditorHeight}px` }}
>
<div className="flex-1 min-h-0">
<React.Suspense
fallback={(
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Step-by-step instructions, guidelines, or reference content..."
rows={12}
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
/>
{/* Header */}
<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}
{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
</span>
)}
>
<LazyCodeMirrorEditor
value={instructions}
onChange={setInstructions}
extensions={instructionsEditorExtensions}
className="h-full"
/>
</React.Suspense>
</div>
<div
role="separator"
aria-label="Resize instructions editor"
onMouseDown={handleStartInstructionsResize}
className="absolute right-1.5 bottom-1.5 z-10 h-4 w-4 cursor-nwse-resize opacity-80 hover:opacity-100"
>
<svg viewBox="0 0 16 16" className="h-4 w-4 text-[var(--surface-muted-foreground)]" aria-hidden="true">
<path d="M6 14L14 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M10 14L14 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M2 14L14 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
</div>
</div>
{/* Supporting Files */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Supporting Files</h2>
<p className="typography-meta text-muted-foreground/80">
Reference documentation, scripts, or templates
</h2>
<p className="typography-meta text-muted-foreground truncate">
{selectedSkill ? `${locationLabel(selectedSkill.scope, selectedSkill.source)} skill` : 'Configure a new skill'}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddFile}
className="gap-1.5"
>
<RiAddLine className="h-3.5 w-3.5" />
Add File
</Button>
</div>
{(() => {
// For new skills, show pending files
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
if (filesToShow.length === 0) {
return (
<p className="typography-meta text-muted-foreground py-2">
{isNewSkill ? 'No files yet. Use "Add File" to include reference materials.' : 'No supporting files. Use "Add File" to include reference materials.'}
</p>
);
}
return (
<div className="space-y-2">
{filesToShow.map((file) => (
<div
key={file.path}
className="flex items-center justify-between px-3 py-2 rounded-lg border bg-muted/30 hover:bg-interactive-hover cursor-pointer transition-colors"
onClick={() => handleEditFile(file.path)}
>
<div className="flex items-center gap-2 min-w-0">
<RiFileLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="typography-ui-label truncate">{file.path}</span>
{isNewSkill && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
pending
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(file.path);
{/* Basic Information */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Basic Information
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{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>
<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"
className="h-7 w-40 px-2"
/>
<Select
value={locationValueFrom(draftScope, draftSource)}
onValueChange={(v) => {
const next = locationPartsFrom(v as SkillLocationValue);
setDraftScope(next.scope);
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</Button>
<SelectTrigger className="w-fit gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-3.5 w-3.5" />
) : (
<RiFolderLine className="h-3.5 w-3.5" />
)}
{draftSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{locationLabel(draftScope, draftSource)}</span>
</SelectTrigger>
<SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-0.5">
<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>
</div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
);
})()}
</div>
</div>
)}
<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>
<div className="mt-1.5">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does..."
rows={2}
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
/>
</div>
</div>
</section>
</div>
{/* Instructions */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Instructions
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Step-by-step instructions, guidelines, or reference content..."
className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
/>
</section>
</div>
{/* Supporting Files */}
<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
</h3>
<ButtonSmall variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
<RiAddLine className="h-3.5 w-3.5" /> Add File
</ButtonSmall>
</div>
<section className="px-2 pb-2 pt-0">
{(() => {
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
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.
</p>
);
}
return (
<div className="divide-y divide-[var(--surface-subtle)]">
{filesToShow.map((file) => (
<div
key={file.path}
className="flex items-center gap-2 py-1.5 cursor-pointer group"
onClick={() => handleEditFile(file.path)}
>
<RiFileLine className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<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
</span>
)}
<ButtonSmall
variant="ghost"
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(file.path);
}}
>
<RiDeleteBinLine className="h-3 w-3" />
</ButtonSmall>
</div>
))}
</div>
);
})()}
</section>
</div>
{/* Save action */}
<div className="px-2 py-1">
<ButtonSmall
onClick={handleSave}
disabled={isSaving || !hasSkillChanges}
size="xs"
className="!font-normal"
>
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</ButtonSmall>
</div>
{/* Save Button */}
<div className="flex justify-end border-t border-border/40 pt-4">
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving || !hasSkillChanges}
className="gap-2 h-6 px-2 text-xs w-fit"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</Button>
</div>
{/* Add/Edit File Dialog */}
@@ -711,15 +540,14 @@ export const SkillsPage: React.FC = () => {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setDeleteFilePath(null)}
disabled={isDeletingFile}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile}>
</ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</ButtonLarge>
</DialogFooter>
@@ -730,7 +558,7 @@ export const SkillsPage: React.FC = () => {
setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null);
}}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col" keyboardAvoid>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col" keyboardAvoid>
<DialogHeader className="flex-shrink-0">
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
<DialogDescription>
@@ -742,7 +570,7 @@ export const SkillsPage: React.FC = () => {
<span className="typography-meta text-muted-foreground">Loading file content...</span>
</div>
) : (
<div className="space-y-4 flex-1 min-h-0 flex flex-col">
<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
@@ -751,7 +579,7 @@ export const SkillsPage: React.FC = () => {
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="example.md or docs/reference.txt"
className="text-foreground placeholder:text-muted-foreground"
className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
disabled={editingFilePath !== null}
/>
{!editingFilePath && (
@@ -764,46 +592,36 @@ export const SkillsPage: React.FC = () => {
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
Content
</label>
<div className="h-[45vh] min-h-[220px] max-h-[55vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden">
<React.Suspense
fallback={(
<Textarea
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content..."
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
/>
)}
>
<LazyCodeMirrorEditor
value={newFileContent}
onChange={setNewFileContent}
extensions={supportingFileEditorExtensions}
className="h-full"
/>
</React.Suspense>
</div>
<Textarea
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content..."
outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
className="h-full min-h-0 font-mono typography-meta"
/>
</div>
</div>
)}
<DialogFooter>
<Button
<DialogFooter className="mt-4">
<ButtonLarge
variant="ghost"
onClick={() => {
setIsFileDialogOpen(false);
setEditingFilePath(null);
}}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
{editingFilePath ? 'Save Changes' : 'Create File'}
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
);
};
export const SkillsPage: React.FC<SkillsPageProps> = ({ view = 'installed' }) => {
return view === 'catalog' ? <SkillsCatalogStandalone /> : <SkillsInstalledPage />;
};
@@ -1,8 +1,9 @@
import React, { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
import {
Dialog,
DialogContent,
@@ -19,11 +20,9 @@ import {
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
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';
interface SkillsSidebarProps {
@@ -35,6 +34,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
const [renameNewName, setRenameNewName] = React.useState('');
const [deleteDialogSkill, setDeleteDialogSkill] = React.useState<DiscoveredSkill | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
const [openMenuSkill, setOpenMenuSkill] = React.useState<string | null>(null);
const {
selectedSkillName,
@@ -43,20 +43,12 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
setSkillDraft,
createSkill,
deleteSkill,
loadSkills,
getSkillDetail,
} = useSkillsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
// Skills are loaded by the Settings shell when this page is active.
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
loadSkills();
}, [loadSkills]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const bgClass = 'bg-background';
const handleCreateNew = () => {
// Generate unique name
@@ -73,9 +65,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
setSelectedSkill(newName);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
@@ -125,9 +115,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
});
setSelectedSkill(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
@@ -214,18 +202,18 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Skills</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>
<Button
type="button"
<ButtonSmall
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="size-4" />
</Button>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
@@ -258,13 +246,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
</SidebarGroup>
@@ -277,13 +265,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
</>
@@ -309,13 +297,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
</SidebarGroup>
@@ -328,13 +316,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
</>
@@ -359,14 +347,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setDeleteDialogSkill(null)}
disabled={isDeletePending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
Delete
</ButtonLarge>
@@ -395,13 +383,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}}
/>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setRenameDialogSkill(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleRenameSkill}>
Rename
</ButtonLarge>
@@ -419,6 +407,8 @@ interface SkillListItemProps {
onDelete: () => void;
onRename: () => void;
onDuplicate: () => void;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
}
const SkillListItem: React.FC<SkillListItemProps> = ({
@@ -428,13 +418,20 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
onDelete,
onRename,
onDuplicate,
isMenuOpen,
onMenuOpenChange,
}) => {
const isMobile = isMobileDeviceViaCSS();
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
>
<div className="flex min-w-0 flex-1 items-center">
<button
@@ -462,15 +459,14 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
</div>
</button>
<DropdownMenu>
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
<ButtonSmall
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</ButtonSmall>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -240,12 +240,12 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
<div className="space-y-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Catalog name</label>
<label className="typography-ui-label text-foreground">Catalog name</label>
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Repository</label>
<label className="typography-ui-label text-foreground">Repository</label>
<Input
value={source}
onChange={(e) => {
@@ -261,7 +261,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
<label className="typography-ui-label text-foreground">Optional subpath</label>
<Input
value={subpath}
onChange={(e) => {
@@ -274,28 +274,26 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
{identityOptions.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-meta text-muted-foreground mt-1">
Select a Git identity (SSH key) that can access this repository.
</div>
<div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between">
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
{identityOptions.map((id) => (
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
{id.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="typography-micro text-muted-foreground mt-2">
Configure identities in Settings → Git Identities.
<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>
</div>
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="w-fit">
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
{identityOptions.map((id) => (
<SelectItem key={id.id} value={id.id}>
{id.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="typography-micro text-muted-foreground">
Configure identities in Settings - Git Identities.
</p>
</div>
) : null}
@@ -313,25 +311,24 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="outline"
</ButtonLarge>
<ButtonLarge
variant="ghost"
onClick={() => void handleScan()}
disabled={isScanning || !source.trim()}
className="gap-2"
>
<RiGitRepositoryLine className="h-4 w-4" />
{isScanning ? 'Scanning…' : 'Scan'}
</Button>
<Button
variant="default"
{isScanning ? 'Scanning...' : 'Scan'}
</ButtonLarge>
<ButtonLarge
onClick={() => void handleAdd()}
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
>
Add catalog
</Button>
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -8,8 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { ButtonSmall } from '@/components/ui/button-small';
import {
Select,
SelectContent,
@@ -73,8 +73,8 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setAll('skip')}>Skip all</Button>
<Button variant="outline" size="sm" onClick={() => setAll('overwrite')}>Overwrite all</Button>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</ButtonSmall>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</ButtonSmall>
</div>
</div>
@@ -82,7 +82,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
{conflicts.map((conflict) => (
<div
key={conflict.skillName}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2"
className="flex items-center justify-between gap-3 py-1.5"
>
<div className="min-w-0">
<div className="typography-ui-label truncate">{conflict.skillName}</div>
@@ -95,7 +95,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
value={decisions[conflict.skillName] || 'skip'}
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
>
<SelectTrigger className="!h-9 w-36 justify-between">
<SelectTrigger className="w-fit">
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
</SelectTrigger>
<SelectContent align="end">
@@ -113,9 +113,9 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge
onClick={() => onConfirm(decisions)}
disabled={!canConfirm}
@@ -19,6 +19,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RiFolderLine, RiGitRepositoryLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
@@ -27,6 +28,7 @@ import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
import {
SKILL_LOCATION_OPTIONS,
@@ -49,6 +51,10 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState('');
const [scope, setScope] = React.useState<'user' | 'project'>('user');
@@ -70,6 +76,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
targetSource: 'opencode' | 'agents';
selections: Array<{ skillDir: string }>;
gitIdentityId?: string;
directoryOverride?: string | null;
} | null>(null);
React.useEffect(() => {
@@ -78,6 +85,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setSubpath('');
setScope('user');
setTargetSource('opencode');
setTargetProjectId(activeProjectId);
setItems([]);
setSelected({});
setSearch('');
@@ -89,7 +97,32 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setConflicts([]);
setBaseInstallRequest(null);
}, [open, loadDefaultGitIdentityId]);
}, [open, loadDefaultGitIdentityId, activeProjectId]);
const resolvedTargetProjectId = React.useMemo(() => {
if (projects.length === 0) {
return null;
}
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
return targetProjectId;
}
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
return activeProjectId;
}
return projects[0]?.id ?? null;
}, [activeProjectId, projects, targetProjectId]);
const directoryOverride = React.useMemo(() => {
if (scope !== 'project') {
return null;
}
const id = resolvedTargetProjectId;
if (!id) {
return null;
}
const project = projects.find((p) => p.id === id);
return project?.path ?? null;
}, [projects, resolvedTargetProjectId, scope]);
const installedByName = React.useMemo(() => {
const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>();
@@ -189,13 +222,22 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
targetSource,
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
gitIdentityId: gitIdentityId || undefined,
directoryOverride,
};
const result = await installSkills({
...request,
conflictPolicy: 'prompt',
conflictDecisions: opts.conflictDecisions,
});
const result = await installSkills(
{
source: request.source,
subpath: request.subpath,
scope: request.scope,
targetSource: request.targetSource,
selections: request.selections,
gitIdentityId: request.gitIdentityId,
conflictPolicy: 'prompt',
conflictDecisions: opts.conflictDecisions,
},
{ directory: request.directoryOverride ?? null }
);
if (result.ok) {
const installedCount = result.installed?.length || 0;
@@ -292,7 +334,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger className="!h-9 w-full gap-1.5">
<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>
@@ -315,6 +357,32 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div>
</div>
{scope === 'project' && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Project</label>
{projects.length === 0 ? (
<p className="typography-meta text-muted-foreground">No projects available</p>
) : (
<Select
value={resolvedTargetProjectId ?? ''}
onValueChange={(v) => setTargetProjectId(v)}
disabled={projects.length === 1}
>
<SelectTrigger size="lg" className="w-full justify-between">
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent align="start">
{projects.map((p) => (
<SelectItem key={p.id} value={p.id} className="pr-2 [&>span:first-child]:hidden">
{p.label || p.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{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>
@@ -323,7 +391,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div>
<div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between">
<SelectTrigger size="lg" className="w-full justify-between">
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
@@ -419,11 +487,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge
disabled={isInstalling || selectedDirs.length === 0 || !source.trim()}
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
onClick={() => void doInstall({})}
>
{isInstalling ? 'Installing…' : 'Install selected'}
@@ -9,17 +9,19 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
import {
SKILL_LOCATION_OPTIONS,
@@ -39,6 +41,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
const { installSkills, isInstalling } = useSkillsCatalogStore();
const [scope, setScope] = React.useState<'user' | 'project'>('user');
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
const [conflictsOpen, setConflictsOpen] = React.useState(false);
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
const [baseRequest, setBaseRequest] = React.useState<{
@@ -47,16 +52,43 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: 'user' | 'project';
targetSource: 'opencode' | 'agents';
skillDir: string;
directoryOverride?: string | null;
} | null>(null);
React.useEffect(() => {
if (!open) return;
setScope('user');
setTargetSource('opencode');
setTargetProjectId(activeProjectId);
setConflictsOpen(false);
setConflicts([]);
setBaseRequest(null);
}, [open]);
}, [open, activeProjectId]);
const resolvedTargetProjectId = React.useMemo(() => {
if (projects.length === 0) {
return null;
}
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
return targetProjectId;
}
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
return activeProjectId;
}
return projects[0]?.id ?? null;
}, [activeProjectId, projects, targetProjectId]);
const directoryOverride = React.useMemo(() => {
if (scope !== 'project') {
return null;
}
const id = resolvedTargetProjectId;
if (!id) {
return null;
}
const project = projects.find((p) => p.id === id);
return project?.path ?? null;
}, [projects, resolvedTargetProjectId, scope]);
const doInstall = async (request: {
source: string;
@@ -64,6 +96,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: 'user' | 'project';
targetSource: 'opencode' | 'agents';
skillDir: string;
directoryOverride?: string | null;
conflictDecisions?: Record<string, ConflictDecision>;
}) => {
// Build selection with clawdhub metadata if present
@@ -86,7 +119,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
selections: [selection],
conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions,
});
}, { directory: request.directoryOverride ?? null });
if (result.ok) {
toast.success('Skill installed successfully');
@@ -101,6 +134,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: request.scope,
targetSource: request.targetSource,
skillDir: request.skillDir,
directoryOverride: request.directoryOverride ?? null,
});
setConflicts(result.error.conflicts);
setConflictsOpen(true);
@@ -122,7 +156,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" keyboardAvoid>
<DialogContent className="max-w-md" keyboardAvoid>
<DialogHeader>
<DialogTitle>Install skill</DialogTitle>
<DialogDescription>
@@ -130,21 +164,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{item.warnings?.length ? (
<div className="rounded-lg border bg-muted/30 px-3 py-2">
<div className="typography-micro text-muted-foreground">Warnings</div>
<ul className="mt-1 space-y-1">
{item.warnings.map((w) => (
<li key={w} className="typography-meta text-muted-foreground">{w}</li>
))}
</ul>
</div>
) : null}
</div>
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<div className="flex items-center gap-2">
<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>
<Select
value={locationValueFrom(scope, targetSource)}
onValueChange={(v) => {
@@ -153,23 +175,21 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger className="!h-9 w-full sm:w-auto">
<span className="flex items-center gap-2 whitespace-nowrap">
{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>
<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>
</SelectTrigger>
<SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<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}
{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>
</div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
<span className="typography-micro text-muted-foreground ml-5">{option.description}</span>
</div>
</SelectItem>
))}
@@ -177,27 +197,61 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
</Select>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button className="w-full sm:w-auto" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="w-full sm:w-auto"
variant="default"
disabled={isInstalling || !item.installable}
onClick={() =>
void doInstall({
source: item.repoSource,
subpath: item.repoSubpath,
scope,
targetSource,
skillDir: item.skillDir,
})
}
>
{isInstalling ? 'Installing…' : 'Install'}
</Button>
</div>
{scope === 'project' && (
<div className="flex flex-wrap items-center gap-2">
<span className="typography-ui-label text-foreground">Project</span>
{projects.length === 0 ? (
<span className="typography-meta text-muted-foreground">No projects available</span>
) : (
<Select
value={resolvedTargetProjectId ?? ''}
onValueChange={(v) => setTargetProjectId(v)}
disabled={projects.length === 1}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent align="start">
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label || p.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-2 py-1.5 rounded">
{item.warnings.join(' · ')}
</div>
) : null}
</div>
<DialogFooter>
<ButtonLarge
variant="ghost"
onClick={() => onOpenChange(false)}
>
Cancel
</ButtonLarge>
<ButtonLarge
disabled={isInstalling || !item.installable || (scope === 'project' && !directoryOverride)}
onClick={() =>
void doInstall({
source: item.repoSource,
subpath: item.repoSubpath,
scope,
targetSource,
skillDir: item.skillDir,
directoryOverride,
})
}
>
{isInstalling ? 'Installing...' : 'Install'}
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -215,6 +269,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
targetSource: baseRequest.targetSource,
skillDir: baseRequest.skillDir,
conflictDecisions: decisions,
directoryOverride: baseRequest.directoryOverride ?? null,
});
setConflictsOpen(false);
}}
@@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
@@ -12,16 +12,19 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine } from '@remixicon/react';
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine, RiSearchLine } from '@remixicon/react';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -36,6 +39,7 @@ type SkillsMode = 'manual' | 'external';
interface SkillsCatalogPageProps {
mode: SkillsMode;
onModeChange: (mode: SkillsMode) => void;
showModeTabs?: boolean;
}
const loadSettings = async (): Promise<DesktopSettings | null> => {
@@ -61,7 +65,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
}
};
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange }) => {
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
const {
sources,
itemsBySource,
@@ -142,234 +146,250 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-3">
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={onModeChange}
animate={false}
/>
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">Skills Catalog</h1>
<p className="typography-meta text-muted-foreground">
Browse curated repositories and install skills into your OpenCode configuration.
</p>
{/* Header */}
<div className="mb-4">
{showModeTabs && (
<div className="mb-4">
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={onModeChange}
animate={false}
/>
</div>
)}
<h2 className="typography-ui-header font-semibold text-foreground px-1">Skills Catalog</h2>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex-1 space-y-2">
<label className="typography-ui-label font-medium text-foreground">Source</label>
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger className="!h-9 w-full justify-between">
<span>{selectedSource?.label || 'Select source'}</span>
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id} className="pr-2 [&>span:first-child]:hidden">
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Source & Search */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Source Repository</h3>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
className="gap-2"
>
<RiRefreshLine className="h-4 w-4" />
Refresh
</Button>
{isCustomSource ? (
<Button
type="button"
variant="outline"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
className="gap-2"
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<RiDeleteBinLine className="h-4 w-4" />
Remove
</Button>
) : null}
<Button
type="button"
variant="default"
onClick={() => setAddCatalogOpen(true)}
className="gap-2"
>
<RiAddLine className="h-4 w-4" />
Add catalog
</Button>
</div>
</div>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id}>
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills…"
className="max-w-md"
/>
<div className="typography-meta text-muted-foreground">
{isLoadingCatalog ? 'Loading…' : `${filtered.length} skill(s)`}
</div>
</div>
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title="Refresh"
>
<RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
</ButtonSmall>
{lastCatalogError ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Catalog error</div>
<div className="typography-meta text-muted-foreground mt-1">{lastCatalogError.message}</div>
</div>
) : null}
</div>
<div className="space-y-2">
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-10 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>
</div>
) : isLoadingSource ? (
<div className="py-10 text-center text-muted-foreground">
<p className="typography-body">Loading skills…</p>
</div>
) : (
<>
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
return (
<div
key={`${item.sourceId}:${item.skillDir}`}
className="rounded-lg border bg-muted/10 px-3 py-2"
{isCustomSource && (
<ButtonSmall
variant="ghost"
size="xs"
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"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<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 ({installedScope || 'unknown'})
</span>
) : null}
{!item.installable ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
not installable
</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>
)}
{item.clawdhub ? (
<div className="typography-micro text-muted-foreground mt-1 flex items-center gap-3">
{item.clawdhub.owner ? (
<span>by {item.clawdhub.owner}</span>
) : null}
<span className="flex items-center gap-1">
<RiDownloadLine className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 ? (
<span className="flex items-center gap-1">
<RiStarLine className="h-3 w-3" />
{item.clawdhub.stars}
</span>
) : null}
<span>v{item.clawdhub.version}</span>
</div>
) : null}
{item.warnings?.length ? (
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
) : null}
</div>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
)}
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
Install
</Button>
</div>
</div>
);
})}
{isClawdHubSource && hasMoreClawdHub ? (
<div className="flex justify-center pt-2">
<Button
type="button"
variant="outline"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore || isLoadingSource}
>
{isLoadingMore ? 'Loading…' : 'Load more'}
</Button>
<ButtonSmall
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
>
<RiAddLine className="h-3.5 w-3.5" /> Add Catalog
</ButtonSmall>
</div>
<div className="py-1.5">
<div className="relative">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills..."
className="h-7 pl-8 w-full sm:w-64"
/>
</div>
) : null}
</>
)}
</div>
<span className="typography-meta text-muted-foreground mt-1 block">
{isLoadingCatalog ? 'Loading...' : `${filtered.length} skill(s) found`}
</span>
</div>
</section>
</div>
{/* 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-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
</div>
)}
{/* Skills List */}
<div className="mb-8">
<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>
</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>
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
return (
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<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'})
</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
</span>
)}
</div>
{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>
)}
{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 className="flex items-center gap-1">
<RiDownloadLine className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 && (
<span className="flex items-center gap-1">
<RiStarLine className="h-3 w-3" />
{item.clawdhub.stars}
</span>
)}
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
</div>
)}
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
{item.warnings.join(' · ')}
</div>
) : null}
</div>
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal shrink-0"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
Install
</ButtonSmall>
</div>
</div>
);
})}
</div>
)}
</section>
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2 px-2">
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore}
>
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
</ButtonSmall>
</div>
)}
</div>
{/* Dialogs */}
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
<InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} />
<Dialog
open={isRemoveCatalogDialogOpen}
onOpenChange={(open) => {
if (!isRemovingCatalog) {
setIsRemoveCatalogDialogOpen(open);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Remove Catalog</DialogTitle>
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
variant="ghost"
onClick={() => setIsRemoveCatalogDialogOpen(false)}
disabled={isRemovingCatalog}
>
Cancel
</ButtonLarge>
<ButtonLarge className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
Remove Catalog
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
<InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} />
<Dialog
open={isRemoveCatalogDialogOpen}
onOpenChange={(open) => {
if (!isRemovingCatalog) {
setIsRemoveCatalogDialogOpen(open);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Remove Catalog</DialogTitle>
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRemoveCatalogDialogOpen(false)}
disabled={isRemovingCatalog}
>
Cancel
</Button>
<Button variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
);