feat(ui): add Skills view component to desktop app (#8190)
Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import PermissionSettingsView from './components/settings/permission/PermissionS
|
||||
|
||||
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
|
||||
import RecipesView from './components/recipes/RecipesView';
|
||||
import SkillsView from './components/skills/SkillsView';
|
||||
import AppsView from './components/apps/AppsView';
|
||||
import StandaloneAppView from './components/apps/StandaloneAppView';
|
||||
import { View, ViewOptions } from './utils/navigationUtils';
|
||||
@@ -197,6 +198,10 @@ const RecipesRoute = () => {
|
||||
return <RecipesView />;
|
||||
};
|
||||
|
||||
const SkillsRoute = () => {
|
||||
return <SkillsView />;
|
||||
};
|
||||
|
||||
const PermissionRoute = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -226,6 +231,9 @@ const PermissionRoute = () => {
|
||||
case 'recipes':
|
||||
navigate('/recipes');
|
||||
break;
|
||||
case 'skills':
|
||||
navigate('/skills');
|
||||
break;
|
||||
default:
|
||||
navigate('/');
|
||||
}
|
||||
@@ -662,6 +670,7 @@ export function AppInner() {
|
||||
<Route path="sessions" element={<SessionsRoute />} />
|
||||
<Route path="schedules" element={<SchedulesRoute />} />
|
||||
<Route path="recipes" element={<RecipesRoute />} />
|
||||
<Route path="skills" element={<SkillsRoute />} />
|
||||
<Route
|
||||
path="shared-session"
|
||||
element={
|
||||
|
||||
@@ -21,6 +21,7 @@ export const DEFAULT_ITEM_ORDER = [
|
||||
'home',
|
||||
'chat',
|
||||
'recipes',
|
||||
'skills',
|
||||
'apps',
|
||||
'scheduler',
|
||||
'extensions',
|
||||
@@ -98,7 +99,17 @@ export const NavigationProvider: React.FC<NavigationProviderProps> = ({ children
|
||||
const stored = localStorage.getItem('navigation_preferences');
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
const parsed = JSON.parse(stored);
|
||||
// Only backfill truly new default IDs (not previously known to the user).
|
||||
// Using itemOrder as the source of truth ensures items the user
|
||||
// intentionally disabled stay disabled.
|
||||
const newIds = DEFAULT_ITEM_ORDER.filter(
|
||||
(id) => !parsed.itemOrder?.includes(id)
|
||||
);
|
||||
return {
|
||||
itemOrder: [...(parsed.itemOrder ?? []), ...newIds],
|
||||
enabledItems: [...(parsed.enabledItems ?? []), ...newIds],
|
||||
};
|
||||
} catch {
|
||||
console.error('Failed to parse navigation preferences');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Zap, AlertCircle, Plus } from 'lucide-react';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { getSlashCommands } from '../../api';
|
||||
import { errorMessage } from '../../utils/conversionUtils';
|
||||
import { getInitialWorkingDir } from '../../utils/workingDir';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
import { SearchView } from '../conversation/SearchView';
|
||||
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
|
||||
|
||||
const i18n = defineMessages({
|
||||
errorLoadingSkills: {
|
||||
id: 'skillsView.errorLoadingSkills',
|
||||
defaultMessage: 'Error Loading Skills',
|
||||
},
|
||||
tryAgain: {
|
||||
id: 'skillsView.tryAgain',
|
||||
defaultMessage: 'Try Again',
|
||||
},
|
||||
noSkillsInstalled: {
|
||||
id: 'skillsView.noSkillsInstalled',
|
||||
defaultMessage: 'No skills installed',
|
||||
},
|
||||
noSkillsDescription: {
|
||||
id: 'skillsView.noSkillsDescription',
|
||||
defaultMessage:
|
||||
'Skills are loaded from SKILL.md files in ~/.config/agents/skills/, .goose/skills/, or other supported directories.',
|
||||
},
|
||||
noMatchingSkills: {
|
||||
id: 'skillsView.noMatchingSkills',
|
||||
defaultMessage: 'No matching skills found',
|
||||
},
|
||||
adjustSearchTerms: {
|
||||
id: 'skillsView.adjustSearchTerms',
|
||||
defaultMessage: 'Try adjusting your search terms',
|
||||
},
|
||||
skillsTitle: {
|
||||
id: 'skillsView.skillsTitle',
|
||||
defaultMessage: 'Skills',
|
||||
},
|
||||
addSkill: {
|
||||
id: 'skillsView.addSkill',
|
||||
defaultMessage: 'Add Skill',
|
||||
},
|
||||
skillsDescription: {
|
||||
id: 'skillsView.skillsDescription',
|
||||
defaultMessage: 'View installed skills that extend Goose capabilities. {shortcut} to search.',
|
||||
},
|
||||
searchSkillsPlaceholder: {
|
||||
id: 'skillsView.searchSkillsPlaceholder',
|
||||
defaultMessage: 'Search skills...',
|
||||
},
|
||||
comingSoon: {
|
||||
id: 'skillsView.comingSoon',
|
||||
defaultMessage: 'Coming soon',
|
||||
},
|
||||
});
|
||||
|
||||
interface SkillEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function SkillItem({ skill }: { skill: SkillEntry }) {
|
||||
return (
|
||||
<Card className="py-2 px-4 mb-2 bg-background-primary border-none hover:bg-background-secondary transition-all duration-150">
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base truncate">{skill.name}</h3>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm line-clamp-2">{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSkeleton() {
|
||||
return (
|
||||
<Card className="p-2 mb-2 bg-background-primary">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Skeleton className="h-5 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillsView() {
|
||||
const intl = useIntl();
|
||||
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!searchTerm) return skills;
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return skills.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(searchLower) ||
|
||||
skill.description.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [skills, searchTerm]);
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowSkeleton(true);
|
||||
setShowContent(false);
|
||||
setError(null);
|
||||
const response = await getSlashCommands({
|
||||
query: { working_dir: getInitialWorkingDir() },
|
||||
throwOnError: true,
|
||||
});
|
||||
const skillEntries: SkillEntry[] = (response.data?.commands ?? [])
|
||||
.filter((cmd) => cmd.command_type === 'Skill')
|
||||
.map((cmd) => ({
|
||||
name: cmd.command,
|
||||
description: cmd.help,
|
||||
}));
|
||||
setSkills(skillEntries);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Failed to load skills'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && showSkeleton) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
setTimeout(() => setShowContent(true), 50);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [loading, showSkeleton]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading || showSkeleton) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<SkillSkeleton />
|
||||
<SkillSkeleton />
|
||||
<SkillSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-secondary">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||
<p className="text-lg mb-2">{intl.formatMessage(i18n.errorLoadingSkills)}</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={loadSkills} variant="default">
|
||||
{intl.formatMessage(i18n.tryAgain)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center pt-2 h-full">
|
||||
<p className="text-lg">{intl.formatMessage(i18n.noSkillsInstalled)}</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{intl.formatMessage(i18n.noSkillsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredSkills.length === 0 && searchTerm) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-secondary mt-4">
|
||||
<Zap className="h-12 w-12 mb-4" />
|
||||
<p className="text-lg mb-2">{intl.formatMessage(i18n.noMatchingSkills)}</p>
|
||||
<p className="text-sm">{intl.formatMessage(i18n.adjustSearchTerms)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillItem key={skill.name} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-background-primary px-8 pb-8 pt-16">
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">{intl.formatMessage(i18n.skillsTitle)}</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
disabled
|
||||
title={intl.formatMessage(i18n.comingSoon)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{intl.formatMessage(i18n.addSkill)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mb-1">
|
||||
{intl.formatMessage(i18n.skillsDescription, {
|
||||
shortcut: getSearchShortcutText(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 relative px-8">
|
||||
<ScrollArea className="h-full">
|
||||
<SearchView
|
||||
onSearch={(term) => setSearchTerm(term)}
|
||||
placeholder={intl.formatMessage(i18n.searchSkillsPlaceholder)}
|
||||
>
|
||||
<div
|
||||
className={`h-full relative transition-all duration-300 ${
|
||||
showContent || showSkeleton ? 'opacity-100 animate-in fade-in' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</SearchView>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Home, MessageSquare, FileText, AppWindow, Clock, Puzzle, Settings } from 'lucide-react';
|
||||
import {
|
||||
Home,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
AppWindow,
|
||||
Clock,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
@@ -15,6 +24,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'home', path: '/', label: 'Home', icon: Home },
|
||||
{ id: 'chat', path: '/pair', label: 'Chat', icon: MessageSquare, hasSubItems: true },
|
||||
{ id: 'recipes', path: '/recipes', label: 'Recipes', icon: FileText },
|
||||
{ id: 'skills', path: '/skills', label: 'Skills', icon: Zap },
|
||||
{ id: 'apps', path: '/apps', label: 'Apps', icon: AppWindow },
|
||||
{ id: 'scheduler', path: '/schedules', label: 'Scheduler', icon: Clock },
|
||||
{ id: 'extensions', path: '/extensions', label: 'Extensions', icon: Puzzle },
|
||||
|
||||
@@ -4109,6 +4109,39 @@
|
||||
"shortcutRecorder.save": {
|
||||
"defaultMessage": "Save"
|
||||
},
|
||||
"skillsView.addSkill": {
|
||||
"defaultMessage": "Add Skill"
|
||||
},
|
||||
"skillsView.adjustSearchTerms": {
|
||||
"defaultMessage": "Try adjusting your search terms"
|
||||
},
|
||||
"skillsView.comingSoon": {
|
||||
"defaultMessage": "Coming soon"
|
||||
},
|
||||
"skillsView.errorLoadingSkills": {
|
||||
"defaultMessage": "Error Loading Skills"
|
||||
},
|
||||
"skillsView.noMatchingSkills": {
|
||||
"defaultMessage": "No matching skills found"
|
||||
},
|
||||
"skillsView.noSkillsDescription": {
|
||||
"defaultMessage": "Skills are loaded from SKILL.md files in ~/.config/agents/skills/, .goose/skills/, or other supported directories."
|
||||
},
|
||||
"skillsView.noSkillsInstalled": {
|
||||
"defaultMessage": "No skills installed"
|
||||
},
|
||||
"skillsView.searchSkillsPlaceholder": {
|
||||
"defaultMessage": "Search skills..."
|
||||
},
|
||||
"skillsView.skillsDescription": {
|
||||
"defaultMessage": "View installed skills that extend Goose capabilities. {shortcut} to search."
|
||||
},
|
||||
"skillsView.skillsTitle": {
|
||||
"defaultMessage": "Skills"
|
||||
},
|
||||
"skillsView.tryAgain": {
|
||||
"defaultMessage": "Try Again"
|
||||
},
|
||||
"spellcheckToggle.description": {
|
||||
"defaultMessage": "Check spelling in the chat input. Requires restart to take effect."
|
||||
},
|
||||
|
||||
@@ -829,6 +829,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
sessions: '/sessions',
|
||||
schedules: '/schedules',
|
||||
recipes: '/recipes',
|
||||
skills: '/skills',
|
||||
permission: '/permission',
|
||||
ConfigureProviders: '/configure-providers',
|
||||
sharedSession: '/shared-session',
|
||||
|
||||
@@ -17,6 +17,7 @@ export type View =
|
||||
| 'sharedSession'
|
||||
| 'loading'
|
||||
| 'recipes'
|
||||
| 'skills'
|
||||
| 'permission';
|
||||
|
||||
export type ViewOptions = {
|
||||
@@ -66,6 +67,9 @@ export const createNavigationHandler = (navigate: NavigateFunction) => {
|
||||
case 'recipes':
|
||||
navigate('/recipes', { state: options });
|
||||
break;
|
||||
case 'skills':
|
||||
navigate('/skills', { state: options });
|
||||
break;
|
||||
case 'permission':
|
||||
navigate('/permission', { state: options });
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user