diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 231bc33d..445997e6 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -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 ; }; +const SkillsRoute = () => { + return ; +}; + 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() { } /> } /> } /> + } /> = ({ 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'); } diff --git a/ui/desktop/src/components/skills/SkillsView.tsx b/ui/desktop/src/components/skills/SkillsView.tsx new file mode 100644 index 00000000..39e027f3 --- /dev/null +++ b/ui/desktop/src/components/skills/SkillsView.tsx @@ -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 ( + +
+
+
+

{skill.name}

+
+

{skill.description}

+
+
+
+ ); +} + +function SkillSkeleton() { + return ( + +
+
+ + +
+
+
+ ); +} + +export default function SkillsView() { + const intl = useIntl(); + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [showSkeleton, setShowSkeleton] = useState(true); + const [error, setError] = useState(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 ( +
+ + + +
+ ); + } + + if (error) { + return ( +
+ +

{intl.formatMessage(i18n.errorLoadingSkills)}

+

{error}

+ +
+ ); + } + + if (skills.length === 0) { + return ( +
+

{intl.formatMessage(i18n.noSkillsInstalled)}

+

+ {intl.formatMessage(i18n.noSkillsDescription)} +

+
+ ); + } + + if (filteredSkills.length === 0 && searchTerm) { + return ( +
+ +

{intl.formatMessage(i18n.noMatchingSkills)}

+

{intl.formatMessage(i18n.adjustSearchTerms)}

+
+ ); + } + + return ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ ); + }; + + return ( + +
+
+
+
+

{intl.formatMessage(i18n.skillsTitle)}

+ +
+

+ {intl.formatMessage(i18n.skillsDescription, { + shortcut: getSearchShortcutText(), + })} +

+
+
+ +
+ + setSearchTerm(term)} + placeholder={intl.formatMessage(i18n.searchSkillsPlaceholder)} + > +
+ {renderContent()} +
+
+
+
+
+
+ ); +} diff --git a/ui/desktop/src/hooks/useNavigationItems.ts b/ui/desktop/src/hooks/useNavigationItems.ts index d8d44fcf..7113f115 100644 --- a/ui/desktop/src/hooks/useNavigationItems.ts +++ b/ui/desktop/src/hooks/useNavigationItems.ts @@ -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 }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 7dfb8cc6..189f60aa 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -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." }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 3fdf3a97..68fe01f1 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -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', diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts index aa4f41b4..e98733bb 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -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;