diff --git a/src/config/ui.rs b/src/config/ui.rs index 49198ed2..182c4e8a 100644 --- a/src/config/ui.rs +++ b/src/config/ui.rs @@ -634,6 +634,10 @@ pub struct PagesConfig { #[serde(default)] pub providers: PageConfig, #[serde(default)] + pub templates: PageConfig, + #[serde(default)] + pub skills: PageConfig, + #[serde(default)] pub usage: PageConfig, #[serde(default)] pub admin: AdminPagesConfig, diff --git a/ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx b/ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx index 59fad754..0c609390 100644 --- a/ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx +++ b/ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx @@ -93,7 +93,8 @@ export interface SkillFormModalProps { open: boolean; onClose: () => void; editingSkill?: SkillResource | null; - ownerOverride: SkillOwner; + /** Owner for created skills. When omitted the server derives it from the caller's auth scope. */ + ownerOverride?: SkillOwner; onSaved?: (skill: SkillResource) => void; } diff --git a/ui/src/components/Header/Header.tsx b/ui/src/components/Header/Header.tsx index e7b4f0a8..c9f36af6 100644 --- a/ui/src/components/Header/Header.tsx +++ b/ui/src/components/Header/Header.tsx @@ -1,8 +1,10 @@ -import { Link, NavLink, useLocation } from "react-router-dom"; +import { Link, NavLink, useLocation, useNavigate } from "react-router-dom"; import { BarChart3, BookOpen, Box, + Brain, + ClipboardPenLine, FolderOpen, Key, Menu, @@ -10,10 +12,17 @@ import { Palette, Server, Shield, + ToolCase, WandSparkles, UsersRound, } from "lucide-react"; import { Button } from "@/components/Button/Button"; +import { + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, +} from "@/components/Dropdown/Dropdown"; import { HadrianIcon } from "@/components/HadrianIcon/HadrianIcon"; import { ThemeToggle } from "@/components/ThemeToggle/ThemeToggle"; import { UserMenu } from "@/components/UserMenu/UserMenu"; @@ -32,18 +41,45 @@ export interface NavItem { pageKey?: string; } -export const navItems: NavItem[] = [ +/** A set of nav items collapsed under a single top-bar dropdown (e.g. "Resources"). */ +export interface NavGroup { + label: string; + icon: React.ComponentType<{ className?: string }>; + items: NavItem[]; +} + +export type NavEntry = NavItem | NavGroup; + +function isNavGroup(entry: NavEntry): entry is NavGroup { + return "items" in entry; +} + +/** Ordered top-bar navigation. Individual links plus grouped dropdowns. */ +export const navEntries: NavEntry[] = [ { to: "/chat", icon: MessageSquare, label: "Chat", pageKey: "chat" }, { to: "/studio", icon: Palette, label: "Studio", pageKey: "studio" }, { to: "/projects", icon: FolderOpen, label: "Projects", pageKey: "projects" }, { to: "/teams", icon: UsersRound, label: "Teams", pageKey: "teams" }, - { to: "/knowledge-bases", icon: BookOpen, label: "Knowledge", pageKey: "knowledge_bases" }, - { to: "/containers", icon: Box, label: "Containers", pageKey: "containers" }, - { to: "/api-keys", icon: Key, label: "API Keys", pageKey: "api_keys" }, - { to: "/providers", icon: Server, label: "Providers", pageKey: "providers" }, { to: "/usage", icon: BarChart3, label: "Usage", pageKey: "usage" }, + { + label: "Resources", + icon: ToolCase, + items: [ + { to: "/api-keys", icon: Key, label: "API Keys", pageKey: "api_keys" }, + { to: "/containers", icon: Box, label: "Containers", pageKey: "containers" }, + { to: "/knowledge-bases", icon: BookOpen, label: "Knowledge", pageKey: "knowledge_bases" }, + { to: "/providers", icon: Server, label: "Providers", pageKey: "providers" }, + { to: "/skills", icon: Brain, label: "Skills", pageKey: "skills" }, + { to: "/templates", icon: ClipboardPenLine, label: "Templates", pageKey: "templates" }, + ], + }, ]; +/** Flattened list of every destination, consumed by the mobile menu in UserMenu. */ +export const navItems: NavItem[] = navEntries.flatMap((entry) => + isNavGroup(entry) ? entry.items : [entry] +); + export const adminNavItem: NavItem = { to: "/admin", icon: Shield, @@ -70,15 +106,25 @@ export function Header({ onMenuClick, showMenuButton = false, className }: Heade ? config.branding.logo_dark_url : config?.branding.logo_url; - // Filter nav items by page visibility - const visibleNavItems = navItems.filter((item) => { + const isItemVisible = (item: NavItem) => { if (!item.pageKey) return true; return getPageConfig(config.pages, item.pageKey).status !== "disabled"; - }); + }; + + // Filter nav entries by page visibility; drop groups left with no visible items. + const visibleNavEntries = navEntries.reduce((acc, entry) => { + if (isNavGroup(entry)) { + const items = entry.items.filter(isItemVisible); + if (items.length > 0) acc.push({ ...entry, items }); + } else if (isItemVisible(entry)) { + acc.push(entry); + } + return acc; + }, []); // Only show admin nav if admin is enabled AND user has admin access const showAdmin = config?.admin.enabled && hasAdminAccess(user); - const allNavItems = showAdmin ? [...visibleNavItems, adminNavItem] : visibleNavItems; + const allNavEntries = showAdmin ? [...visibleNavEntries, adminNavItem] : visibleNavEntries; const isActive = (item: NavItem) => { if (item.matchPrefix) { @@ -126,13 +172,16 @@ export function Header({ onMenuClick, showMenuButton = false, className }: Heade role="navigation" aria-label="Main navigation" > - {allNavItems.map((item) => { - const Icon = item.icon; - const active = isActive(item); + {allNavEntries.map((entry) => { + if (isNavGroup(entry)) { + return ; + } + const Icon = entry.icon; + const active = isActive(entry); return ( ); })} @@ -166,3 +215,47 @@ export function Header({ onMenuClick, showMenuButton = false, className }: Heade ); } + +/** Top-bar dropdown that collapses a group of nav items (e.g. "Resources"). */ +function NavGroupMenu({ + group, + isActive, +}: { + group: NavGroup; + isActive: (item: NavItem) => boolean; +}) { + const navigate = useNavigate(); + const Icon = group.icon; + const groupActive = group.items.some(isActive); + + return ( + + + + + {group.items.map((item) => { + const ItemIcon = item.icon; + return ( + navigate(item.to)} + className={cn(isActive(item) && "bg-accent text-accent-foreground")} + > + + {item.label} + + ); + })} + + + ); +} diff --git a/ui/src/components/PageGuard/PageGuard.tsx b/ui/src/components/PageGuard/PageGuard.tsx index 1922c370..2dec7214 100644 --- a/ui/src/components/PageGuard/PageGuard.tsx +++ b/ui/src/components/PageGuard/PageGuard.tsx @@ -14,6 +14,8 @@ const mainPageOrder: MainPageKey[] = [ "containers", "api_keys", "providers", + "templates", + "skills", "usage", ]; @@ -26,6 +28,8 @@ const mainPageRoutes: Record = { containers: "/containers", api_keys: "/api-keys", providers: "/providers", + templates: "/templates", + skills: "/skills", usage: "/usage", }; diff --git a/ui/src/components/SkillImportModal/SkillImportModal.tsx b/ui/src/components/SkillImportModal/SkillImportModal.tsx index 3e7be7d6..4d041051 100644 --- a/ui/src/components/SkillImportModal/SkillImportModal.tsx +++ b/ui/src/components/SkillImportModal/SkillImportModal.tsx @@ -31,7 +31,8 @@ type ImportTab = "github" | "filesystem"; export interface SkillImportModalProps { open: boolean; onClose: () => void; - ownerOverride: SkillOwner; + /** Owner for imported skills. When omitted the server derives it from the caller's auth scope. */ + ownerOverride?: SkillOwner; /** Which tab to open initially. Defaults to "github". */ initialTab?: ImportTab; } diff --git a/ui/src/config/defaults.ts b/ui/src/config/defaults.ts index e4bc2c3c..5d2cdd34 100644 --- a/ui/src/config/defaults.ts +++ b/ui/src/config/defaults.ts @@ -11,6 +11,8 @@ export const defaultPagesConfig: PagesConfig = { containers: enabledPage, api_keys: enabledPage, providers: enabledPage, + templates: enabledPage, + skills: enabledPage, usage: enabledPage, admin: { dashboard: enabledPage, diff --git a/ui/src/config/types.ts b/ui/src/config/types.ts index 8391f47a..7e72da27 100644 --- a/ui/src/config/types.ts +++ b/ui/src/config/types.ts @@ -14,6 +14,8 @@ export interface PagesConfig { containers: PageConfig; api_keys: PageConfig; providers: PageConfig; + templates: PageConfig; + skills: PageConfig; usage: PageConfig; admin: AdminPagesConfig; } diff --git a/ui/src/pages/SkillsPage.tsx b/ui/src/pages/SkillsPage.tsx new file mode 100644 index 00000000..7a6e250a --- /dev/null +++ b/ui/src/pages/SkillsPage.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type ColumnDef } from "@tanstack/react-table"; +import { ChevronDown, Download, Folder, Plus } from "lucide-react"; + +import { skillDeleteMutation } from "@/api/generated/@tanstack/react-query.gen"; +import type { SkillResource } from "@/api/generated/types.gen"; +import { useAuth } from "@/auth"; +import { Button } from "@/components/Button/Button"; +import { Card, CardContent } from "@/components/Card/Card"; +import { DataTable } from "@/components/DataTable/DataTable"; +import { + Dropdown, + DropdownContent, + DropdownItem, + DropdownTrigger, +} from "@/components/Dropdown/Dropdown"; +import { SkillFormModal } from "@/components/Admin"; +import { SkillImportModal } from "@/components/SkillImportModal/SkillImportModal"; +import { useToast } from "@/components/Toast/Toast"; +import { useConfirm } from "@/components/ConfirmDialog/ConfirmDialog"; +import { useUserSkills } from "@/hooks/useUserSkills"; +import { createSkillColumns } from "@/pages/admin/skillColumns"; +import { formatApiError } from "@/utils/formatApiError"; + +export default function SkillsPage() { + const { user } = useAuth(); + const { toast } = useToast(); + const confirm = useConfirm(); + const queryClient = useQueryClient(); + const { skills, isLoading } = useUserSkills(); + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingSkill, setEditingSkill] = useState(null); + const [importOpen, setImportOpen] = useState(false); + const [importTab, setImportTab] = useState<"github" | "filesystem">("github"); + + const deleteSkillMutation = useMutation({ + ...skillDeleteMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [{ _id: "skillList" }] }); + toast({ title: "Skill deleted", type: "success" }); + }, + onError: (error) => { + toast({ + title: "Failed to delete skill", + description: formatApiError(error), + type: "error", + }); + }, + }); + + const handleEdit = (skill: SkillResource) => { + setEditingSkill(skill); + setIsFormOpen(true); + }; + + const handleDelete = async (skill: SkillResource) => { + const confirmed = await confirm({ + title: "Delete Skill", + message: `Are you sure you want to delete "${skill.name}"? This action cannot be undone.`, + confirmLabel: "Delete", + variant: "destructive", + }); + if (confirmed) { + deleteSkillMutation.mutate({ path: { skill_id: skill.id } }); + } + }; + + const openImport = (tab: "github" | "filesystem") => { + setImportTab(tab); + setImportOpen(true); + }; + + const columns = createSkillColumns(handleEdit, handleDelete); + + // Without a user id the server derives the owner from the caller's auth scope. + const ownerOverride = user?.id ? ({ type: "user", user_id: user.id } as const) : undefined; + + return ( +
+
+
+

Skills

+

+ Reusable, file-based skills the model can invoke across chats and projects +

+
+ + + + + + { + setEditingSkill(null); + setIsFormOpen(true); + }} + > + + Create new + + openImport("github")}> + + Import from GitHub + + openImport("filesystem")}> + + Import from folder + + + +
+ + + + []} + data={skills} + isLoading={isLoading} + emptyMessage="No skills yet. Create one or import from GitHub or a folder." + searchColumn="name" + searchPlaceholder="Search skills..." + /> + + + + { + setIsFormOpen(false); + setEditingSkill(null); + }} + editingSkill={editingSkill} + ownerOverride={ownerOverride} + onSaved={() => { + toast({ + title: editingSkill ? "Skill updated" : "Skill created", + type: "success", + }); + }} + /> + setImportOpen(false)} + ownerOverride={ownerOverride} + initialTab={importTab} + /> +
+ ); +} diff --git a/ui/src/pages/TemplatesPage.tsx b/ui/src/pages/TemplatesPage.tsx new file mode 100644 index 00000000..f32d5be2 --- /dev/null +++ b/ui/src/pages/TemplatesPage.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Plus } from "lucide-react"; + +import { templateDeleteMutation } from "@/api/generated/@tanstack/react-query.gen"; +import type { Template } from "@/api/generated/types.gen"; +import { Button } from "@/components/Button/Button"; +import { Card, CardContent } from "@/components/Card/Card"; +import { DataTable } from "@/components/DataTable/DataTable"; +import { PromptFormModal } from "@/components/PromptFormModal/PromptFormModal"; +import { useToast } from "@/components/Toast/Toast"; +import { useConfirm } from "@/components/ConfirmDialog/ConfirmDialog"; +import { useUserTemplates } from "@/hooks/useUserPrompts"; +import { createTemplateColumns } from "@/pages/admin/promptColumns"; +import { formatApiError } from "@/utils/formatApiError"; + +export default function TemplatesPage() { + const { toast } = useToast(); + const confirm = useConfirm(); + const queryClient = useQueryClient(); + const { templates, isLoading } = useUserTemplates(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingTemplate, setEditingTemplate] = useState