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:
Vincenzo Palazzo
2026-04-09 16:17:56 +02:00
committed by GitHub
parent e8cbfc55da
commit ed5b2448af
7 changed files with 323 additions and 2 deletions
+9
View File
@@ -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>
);
}
+11 -1
View File
@@ -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 },
+33
View File
@@ -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."
},
+1
View File
@@ -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',
+4
View File
@@ -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;