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:
committed by
GitHub
parent
d2d39c48ac
commit
d2358c2c03
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user