import { Tabs, useDesignSystem } from "@agent-native/toolkit/design-system"; import { IconArrowUpRight, IconHistory, IconSearch, IconSettings, IconUserCircle, IconUsers, IconX, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode, } from "react"; import { Link } from "react-router"; import { cn } from "../utils.js"; type SettingsTabIcon = ComponentType<{ className?: string }>; /** * A single deep-link target inside settings that the settings search can jump * to. Entries usually map to a section within a tab (via a `hash`/anchor id), * making individual controls discoverable without hunting through every tab. */ export interface SettingsSearchEntry { /** Stable unique id for the entry. */ id: string; /** Primary label shown in the search results. */ label: string; /** Extra space-separated terms to match against (synonyms, provider names). */ keywords?: string; /** Optional secondary description shown under the label. */ description?: string; /** Tab to activate when the entry is picked. Defaults to the owning tab. */ tabId?: string; /** * Optional section id / DOM element id to open + scroll to after switching * tabs. Handled by the inner panels' own hash listeners. */ hash?: string; /** Optional icon override; defaults to the owning tab's icon. */ icon?: SettingsTabIcon; } export interface SettingsTabItem { id: string; label: string; icon?: SettingsTabIcon; content: ReactNode; /** Optional route for settings that live on a canonical page elsewhere. */ href?: string; /** Whether a parent surface may expose a personal/organization scope for this tab. */ scopeAware?: boolean; /** * Optional visual navigation group. Adjacent tabs with the same group render * together; a quiet divider separates each group on desktop while mobile * keeps the compact horizontal tab scroller unchanged. */ group?: string; /** Extra space-separated terms so this tab is findable via search. */ keywords?: string; /** Deep-link entries within this tab for the settings search. */ searchEntries?: SettingsSearchEntry[]; } export interface SettingsTabsPageProps { general: ReactNode; account?: ReactNode; team?: ReactNode; whatsNew?: ReactNode; extraTabs?: SettingsTabItem[]; generalLabel?: string; accountLabel?: string; teamLabel?: string; whatsNewLabel?: string; ariaLabel?: string; defaultTab?: string; className?: string; navClassName?: string; contentClassName?: string; /** Whether to render the settings search box. Defaults to true. */ enableSearch?: boolean; /** Placeholder for the settings search box. */ searchPlaceholder?: string; /** Extra global search entries (e.g. anchors within the General tab). */ searchEntries?: SettingsSearchEntry[]; /** Deep-link entries for the General tab. */ generalSearchEntries?: SettingsSearchEntry[]; /** * Controlled active tab id. When provided, the parent owns tab state (and is * responsible for URL/app-state sync). Recognized top-level tab hashes still * report through `onValueChange`, so shared links such as * `/settings#organization` can select the matching controlled Team tab. * Leave undefined for the default uncontrolled, hash-driven behavior. */ value?: string; /** * Called whenever the active tab changes via a tab click or a search result * selection. Fires in both controlled and uncontrolled modes. */ onValueChange?: (tabId: string) => void; } interface ResolvedSearchEntry extends SettingsSearchEntry { tabId: string; tabLabel: string; icon?: SettingsTabIcon; haystack: string; } function normalizeTabId(value?: string | null): string | null { const normalized = value ?.replace(/^#/, "") .trim() .toLowerCase() .replace(/['"]/g, "") .replace(/[\s_]+/g, "-"); if (!normalized) return null; if ( normalized === "whats-new" || normalized === "what-s-new" || normalized === "changelog" || normalized === "updates" ) { return "whats-new"; } if (normalized === "workspace" || normalized === "workspace-settings") { return "workspace"; } return normalized; } function resolveTabId( tabs: SettingsTabItem[], value?: string | null, ): string | null { const normalized = normalizeTabId(value); if (!normalized) return null; if (tabs.some((tab) => tab.id === normalized)) return normalized; const section = normalized.split(":", 1)[0]; const owner = tabs.find((tab) => tab.searchEntries?.some( (entry) => normalizeTabId(entry.hash ?? entry.id) === section, ), ); if (owner) return owner.id; if ( normalized === "organization" || normalized === "org" || normalized === "team" ) { if (tabs.some((tab) => tab.id === "organization")) return "organization"; if (tabs.some((tab) => tab.id === "team")) return "team"; } return null; } function activeTabFromHash( tabs: SettingsTabItem[], defaultTab: string, ): string { if (typeof window === "undefined") return defaultTab; return resolveTabId(tabs, window.location.hash) ?? defaultTab; } function updateHashForTab(tabId: string) { if (typeof window === "undefined") return; const { pathname, search } = window.location; const hash = tabId === "general" ? "" : `#${encodeURIComponent(tabId)}`; window.history.pushState(null, "", `${pathname}${search}${hash}`); } function isEditableElement(element: Element | null): boolean { if (!(element instanceof HTMLElement)) return false; const tagName = element.tagName.toLowerCase(); return ( tagName === "input" || tagName === "textarea" || tagName === "select" || element.isContentEditable ); } export function SettingsTabsPage({ general, account, team, whatsNew, extraTabs = [], generalLabel = "General", accountLabel = "Account", teamLabel = "Team", whatsNewLabel = "What's new", ariaLabel = "Settings sections", defaultTab = "general", className, navClassName, contentClassName, enableSearch = true, searchPlaceholder = "Search settings", searchEntries, generalSearchEntries, value, onValueChange, }: SettingsTabsPageProps) { const rootRef = useRef(null); const searchInputRef = useRef(null); const autoFocusedSearchRef = useRef(false); const controlledHashRef = useRef(null); const tabs = useMemo(() => { const hasOrganizationTab = extraTabs.some( (tab) => tab.id === "organization", ); const inlineTabs = extraTabs.filter((tab) => !tab.href); const linkedTabs = extraTabs.filter((tab) => tab.href); const next: SettingsTabItem[] = [ { id: "general", label: generalLabel, icon: IconSettings, content: general, searchEntries: generalSearchEntries, }, ]; if (account) { next.push({ id: "account", label: accountLabel, icon: IconUserCircle, content: account, keywords: "profile photo avatar identity signed in email name", }); } next.push(...inlineTabs); if (team && !hasOrganizationTab) { next.push({ id: "team", label: teamLabel, icon: IconUsers, group: "workspace", content: team, }); } if (whatsNew) { next.push({ id: "whats-new", label: whatsNewLabel, icon: IconHistory, group: next.at(-1)?.group ?? "app", content: whatsNew, }); } next.push(...linkedTabs); return next; }, [ account, accountLabel, extraTabs, general, generalLabel, generalSearchEntries, team, teamLabel, whatsNew, whatsNewLabel, ]); const fallbackTab = tabs.some((tab) => tab.id === defaultTab) ? defaultTab : (tabs[0]?.id ?? "general"); const tabGroups = useMemo(() => { const groups: Array<{ id: string; tabs: SettingsTabItem[] }> = []; for (const tab of tabs) { const groupId = tab.group ?? "app"; const previousGroup = groups.at(-1); if (previousGroup?.id === groupId) { previousGroup.tabs.push(tab); } else { groups.push({ id: groupId, tabs: [tab] }); } } return groups; }, [tabs]); const isControlled = value !== undefined; const [internalTab, setInternalTab] = useState(() => activeTabFromHash(tabs, fallbackTab), ); const activeTab = isControlled ? value : internalTab; const [query, setQuery] = useState(""); const designSystem = useDesignSystem(); const hasLinkedTabs = tabs.some((tab) => Boolean(tab.href)); const hasCustomTabs = Boolean(designSystem?.components?.Tabs) && !hasLinkedTabs; const changeTab = useCallback( (tabId: string) => { if (!isControlled) setInternalTab(tabId); onValueChange?.(tabId); }, [isControlled, onValueChange], ); useEffect(() => { // In controlled mode the parent owns (and validates) the active tab. if (isControlled) return; if (tabs.some((tab) => tab.id === internalTab)) return; setInternalTab(fallbackTab); }, [fallbackTab, internalTab, isControlled, tabs]); useEffect(() => { // Hash-driven tab tracking only applies to the uncontrolled mode. Only // react to hashes that name a top-level tab. Section-level hashes // (e.g. `#llm`, `#secrets`) are consumed by the inner panels, so leave // the active tab untouched to avoid bouncing back to General. if (isControlled) return; const handleHashChange = () => { const fromHash = resolveTabId(tabs, window.location.hash); if (fromHash) { setInternalTab(fromHash); } }; window.addEventListener("hashchange", handleHashChange); return () => window.removeEventListener("hashchange", handleHashChange); }, [isControlled, tabs]); useEffect(() => { // Controlled pages retain ownership of their active state, but shared // organization navigation uses a hash deep link. Report only recognized // top-level tab hashes; section hashes remain available to inner panels. if (!isControlled) return; const syncControlledHash = () => { const hash = window.location.hash; const fromHash = resolveTabId(tabs, hash); if (!fromHash || controlledHashRef.current === hash) return; controlledHashRef.current = hash; onValueChange?.(fromHash); }; syncControlledHash(); window.addEventListener("hashchange", syncControlledHash); return () => window.removeEventListener("hashchange", syncControlledHash); }, [isControlled, onValueChange, tabs, value]); useEffect(() => { if (!enableSearch || autoFocusedSearchRef.current) return; if ( typeof window === "undefined" || typeof window.matchMedia !== "function" ) { return; } if (window.matchMedia("(max-width: 767px)").matches) return; const frame = window.requestAnimationFrame(() => { autoFocusedSearchRef.current = true; const input = searchInputRef.current; if (!input) return; const activeElement = document.activeElement; const activeInsideSettings = !!activeElement && rootRef.current?.contains(activeElement); if ( activeElement !== input && (isEditableElement(activeElement) || activeInsideSettings) ) { return; } input.focus({ preventScroll: true }); }); return () => window.cancelAnimationFrame(frame); }, [enableSearch]); const selectedTab = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]; // Flatten tab + deep-link entries into one searchable index. const searchIndex = useMemo(() => { const entries: ResolvedSearchEntry[] = []; const seen = new Set(); const add = (entry: ResolvedSearchEntry) => { if (seen.has(entry.id)) return; seen.add(entry.id); entries.push(entry); }; for (const tab of tabs) { add({ id: `tab:${tab.id}`, label: tab.label, keywords: tab.keywords, tabId: tab.id, tabLabel: tab.label, icon: tab.icon, haystack: `${tab.label} ${tab.keywords ?? ""}`.toLowerCase(), }); for (const sub of tab.searchEntries ?? []) { add({ ...sub, tabId: sub.tabId ?? tab.id, tabLabel: tab.label, icon: sub.icon ?? tab.icon, haystack: `${sub.label} ${sub.keywords ?? ""} ${sub.description ?? ""} ${tab.label}`.toLowerCase(), }); } } for (const sub of searchEntries ?? []) { const owner = tabs.find((tab) => tab.id === (sub.tabId ?? "general")); add({ ...sub, tabId: sub.tabId ?? "general", tabLabel: owner?.label ?? sub.tabId ?? "General", icon: sub.icon ?? owner?.icon, haystack: `${sub.label} ${sub.keywords ?? ""} ${sub.description ?? ""} ${owner?.label ?? ""}`.toLowerCase(), }); } return entries; }, [searchEntries, tabs]); const trimmedQuery = query.trim().toLowerCase(); const results = useMemo(() => { if (!trimmedQuery) return []; const terms = trimmedQuery.split(/\s+/).filter(Boolean); return searchIndex .filter((entry) => terms.every((term) => entry.haystack.includes(term))) .sort((a, b) => { const aStarts = a.label.toLowerCase().startsWith(trimmedQuery) ? 0 : 1; const bStarts = b.label.toLowerCase().startsWith(trimmedQuery) ? 0 : 1; if (aStarts !== bStarts) return aStarts - bStarts; return a.label.localeCompare(b.label); }); }, [searchIndex, trimmedQuery]); const selectEntry = (entry: ResolvedSearchEntry) => { changeTab(entry.tabId); setQuery(""); if (typeof window === "undefined") return; const hash = entry.hash?.replace(/^#/, ""); if (hash) { const { pathname, search } = window.location; window.history.pushState(null, "", `${pathname}${search}#${hash}`); // Let the inner panels open + scroll to their section. window.dispatchEvent(new Event("hashchange")); window.requestAnimationFrame(() => { document .getElementById(hash) ?.scrollIntoView({ block: "start", behavior: "smooth" }); }); } else if (!isControlled) { updateHashForTab(entry.tabId); } }; const searching = enableSearch && trimmedQuery.length > 0; return (
{enableSearch ? (
setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") setQuery(""); if (event.key === "Enter" && results[0]) { event.preventDefault(); selectEntry(results[0]); } }} placeholder={searchPlaceholder} aria-label={searchPlaceholder} className="h-8 w-full rounded-md border border-border bg-background ps-8 pe-7 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/30 focus:ring-2 focus:ring-accent/40" /> {query ? ( ) : null}
) : null} {searching ? (
{results.length === 0 ? (

No matching settings

) : ( results.map((entry) => { const Icon = entry.icon; const tab = tabs.find( (candidate) => candidate.id === entry.tabId, ); const resultHref = tab?.href ? entry.hash ? `${tab.href.split("#", 1)[0]}#${entry.hash.replace(/^#/, "")}` : tab.href : null; const result = ( <> {Icon ? ( ) : null} {entry.label} {entry.description ?? entry.tabLabel} ); return resultHref ? ( {result} ) : ( ); }) )}
) : hasCustomTabs ? ( ({ value: tab.id, label: tab.label, icon: tab.icon ? : null, // The panel stays outside this navigation rail so settings // keeps its existing scroll container and deep-link behavior. content: null, }))} value={activeTab} onChange={(tabId) => { const nextTab = String(tabId); changeTab(nextTab); if (!isControlled) updateHashForTab(nextTab); }} orientation="vertical" aria-label={ariaLabel} className="flex gap-1 overflow-x-auto sm:flex-col sm:overflow-x-visible" /> ) : ( )}
{selectedTab?.content}
); }