Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/config/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";

Check warning on line 1 in ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx

View workflow job for this annotation

GitHub Actions / Frontend

Component 'SkillFormModal' is missing a Storybook story. Create src/components/Admin/SkillFormModal/SkillFormModal.stories.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
Expand Down Expand Up @@ -93,7 +93,8 @@
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;
}

Expand Down
125 changes: 109 additions & 16 deletions ui/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
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,
MessageSquare,
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";
Expand All @@ -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,
Expand All @@ -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<NavEntry[]>((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) {
Expand Down Expand Up @@ -126,21 +172,24 @@ 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 <NavGroupMenu key={entry.label} group={entry} isActive={isActive} />;
}
const Icon = entry.icon;
const active = isActive(entry);
return (
<NavLink
key={item.to}
to={item.to}
key={entry.to}
to={entry.to}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
active ? "bg-muted text-foreground" : "text-muted-foreground"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span>
<span>{entry.label}</span>
</NavLink>
);
})}
Expand All @@ -166,3 +215,47 @@ export function Header({ onMenuClick, showMenuButton = false, className }: Heade
</header>
);
}

/** 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 (
<Dropdown>
<DropdownTrigger
variant="ghost"
className={cn(
"gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium",
"hover:bg-muted hover:text-foreground",
groupActive ? "bg-muted text-foreground" : "text-muted-foreground"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{group.label}</span>
</DropdownTrigger>
<DropdownContent align="start" className="w-48">
{group.items.map((item) => {
const ItemIcon = item.icon;
return (
<DropdownItem
key={item.to}
onClick={() => navigate(item.to)}
className={cn(isActive(item) && "bg-accent text-accent-foreground")}
>
<ItemIcon className="mr-2 h-4 w-4" />
{item.label}
</DropdownItem>
);
})}
</DropdownContent>
</Dropdown>
);
}
4 changes: 4 additions & 0 deletions ui/src/components/PageGuard/PageGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const mainPageOrder: MainPageKey[] = [
"containers",
"api_keys",
"providers",
"templates",
"skills",
"usage",
];

Expand All @@ -26,6 +28,8 @@ const mainPageRoutes: Record<MainPageKey, string> = {
containers: "/containers",
api_keys: "/api-keys",
providers: "/providers",
templates: "/templates",
skills: "/skills",
usage: "/usage",
};

Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/SkillImportModal/SkillImportModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

Check warning on line 1 in ui/src/components/SkillImportModal/SkillImportModal.tsx

View workflow job for this annotation

GitHub Actions / Frontend

Component 'SkillImportModal' is missing a Storybook story. Create src/components/SkillImportModal/SkillImportModal.stories.tsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Brain, Check, Download, Folder, Loader2 } from "lucide-react";

Expand Down Expand Up @@ -31,7 +31,8 @@
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;
}
Expand Down
2 changes: 2 additions & 0 deletions ui/src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const defaultPagesConfig: PagesConfig = {
containers: enabledPage,
api_keys: enabledPage,
providers: enabledPage,
templates: enabledPage,
skills: enabledPage,
usage: enabledPage,
admin: {
dashboard: enabledPage,
Expand Down
2 changes: 2 additions & 0 deletions ui/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface PagesConfig {
containers: PageConfig;
api_keys: PageConfig;
providers: PageConfig;
templates: PageConfig;
skills: PageConfig;
usage: PageConfig;
admin: AdminPagesConfig;
}
Expand Down
Loading
Loading