From e5ebca27908c24e59d798e16da69cfe5abd938e6 Mon Sep 17 00:00:00 2001 From: AYUshJaiswal454 Date: Wed, 14 Oct 2026 23:48:56 +0530 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20=E2=8C=98K=20/=20Ctrl+K=20com?= =?UTF-8?q?mand=20palette=20to=20admin=20UI=20(#1010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared navigation items and settings sections into packages/frontend/src/lib/navigation.ts - Import shared navigation definitions in app-sidebar.tsx, settings/layout.tsx, and command-palette.tsx - Implement CommandPalette component on @radix-ui/react-dialog with 5 grouped sections: Pages, Settings, Connectors, MCP servers, and Actions - Support case-insensitive matching across query words, labels, keywords, and descriptions with maximum 8 results per group - Cache connectors and MCP servers on first open and invalidate across organization switches - Enforce role-based access: admin-only settings only for ADMIN, hide "Add connector" for VIEWER, and hide SSO in Cloud - Implement full WAI-ARIA combobox accessibility (role="combobox", role="listbox", role="option", aria-controls, aria-activedescendant, aria-selected) - Add visible header trigger showing ⌘K on macOS and Ctrl K elsewhere - Add comprehensive Playwright e2e test suite covering keyboard shortcuts, navigation, permissions, and accessibility Closes #1010 --- packages/frontend/src/app/settings/layout.tsx | 56 +-- .../frontend/src/components/app-shell.tsx | 42 +- .../frontend/src/components/app-sidebar.tsx | 54 +- .../src/components/command-palette.tsx | 476 ++++++++++++++++++ packages/frontend/src/lib/navigation.ts | 84 ++++ .../tests/e2e/command-palette.spec.ts | 323 ++++++++++++ 6 files changed, 956 insertions(+), 79 deletions(-) create mode 100644 packages/frontend/src/components/command-palette.tsx create mode 100644 packages/frontend/src/lib/navigation.ts create mode 100644 packages/frontend/tests/e2e/command-palette.spec.ts diff --git a/packages/frontend/src/app/settings/layout.tsx b/packages/frontend/src/app/settings/layout.tsx index 71b846526..2edce7a9f 100644 --- a/packages/frontend/src/app/settings/layout.tsx +++ b/packages/frontend/src/app/settings/layout.tsx @@ -6,47 +6,18 @@ import { useAuth } from '@/lib/auth-context'; import { AppShell } from '@/components/app-shell'; import { cn } from '@/lib/utils'; -interface SidebarItem { - href: string; - label: string; - description: string; - icon: React.ComponentType<{ size?: number }>; - exact?: boolean; - adminOnly?: boolean; - /** Hidden in cloud. The backend answers 404 there regardless. */ - selfHostedOnly?: boolean; -} - -interface SidebarSection { - title?: string; - icon?: React.ComponentType<{ size?: number }>; - adminOnly?: boolean; - items: SidebarItem[]; -} - -const SIDEBAR_SECTIONS: SidebarSection[] = [ - { - items: [ - { href: '/settings', label: 'Profile', description: 'Name, email, password', icon: GearIcon, exact: true }, - { href: '/settings/connections', label: 'Connections', description: 'What each AI client can reach', icon: PlugIcon }, - ], - }, - { - title: 'Organization', - icon: BuildingIcon, - items: [ - { href: '/settings/organization', label: 'General', description: 'Workspace and new orgs', icon: BuildingIcon }, - { href: '/settings/users', label: 'Users', description: 'Members and invitations', icon: UsersIcon, adminOnly: true }, - { href: '/settings/roles', label: 'Roles', description: 'MCP tool access control', icon: ShieldIcon, adminOnly: true }, - // Self-hosted only: SSO assumes the workspace owns its own directory, - // tenant and operator. The backend refuses these routes in cloud, so - // showing the entry there would lead an admin to a page that 404s. - { href: '/settings/identity-providers', label: 'Single sign-on', description: 'Microsoft, Google, Okta', icon: FingerprintIcon, adminOnly: true, selfHostedOnly: true }, - { href: '/settings/license', label: 'License', description: 'Plan, billing, subscription', icon: KeyIcon, adminOnly: true }, - { href: '/settings/admin', label: 'Administration', description: 'SMTP, alerts, footer links', icon: WrenchIcon, adminOnly: true }, - ], - }, -]; +import { SIDEBAR_SECTIONS } from '@/lib/navigation'; + +const SETTINGS_ICONS: Record> = { + '/settings': GearIcon, + '/settings/connections': PlugIcon, + '/settings/organization': BuildingIcon, + '/settings/users': UsersIcon, + '/settings/roles': ShieldIcon, + '/settings/identity-providers': FingerprintIcon, + '/settings/license': KeyIcon, + '/settings/admin': WrenchIcon, +}; export default function SettingsLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -75,6 +46,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode if (item.adminOnly && !isAdmin) return null; if (item.selfHostedOnly && isCloud) return null; const isActive = item.exact ? pathname === item.href : pathname.startsWith(item.href); + const Icon = SETTINGS_ICONS[item.href]; return ( - + {Icon && }
{item.label}
{item.description}
diff --git a/packages/frontend/src/components/app-shell.tsx b/packages/frontend/src/components/app-shell.tsx index 1bae6acb1..9e36f363d 100644 --- a/packages/frontend/src/components/app-shell.tsx +++ b/packages/frontend/src/components/app-shell.tsx @@ -1,9 +1,10 @@ 'use client'; import Link from 'next/link'; -import { useState } from 'react'; +import { useState, useSyncExternalStore } from 'react'; import { useTheme } from '@/lib/theme-context'; import { AppSidebar } from '@/components/app-sidebar'; +import { CommandPalette } from '@/components/command-palette'; import { Footer } from '@/components/footer'; import { TrialBanner } from '@/components/trial-banner'; import { SubscriptionBanner } from '@/components/subscription-banner'; @@ -77,13 +78,22 @@ export function AppShell({ children, }: AppShellProps) { const [mobileOpen, setMobileOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); const { resolvedTheme, setTheme } = useTheme(); const isDark = resolvedTheme === 'dark'; + const isMac = useSyncExternalStore( + () => () => {}, + () => /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent || navigator.platform || ''), + () => true + ); + const shortcutLabel = isMac ? '⌘K' : 'Ctrl K'; + const headerTitle = title ?? breadcrumbs?.[breadcrumbs.length - 1]?.label; return (
+ setMobileOpen(false)} />
@@ -130,12 +140,40 @@ export function AppShell({
{subtitle}
)}
- {/* Phones: the theme toggle rides the title row, so the second row + {/* Phones: the search button and theme toggle ride the title row, so the second row belongs entirely to the page's own actions. */} +
+ {actions && (
diff --git a/packages/frontend/src/components/app-sidebar.tsx b/packages/frontend/src/components/app-sidebar.tsx index 6700c53ab..b4302e33e 100644 --- a/packages/frontend/src/components/app-sidebar.tsx +++ b/packages/frontend/src/components/app-sidebar.tsx @@ -39,38 +39,19 @@ const SettingsIcon = () => ( ); -type NavLink = { href: string; label: string; icon: () => React.ReactNode }; -type NavGroup = { group: string; items: NavLink[] }; - -const NAV: NavGroup[] = [ - { - group: 'Overview', - items: [ - { href: '/', label: 'Dashboard', icon: DashboardIcon }, - { href: '/analytics', label: 'Analytics', icon: AnalyticsIcon }, - { href: '/logs', label: 'Audit Log', icon: LogsIcon }, - ], - }, - { - group: 'Build', - items: [ - { href: '/connectors', label: 'Connectors', icon: ConnectorsIcon }, - { href: '/connectors/store', label: 'Marketplace', icon: StoreIcon }, - { href: '/mcp-server', label: 'MCP Servers', icon: McpIcon }, - ], - }, - { - group: 'Intelligence', - items: [ - { href: '/knowledge-graph', label: 'Knowledge Graph', icon: KgIcon }, - { href: '/knowledge-graph/skills', label: 'AI Skills', icon: SkillsIcon }, - ], - }, - { - group: 'Get started', - items: [{ href: '/welcome', label: 'Setup & onboarding', icon: OnboardingIcon }], - }, -]; +import { NAV } from '@/lib/navigation'; + +const NAV_ICONS: Record React.ReactNode> = { + '/': DashboardIcon, + '/analytics': AnalyticsIcon, + '/logs': LogsIcon, + '/connectors': ConnectorsIcon, + '/connectors/store': StoreIcon, + '/mcp-server': McpIcon, + '/knowledge-graph': KgIcon, + '/knowledge-graph/skills': SkillsIcon, + '/welcome': OnboardingIcon, +}; // All navigable hrefs, used to resolve the single active item: the longest // href that prefix-matches the current path wins (so /connectors/store lights @@ -126,7 +107,9 @@ export function AppSidebar({ mobileOpen, onClose }: { mobileOpen: boolean; onClo
{g.group}
- {g.items.map((item) => ( + {g.items.map((item) => { + const Icon = NAV_ICONS[item.href]; + return ( - + {Icon && } {item.label} - ))} + ); + })}
))} diff --git a/packages/frontend/src/components/command-palette.tsx b/packages/frontend/src/components/command-palette.tsx new file mode 100644 index 000000000..58e3ebe25 --- /dev/null +++ b/packages/frontend/src/components/command-palette.tsx @@ -0,0 +1,476 @@ +'use client'; + +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import * as Dialog from '@radix-ui/react-dialog'; +import { useAuth } from '@/lib/auth-context'; +import { useTheme } from '@/lib/theme-context'; +import { connectors, mcpServers } from '@/lib/api'; +import { NAV, SIDEBAR_SECTIONS } from '@/lib/navigation'; +import { cn } from '@/lib/utils'; + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface CommandItem { + id: string; + label: string; + description?: string; + group: 'Pages' | 'Settings' | 'Connectors' | 'MCP servers' | 'Actions'; + keywords?: string[]; + href?: string; + action?: () => void | Promise; +} + +function matchesWords(label: string, keywords: string[] | undefined, description: string | undefined, words: string[]): boolean { + if (words.length === 0) return true; + const searchable = [label, ...(keywords || []), description || ''].join(' ').toLowerCase(); + return words.every((word) => searchable.includes(word)); +} + +function sanitizeId(prefix: string, path: string): string { + const clean = path.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, ''); + return `${prefix}-${clean || 'home'}`; +} + +export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const router = useRouter(); + const { token, user, orgs, switchOrg, logout, deploymentMode } = useAuth(); + const { resolvedTheme, setTheme } = useTheme(); + + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + + // Cached connector and MCP server records + const [connectorsData, setConnectorsData] = useState(null); + const [mcpServersData, setMcpServersData] = useState(null); + const [isLoadingRecords, setIsLoadingRecords] = useState(false); + + // Track the organizationId for which records were loaded + const lastLoadedOrgIdRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + const isAdmin = user?.role === 'ADMIN'; + const isViewer = user?.role === 'VIEWER'; + const isCloud = deploymentMode === 'cloud'; + const isDark = resolvedTheme === 'dark'; + + // Keyboard shortcut listener: ⌘K on macOS, Ctrl+K elsewhere (works anywhere inside AppShell) + useEffect(() => { + function handleGlobalKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + onOpenChange(!open); + } + } + window.addEventListener('keydown', handleGlobalKeyDown, true); + return () => window.removeEventListener('keydown', handleGlobalKeyDown, true); + }, [open, onOpenChange]); + + // Load records on first open or reload when workspace (organizationId) changes + useEffect(() => { + if (!open || !token) return; + + const currentOrgId = user?.organizationId ?? null; + const needsFetch = connectorsData === null || lastLoadedOrgIdRef.current !== currentOrgId; + + if (needsFetch) { + setIsLoadingRecords(true); + Promise.allSettled([connectors.list(token), mcpServers.list(token)]) + .then(([connRes, mcpRes]) => { + if (connRes.status === 'fulfilled' && Array.isArray(connRes.value)) { + setConnectorsData(connRes.value); + } else { + setConnectorsData([]); + } + if (mcpRes.status === 'fulfilled' && Array.isArray(mcpRes.value)) { + setMcpServersData(mcpRes.value); + } else { + setMcpServersData([]); + } + lastLoadedOrgIdRef.current = currentOrgId; + }) + .catch(() => { + // Graceful fallback: pages, settings and actions still work + setConnectorsData([]); + setMcpServersData([]); + }) + .finally(() => { + setIsLoadingRecords(false); + }); + } + }, [open, token, user?.organizationId, connectorsData]); + + // Reset query and selection when dialog opens/closes + const handleOpenChange = useCallback((nextOpen: boolean) => { + if (nextOpen) { + setQuery(''); + setActiveIndex(0); + } + onOpenChange(nextOpen); + }, [onOpenChange]); + + // Parse words from search query + const queryWords = useMemo(() => { + return query.trim().toLowerCase().split(/\s+/).filter(Boolean); + }, [query]); + + // Generate candidates for all 5 groups + const groupedResults = useMemo(() => { + // 1. Pages + const pagesCandidates: CommandItem[] = NAV.flatMap((group) => + group.items.map((item) => ({ + id: sanitizeId('page', item.href), + label: item.label, + description: group.group, + group: 'Pages' as const, + keywords: item.keywords, + href: item.href, + })) + ); + const pages = pagesCandidates + .filter((i) => matchesWords(i.label, i.keywords, i.description, queryWords)) + .slice(0, 8); + + // 2. Settings + const settingsCandidates: CommandItem[] = SIDEBAR_SECTIONS.flatMap((section) => + section.items + .filter((item) => { + if (item.adminOnly && !isAdmin) return false; + if (item.selfHostedOnly && isCloud) return false; + return true; + }) + .map((item) => ({ + id: sanitizeId('settings', item.href), + label: item.label, + description: item.description, + group: 'Settings' as const, + keywords: item.keywords, + href: item.href, + })) + ); + const settings = settingsCandidates + .filter((i) => matchesWords(i.label, i.keywords, i.description, queryWords)) + .slice(0, 8); + + // 3. Connectors + const connectorItems: CommandItem[] = (connectorsData || []).map((c) => ({ + id: `connector-${c.id}`, + label: c.name || 'Unnamed connector', + description: c.type ? `${c.type} connector` : undefined, + group: 'Connectors' as const, + keywords: [c.type, c.slug, 'connector'].filter(Boolean), + href: `/connectors/${c.id}`, + })); + const connectorsGroup = connectorItems + .filter((i) => matchesWords(i.label, i.keywords, i.description, queryWords)) + .slice(0, 8); + + // 4. MCP servers + const mcpItems: CommandItem[] = (mcpServersData || []).map((s) => ({ + id: `mcp-${s.id}`, + label: s.name || 'Unnamed server', + description: s.slug || 'MCP server', + group: 'MCP servers' as const, + keywords: [s.slug, 'mcp', 'server'].filter(Boolean), + href: `/mcp-server/${s.id}`, + })); + const mcpServersGroup = mcpItems + .filter((i) => matchesWords(i.label, i.keywords, i.description, queryWords)) + .slice(0, 8); + + // 5. Actions + const actionCandidates: CommandItem[] = []; + + // Add connector (hidden for VIEWER) + if (!isViewer) { + actionCandidates.push({ + id: 'action-add-connector', + label: 'Add connector', + description: 'Create a new connector', + group: 'Actions' as const, + keywords: ['add connector', 'create', 'new', 'connector', 'import'], + href: '/connectors/new', + }); + } + + actionCandidates.push({ + id: 'action-browse-marketplace', + label: 'Browse Marketplace', + description: 'Find and install connectors', + group: 'Actions' as const, + keywords: ['browse marketplace', 'store', 'marketplace', 'catalog', 'adapters'], + href: '/connectors/store', + }); + + actionCandidates.push({ + id: 'action-toggle-theme', + label: 'Toggle theme', + description: isDark ? 'Switch to light mode' : 'Switch to dark mode', + group: 'Actions' as const, + keywords: ['toggle theme', 'theme', 'dark', 'light', 'mode'], + action: () => setTheme(isDark ? 'light' : 'dark'), + }); + + // Workspace switchers + const otherOrgs = (orgs || []).filter((o) => o.id !== user?.organizationId); + for (const org of otherOrgs) { + actionCandidates.push({ + id: `action-switch-org-${org.id}`, + label: `Switch to ${org.name}`, + description: `Workspace (${org.role})`, + group: 'Actions' as const, + keywords: ['switch to', 'workspace', 'organization', 'org', org.name], + action: () => switchOrg(org.id), + }); + } + + actionCandidates.push({ + id: 'action-logout', + label: 'Log out', + description: 'Sign out of your account', + group: 'Actions' as const, + keywords: ['log out', 'logout', 'sign out'], + action: () => logout(), + }); + + const actions = actionCandidates + .filter((i) => matchesWords(i.label, i.keywords, i.description, queryWords)) + .slice(0, 8); + + return [ + { name: 'Pages', items: pages }, + { name: 'Settings', items: settings }, + { name: 'Connectors', items: connectorsGroup }, + { name: 'MCP servers', items: mcpServersGroup }, + { name: 'Actions', items: actions }, + ].filter((group) => group.items.length > 0); + }, [queryWords, isAdmin, isViewer, isCloud, isDark, connectorsData, mcpServersData, orgs, user?.organizationId, setTheme, switchOrg, logout]); + + // Flattened items for single-index keyboard navigation + const flatItems = useMemo(() => { + return groupedResults.flatMap((g) => g.items); + }, [groupedResults]); + + // Ensure activeIndex is bounded + useEffect(() => { + if (flatItems.length === 0) { + setActiveIndex(-1); + } else if (activeIndex >= flatItems.length || activeIndex < 0) { + setActiveIndex(0); + } + }, [flatItems, activeIndex]); + + // Scroll active item into view + useEffect(() => { + if (activeIndex >= 0 && listRef.current) { + const activeEl = listRef.current.querySelector(`[data-index="${activeIndex}"]`) as HTMLElement | null; + if (activeEl) { + activeEl.scrollIntoView({ block: 'nearest' }); + } + } + }, [activeIndex]); + + const executeItem = useCallback((item: CommandItem) => { + handleOpenChange(false); + if (item.action) { + item.action(); + } else if (item.href) { + router.push(item.href); + } + }, [handleOpenChange, router]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (flatItems.length > 0) { + setActiveIndex((prev) => (prev + 1) % flatItems.length); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (flatItems.length > 0) { + setActiveIndex((prev) => (prev - 1 + flatItems.length) % flatItems.length); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < flatItems.length) { + executeItem(flatItems[activeIndex]); + } + } + }; + + const activeItem = activeIndex >= 0 ? flatItems[activeIndex] : null; + + let currentIndexTracker = 0; + + return ( + + + + + Command Palette + + {/* Search Input Bar */} +
+ + + + + { + setQuery(e.target.value); + setActiveIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Type a command or search..." + className="flex-1 bg-transparent text-[14px] text-[var(--text)] placeholder-[var(--text-3)] focus:outline-none" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + autoFocus + /> + {query ? ( + + ) : ( + + ESC + + )} +
+ + {/* Results Area */} +
+ {isLoadingRecords && ( +
+ + Loading connectors and servers... +
+ )} + + {groupedResults.length === 0 && !isLoadingRecords ? ( +
+ {query ? ( + <>No results found for “{query}” + ) : ( + 'No commands available' + )} +
+ ) : ( + groupedResults.map((group) => ( +
+
+ {group.name} +
+ {group.items.map((item) => { + const itemIndex = currentIndexTracker++; + const isSelected = itemIndex === activeIndex; + + return ( +
executeItem(item)} + onMouseEnter={() => setActiveIndex(itemIndex)} + className={cn( + 'flex cursor-pointer items-center justify-between gap-3 rounded-[9px] px-2.5 py-2 text-left text-[13px] transition-colors', + isSelected + ? 'bg-[var(--brand-tint)] text-[var(--brand)] font-medium' + : 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]' + )} + > +
+
+ {item.label} +
+ {item.description && ( +
+ {item.description} +
+ )} +
+
+ {isSelected && ( + + ↵ + + )} +
+
+ ); + })} +
+ )) + )} +
+ + {/* Footer Navigation Hints */} +
+
+ + ↑ + ↓ + Navigate + + + ↵ + Select + + + esc + Close + +
+
+ AnythingMCP +
+
+
+
+
+ ); +} diff --git a/packages/frontend/src/lib/navigation.ts b/packages/frontend/src/lib/navigation.ts new file mode 100644 index 000000000..731515f2f --- /dev/null +++ b/packages/frontend/src/lib/navigation.ts @@ -0,0 +1,84 @@ +export interface NavItem { + href: string; + label: string; + keywords?: string[]; +} + +export interface NavGroup { + group: string; + items: NavItem[]; +} + +export interface SettingsItem { + href: string; + label: string; + description: string; + exact?: boolean; + adminOnly?: boolean; + /** Hidden in cloud. The backend answers 404 there regardless. */ + selfHostedOnly?: boolean; + keywords?: string[]; +} + +export interface SettingsSection { + title?: string; + adminOnly?: boolean; + items: SettingsItem[]; +} + +export const NAV: NavGroup[] = [ + { + group: 'Overview', + items: [ + { href: '/', label: 'Dashboard', keywords: ['home', 'overview', 'dashboard', 'main'] }, + { href: '/analytics', label: 'Analytics', keywords: ['analytics', 'metrics', 'stats', 'charts'] }, + { href: '/logs', label: 'Audit Log', keywords: ['audit', 'logs', 'audit log', 'history', 'activity'] }, + ], + }, + { + group: 'Build', + items: [ + { href: '/connectors', label: 'Connectors', keywords: ['connectors', 'apis', 'integrations', 'rest', 'soap'] }, + { href: '/connectors/store', label: 'Marketplace', keywords: ['marketplace', 'store', 'catalog', 'adapters'] }, + { href: '/mcp-server', label: 'MCP Servers', keywords: ['mcp servers', 'servers', 'mcp', 'tools', 'endpoints'] }, + ], + }, + { + group: 'Intelligence', + items: [ + { href: '/knowledge-graph', label: 'Knowledge Graph', keywords: ['knowledge graph', 'kg', 'graph', 'entities'] }, + { href: '/knowledge-graph/skills', label: 'AI Skills', keywords: ['ai skills', 'skills', 'prompts'] }, + ], + }, + { + group: 'Get started', + items: [ + { href: '/welcome', label: 'Setup & onboarding', keywords: ['setup', 'onboarding', 'welcome', 'getting started', 'guide'] }, + ], + }, +]; + +export const SIDEBAR_SECTIONS: SettingsSection[] = [ + { + items: [ + { href: '/settings', label: 'Profile', description: 'Name, email, password', exact: true, keywords: ['profile', 'account', 'user', 'password', 'name', 'email'] }, + { href: '/settings/connections', label: 'Connections', description: 'What each AI client can reach', keywords: ['connections', 'clients', 'ai', 'claude', 'chatgpt', 'reach'] }, + ], + }, + { + title: 'Organization', + items: [ + { href: '/settings/organization', label: 'General', description: 'Workspace and new orgs', keywords: ['organization', 'general', 'workspace', 'org'] }, + { href: '/settings/users', label: 'Users', description: 'Members and invitations', adminOnly: true, keywords: ['users', 'members', 'invitations', 'team', 'invite'] }, + { href: '/settings/roles', label: 'Roles', description: 'MCP tool access control', adminOnly: true, keywords: ['roles', 'permissions', 'access', 'rbac', 'control'] }, + // Self-hosted only: SSO assumes the workspace owns its own directory, + // tenant and operator. The backend refuses these routes in cloud, so + // showing the entry there would lead an admin to a page that 404s. + { href: '/settings/identity-providers', label: 'Single sign-on', description: 'Microsoft, Google, Okta', adminOnly: true, selfHostedOnly: true, keywords: ['single sign-on', 'sso', 'saml', 'oidc', 'microsoft', 'google', 'okta', 'identity'] }, + { href: '/settings/license', label: 'License', description: 'Plan, billing, subscription', adminOnly: true, keywords: ['license', 'billing', 'plan', 'subscription'] }, + { href: '/settings/admin', label: 'Administration', description: 'SMTP, alerts, footer links', adminOnly: true, keywords: ['administration', 'admin', 'smtp', 'alerts', 'footer', 'email', 'system'] }, + ], + }, +]; + +export { SIDEBAR_SECTIONS as SETTINGS_SECTIONS }; diff --git a/packages/frontend/tests/e2e/command-palette.spec.ts b/packages/frontend/tests/e2e/command-palette.spec.ts new file mode 100644 index 000000000..1d6d8cb76 --- /dev/null +++ b/packages/frontend/tests/e2e/command-palette.spec.ts @@ -0,0 +1,323 @@ +import { expect, test, type Page } from '@playwright/test'; + +const ADMIN_USER = { + id: 'u1', + email: 'admin@example.com', + name: 'Admin User', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +const VIEWER_USER = { + id: 'u2', + email: 'viewer@example.com', + name: 'Viewer User', + role: 'VIEWER', + organizationId: 'o1', + emailVerified: true, +}; + +async function setupSession( + page: Page, + options: { + user?: typeof ADMIN_USER | typeof VIEWER_USER; + deploymentMode?: 'self-hosted' | 'cloud'; + connectorsList?: any[]; + mcpServersList?: any[]; + } = {} +) { + const user = options.user ?? ADMIN_USER; + const deploymentMode = options.deploymentMode ?? 'self-hosted'; + const connectorsList = options.connectorsList ?? [ + { id: 'c1', name: 'Stripe Payments', type: 'REST', slug: 'stripe' }, + ]; + const mcpServersList = options.mcpServersList ?? [ + { id: 's1', name: 'Search Server', slug: 'search-server' }, + ]; + + const baseURL = test.info().project.use.baseURL ?? 'http://localhost:3100'; + await page.context().addCookies([{ name: 'amcp_token', value: 'test-token', url: baseURL }]); + await page.addInitScript((u) => { + localStorage.setItem('amcp_token', 'test-token'); + localStorage.setItem('amcp_user', JSON.stringify(u)); + }, user); + + await page.route(/\/(api|health)\//, async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const p = url.pathname; + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + + if (p === '/health/server-info') { + return json({ + deploymentMode, + mcpAuthMode: 'oauth2', + hasUsers: true, + registrationEnabled: true, + ssoProviders: [], + }); + } + if (p.endsWith('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); + if (p.endsWith('/api/users/me')) return json(user); + if (p.endsWith('/api/organizations/current')) return json({ id: 'o1', name: 'Acme', createdAt: '2026-01-01' }); + if (p.endsWith('/api/organizations/mine')) { + return json([ + { id: 'o1', name: 'Acme', role: user.role, joinedAt: '2026-01-01' }, + { id: 'o2', name: 'Beta Corp', role: 'MEMBER', joinedAt: '2026-01-01' }, + ]); + } + if (p.endsWith('/api/license/status')) return json({ plan: 'community', status: 'active' }); + if (p.endsWith('/api/connectors/c1')) return json({ id: 'c1', name: 'Stripe Payments', type: 'REST', slug: 'stripe' }); + if (p.endsWith('/api/connectors')) return json(connectorsList); + if (p.endsWith('/api/mcp-servers/s1/activity')) return json({ calls30d: 0, lastCallAt: null }); + if (p.endsWith('/api/mcp-servers/s1')) return json({ id: 's1', name: 'Search Server', slug: 'search-server', isActive: true, apiKeys: [], connectors: [] }); + if (p.endsWith('/api/mcp-servers')) return json(mcpServersList); + if (p.endsWith('/api/audit/stats')) return json({ invocations24h: 0, errors24h: 0 }); + if (p.endsWith('/api/audit/analytics')) return json({ daily: [], topTools: [], totalInvocations: 0, successRate: 100, avgDuration: 0 }); + if (p.includes('/api/audit/invocations')) return json([]); + return json({}); + }); +} + +async function openCommandPalette(page: Page) { + const palette = page.getByRole('dialog'); + await expect.poll(async () => { + if (await palette.isVisible()) return true; + await page.keyboard.press('ControlOrMeta+k'); + return await palette.isVisible(); + }, { timeout: 7000, intervals: [150, 300, 500] }).toBe(true); + + const input = page.getByRole('combobox'); + await expect(input).toBeVisible(); + return { palette, input }; +} + +test.describe('Command Palette', () => { + test('opens via Ctrl+K / ⌘K and header button, and closes via Esc on AppShell pages', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const palette = page.getByRole('dialog'); + await expect(palette).not.toBeVisible(); + + // Open via keyboard shortcut + await expect.poll(async () => { + if (await palette.isVisible()) return true; + await page.keyboard.press('ControlOrMeta+k'); + return await palette.isVisible(); + }, { timeout: 7000, intervals: [150, 300, 500] }).toBe(true); + + // Combobox input should be focused + const searchInput = page.getByRole('combobox'); + await expect(searchInput).toBeFocused(); + + // Close via Esc + await page.keyboard.press('Escape'); + await expect(palette).not.toBeVisible(); + + // Open via visible trigger button in header + const searchButton = page.getByRole('button', { name: 'Open command palette' }).first(); + await expect(searchButton).toBeVisible(); + await searchButton.click(); + await expect(palette).toBeVisible(); + + // Close again via Esc + await page.keyboard.press('Escape'); + await expect(palette).not.toBeVisible(); + }); + + test('does not open on /login page', async ({ page }) => { + await page.route(/\/(api|health)\//, async (route) => { + const p = new URL(route.request().url()).pathname; + if (p === '/health/server-info') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + deploymentMode: 'self-hosted', + mcpAuthMode: 'oauth2', + hasUsers: true, + registrationEnabled: true, + ssoProviders: [], + }), + }); + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + }); + + await page.goto('/login'); + await page.waitForLoadState('domcontentloaded'); + + await page.keyboard.press('ControlOrMeta+k'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('typing "audit" and Enter opens /logs', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + + await input.fill('audit'); + await expect(page.getByRole('option', { name: /Audit Log/i })).toBeVisible(); + + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/logs$/); + }); + + test('typing part of a connector name opens that connector', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + + await input.fill('stripe'); + const option = page.getByRole('option', { name: /Stripe Payments/i }); + await expect(option).toBeVisible(); + + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/connectors\/c1$/); + }); + + test('typing part of an MCP server name opens that server', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + + await input.fill('search'); + const option = page.getByRole('option', { name: /Search Server/i }); + await expect(option).toBeVisible(); + + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/mcp-server\/s1$/); + }); + + test('admin sees "Users", "Add connector", and "Single sign-on" on self-hosted', async ({ page }) => { + await setupSession(page, { user: ADMIN_USER, deploymentMode: 'self-hosted' }); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + + // Check Users in Settings + await input.fill('users'); + await expect(page.getByRole('option', { name: /Users/i })).toBeVisible(); + + // Check Single sign-on in Settings + await input.fill('single sign-on'); + await expect(page.getByRole('option', { name: /Single sign-on/i })).toBeVisible(); + + // Check Add connector in Actions + await input.fill('add connector'); + await expect(page.getByRole('option', { name: /Add connector/i })).toBeVisible(); + }); + + test('viewer sees neither "Users" nor "Add connector"', async ({ page }) => { + await setupSession(page, { user: VIEWER_USER, deploymentMode: 'self-hosted' }); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + + // Viewer should not see Users + await input.fill('users'); + await expect(page.getByRole('option', { name: /Users/i })).not.toBeVisible(); + + // Viewer should not see Add connector + await input.fill('add connector'); + await expect(page.getByRole('option', { name: /Add connector/i })).not.toBeVisible(); + }); + + test('with deploymentMode: "cloud", "Single sign-on" is not listed', async ({ page }) => { + await setupSession(page, { user: ADMIN_USER, deploymentMode: 'cloud' }); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + + await input.fill('single sign-on'); + await expect(page.getByRole('option', { name: /Single sign-on/i })).not.toBeVisible(); + }); + + test('sidebar and settings navigation look and behave as before', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + // Sidebar navigation check + const sidebar = page.locator('aside'); + await expect(sidebar.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + await expect(sidebar.getByRole('link', { name: 'Connectors' })).toBeVisible(); + await expect(sidebar.getByRole('link', { name: 'Audit Log' })).toBeVisible(); + + // Navigate to Settings + await page.goto('/settings'); + await expect(page.getByRole('link', { name: /Profile/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /Connections/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /Users/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /Roles/i })).toBeVisible(); + }); + + test('keyboard arrow navigation and ARIA attributes', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + + // ARIA attributes on input + await expect(input).toHaveAttribute('aria-expanded', 'true'); + await expect(input).toHaveAttribute('aria-controls', 'command-palette-results'); + await expect(input).toHaveAttribute('aria-autocomplete', 'list'); + + // Listbox and options + const listbox = page.getByRole('listbox', { name: 'Commands' }); + await expect(listbox).toBeVisible(); + + const initialDescendant = await input.getAttribute('aria-activedescendant'); + expect(initialDescendant).toBeTruthy(); + + // Press ArrowDown to change active option + await page.keyboard.press('ArrowDown'); + const nextDescendant = await input.getAttribute('aria-activedescendant'); + expect(nextDescendant).toBeTruthy(); + expect(nextDescendant).not.toBe(initialDescendant); + + // Selected option has aria-selected="true" + const activeOption = page.locator(`#${nextDescendant}`); + await expect(activeOption).toHaveAttribute('aria-selected', 'true'); + + // Press ArrowUp to go back + await page.keyboard.press('ArrowUp'); + const backDescendant = await input.getAttribute('aria-activedescendant'); + expect(backDescendant).toBe(initialDescendant); + }); + + test('common actions: Toggle theme, Switch workspace, and Log out are listed and functional', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const html = page.locator('html'); + const wasDark = await html.evaluate((el) => el.classList.contains('dark')); + + // Test Toggle theme action + const { input } = await openCommandPalette(page); + await input.fill('toggle theme'); + await expect(page.getByRole('option', { name: /Toggle theme/i })).toBeVisible(); + await page.keyboard.press('Enter'); + + // Dialog closes and theme switches + await expect(page.getByRole('dialog')).not.toBeVisible(); + if (wasDark) { + await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/); + } else { + await expect(html).toHaveClass(/(^|\s)dark(\s|$)/); + } + + // Test Switch to other workspace and Log out are listed + const second = await openCommandPalette(page); + await second.input.fill('Switch to Beta Corp'); + await expect(page.getByRole('option', { name: /Switch to Beta Corp/i })).toBeVisible(); + + await second.input.fill('Log out'); + await expect(page.getByRole('option', { name: /Log out/i })).toBeVisible(); + }); +}); From 9cc3a592c9e6dc112f5768bc96d38501713f522e Mon Sep 17 00:00:00 2001 From: AYUshJaiswal454 Date: Fri, 16 Oct 2026 01:44:43 +0530 Subject: [PATCH 2/4] fix(frontend): address review comments on command palette (#1094) - Guard against non-array orgs responses with Array.isArray in sidebar and command palette - Lazily render command palette results in CommandPaletteContent inside Dialog.Content - Restore keyboard focus to opening trigger element on close via onCloseAutoFocus - Reset search query to empty on palette open - Cache connectors and MCP servers in module-level cache keyed by organizationId - Add platform-specific shortcut detection (Meta+K on Mac, Ctrl+K elsewhere) and reject Shift/Alt - Centralize shared settings visibility filtering in lib/navigation.ts - Correct listbox ARIA hierarchy with role='group', aria-labelledby, and external status - Export Connector and McpServer interfaces in lib/api.ts - Ignore Enter during IME composition and clean up unused SETTINGS_SECTIONS and import ordering - Add comprehensive regression test suite in command-palette.spec.ts --- packages/frontend/src/app/settings/layout.tsx | 71 +- .../frontend/src/components/app-sidebar.tsx | 5 +- .../src/components/command-palette.tsx | 697 +++++++++++------- packages/frontend/src/lib/api.ts | 35 +- packages/frontend/src/lib/navigation.ts | 37 +- .../tests/e2e/command-palette.spec.ts | 298 +++++++- 6 files changed, 831 insertions(+), 312 deletions(-) diff --git a/packages/frontend/src/app/settings/layout.tsx b/packages/frontend/src/app/settings/layout.tsx index 2edce7a9f..0c3207db9 100644 --- a/packages/frontend/src/app/settings/layout.tsx +++ b/packages/frontend/src/app/settings/layout.tsx @@ -6,7 +6,7 @@ import { useAuth } from '@/lib/auth-context'; import { AppShell } from '@/components/app-shell'; import { cn } from '@/lib/utils'; -import { SIDEBAR_SECTIONS } from '@/lib/navigation'; +import { SIDEBAR_SECTIONS, getVisibleSettingsSections } from '@/lib/navigation'; const SETTINGS_ICONS: Record> = { '/settings': GearIcon, @@ -24,6 +24,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode const { user, deploymentMode } = useAuth(); const isAdmin = user?.role === 'ADMIN'; const isCloud = deploymentMode === 'cloud'; + const visibleSections = getVisibleSettingsSections(SIDEBAR_SECTIONS, { isAdmin, isCloud }); return ( @@ -32,43 +33,37 @@ export default function SettingsLayout({ children }: { children: React.ReactNode {/* Below lg this is a scrolling strip. The scrollbar is hidden, so the soft trailing edge is what says there is more to the right. */} {/* Main content */} diff --git a/packages/frontend/src/components/app-sidebar.tsx b/packages/frontend/src/components/app-sidebar.tsx index b4302e33e..3076db084 100644 --- a/packages/frontend/src/components/app-sidebar.tsx +++ b/packages/frontend/src/components/app-sidebar.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/lib/auth-context'; import { LogoIcon } from '@/components/logo-icon'; import { cn } from '@/lib/utils'; import { GithubStarButton } from '@/components/github-star-button'; +import { NAV } from '@/lib/navigation'; /* ── Inline icons (match the redesign prototype) ── */ function I({ d, children, ...p }: { d?: string; children?: React.ReactNode } & React.SVGProps) { @@ -39,8 +40,6 @@ const SettingsIcon = () => ( ); -import { NAV } from '@/lib/navigation'; - const NAV_ICONS: Record React.ReactNode> = { '/': DashboardIcon, '/analytics': AnalyticsIcon, @@ -139,7 +138,7 @@ export function AppSidebar({ mobileOpen, onClose }: { mobileOpen: boolean; onClo
Switch workspace
- {(orgs || []).map((org) => { + {(Array.isArray(orgs) ? orgs : []).map((org) => { const isCurrent = org.id === user?.organizationId; return ( - ) : ( - - ESC - - )} -
- - {/* Results Area */} -
- {isLoadingRecords && ( -
- - Loading connectors and servers... -
- )} - - {groupedResults.length === 0 && !isLoadingRecords ? ( -
- {query ? ( - <>No results found for “{query}” - ) : ( - 'No commands available' - )} -
- ) : ( - groupedResults.map((group) => ( -
-
- {group.name} -
- {group.items.map((item) => { - const itemIndex = currentIndexTracker++; - const isSelected = itemIndex === activeIndex; - - return ( -
executeItem(item)} - onMouseEnter={() => setActiveIndex(itemIndex)} - className={cn( - 'flex cursor-pointer items-center justify-between gap-3 rounded-[9px] px-2.5 py-2 text-left text-[13px] transition-colors', - isSelected - ? 'bg-[var(--brand-tint)] text-[var(--brand)] font-medium' - : 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]' - )} - > -
-
- {item.label} -
- {item.description && ( -
- {item.description} -
- )} -
-
- {isSelected && ( - - ↵ - - )} -
-
- ); - })} -
- )) - )} -
- - {/* Footer Navigation Hints */} -
-
- - ↑ - ↓ - Navigate - - - ↵ - Select - - - esc - Close - -
-
- AnythingMCP -
-
+ + ) : ( + + ESC + + )} +
+ + {/* Loading Status: rendered outside the listbox to conform to ARIA standards */} + {isLoadingRecords && ( +
+ + Loading connectors and servers... +
+ )} + + {/* Results Area */} + + + {/* Footer Navigation Hints */} +
+
+ + ↑ + ↓ + Navigate + + + ↵ + Select + + + esc + Close + +
+
+ AnythingMCP +
+
+ + ); +} + +export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const triggerElementRef = useRef(null); + + // Keyboard shortcut listener: ⌘K on macOS, Ctrl+K on other platforms + // Ignores shortcut when Shift or Alt is held, does not hijack macOS Ctrl+K + useEffect(() => { + function handleGlobalKeyDown(e: KeyboardEvent) { + if (e.defaultPrevented) return; + if (e.key !== 'k' && e.key !== 'K') return; + if (e.shiftKey || e.altKey) return; + + const isMac = isMacPlatform(); + const matches = isMac ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey); + + if (matches) { + e.preventDefault(); + if (!open && typeof document !== 'undefined') { + triggerElementRef.current = (document.activeElement as HTMLElement) ?? null; + } + onOpenChange(!open); + } + } + window.addEventListener('keydown', handleGlobalKeyDown, true); + return () => window.removeEventListener('keydown', handleGlobalKeyDown, true); + }, [open, onOpenChange]); + + // Track the trigger element when dialog opens + useEffect(() => { + if (open && typeof document !== 'undefined' && !triggerElementRef.current) { + triggerElementRef.current = (document.activeElement as HTMLElement) ?? null; + } + }, [open]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen && typeof document !== 'undefined' && !triggerElementRef.current) { + triggerElementRef.current = (document.activeElement as HTMLElement) ?? null; + } + onOpenChange(nextOpen); + }, + [onOpenChange] + ); + + // Restore keyboard focus to the opening element when dialog closes + const handleCloseAutoFocus = useCallback((event: Event) => { + const el = triggerElementRef.current; + triggerElementRef.current = null; + if (el && typeof el.focus === 'function' && el.isConnected) { + event.preventDefault(); + el.focus(); + } + }, []); + + return ( + + + + + Command Palette + {open && onOpenChange(false)} />} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index ade9c138c..de3e6eee2 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -347,9 +347,25 @@ export function withSecretsNotice(message: string, res: unknown): string { return parts.filter(Boolean).join(' '); } +export interface Connector { + id: string; + name: string; + type: string; + slug?: string; + baseUrl?: string; + isActive?: boolean; + authType?: string; + icon?: string | null; + setupStatus?: string; + missingVariables?: string[]; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +} + export const connectors = { list: (token: string) => - request('/api/connectors', { token }), + request('/api/connectors', { token }), proxyAvailability: (token: string) => request<{ available: boolean }>('/api/connectors/proxy-availability', { token }), /** @@ -1492,10 +1508,25 @@ export const license = { }>('/api/license/usage', { token }), }; +export interface McpServer { + id: string; + name: string; + slug?: string; + description?: string; + instructions?: string; + isActive?: boolean; + createdAt?: string; + updatedAt?: string; + usage?: { calls30d: number; lastCallAt: string | null }; + _count?: { connectors: number; apiKeys: number }; + connectors?: Array<{ connector: { name: string } }>; + [key: string]: unknown; +} + // MCP Servers export const mcpServers = { list: (token: string) => - request('/api/mcp-servers', { token }), + request('/api/mcp-servers', { token }), get: (id: string, token: string) => request(`/api/mcp-servers/${id}`, { token }), /** Calls in the last 30 days and the last one's time (the connect page's live check). */ diff --git a/packages/frontend/src/lib/navigation.ts b/packages/frontend/src/lib/navigation.ts index 731515f2f..86d6f5542 100644 --- a/packages/frontend/src/lib/navigation.ts +++ b/packages/frontend/src/lib/navigation.ts @@ -81,4 +81,39 @@ export const SIDEBAR_SECTIONS: SettingsSection[] = [ }, ]; -export { SIDEBAR_SECTIONS as SETTINGS_SECTIONS }; +export interface NavigationVisibilityContext { + isAdmin: boolean; + isCloud: boolean; +} + +export function isSettingsItemVisible( + item: SettingsItem, + context: NavigationVisibilityContext, + section?: SettingsSection +): boolean { + if (section?.adminOnly && !context.isAdmin) return false; + if (item.adminOnly && !context.isAdmin) return false; + if (item.selfHostedOnly && context.isCloud) return false; + return true; +} + +export function isSettingsSectionVisible( + section: SettingsSection, + context: NavigationVisibilityContext +): boolean { + if (section.adminOnly && !context.isAdmin) return false; + return section.items.some((item) => isSettingsItemVisible(item, context, section)); +} + +export function getVisibleSettingsSections( + sections: SettingsSection[], + context: NavigationVisibilityContext +): SettingsSection[] { + return sections + .filter((section) => !section.adminOnly || context.isAdmin) + .map((section) => ({ + ...section, + items: section.items.filter((item) => isSettingsItemVisible(item, context, section)), + })) + .filter((section) => section.items.length > 0); +} diff --git a/packages/frontend/tests/e2e/command-palette.spec.ts b/packages/frontend/tests/e2e/command-palette.spec.ts index 1d6d8cb76..1b14e39a0 100644 --- a/packages/frontend/tests/e2e/command-palette.spec.ts +++ b/packages/frontend/tests/e2e/command-palette.spec.ts @@ -81,13 +81,26 @@ async function setupSession( }); } +async function getPaletteShortcut(page: Page): Promise { + const isMac = await page.evaluate(() => + /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent || navigator.platform || '') + ); + return isMac ? 'Meta+k' : 'Control+k'; +} + async function openCommandPalette(page: Page) { const palette = page.getByRole('dialog'); + const shortcut = await getPaletteShortcut(page); await expect.poll(async () => { if (await palette.isVisible()) return true; - await page.keyboard.press('ControlOrMeta+k'); - return await palette.isVisible(); - }, { timeout: 7000, intervals: [150, 300, 500] }).toBe(true); + await page.keyboard.press(shortcut); + try { + await expect(palette).toBeVisible({ timeout: 500 }); + return true; + } catch { + return false; + } + }, { timeout: 7000, intervals: [200, 300] }).toBe(true); const input = page.getByRole('combobox'); await expect(input).toBeVisible(); @@ -103,11 +116,17 @@ test.describe('Command Palette', () => { await expect(palette).not.toBeVisible(); // Open via keyboard shortcut + const shortcut = await getPaletteShortcut(page); await expect.poll(async () => { if (await palette.isVisible()) return true; - await page.keyboard.press('ControlOrMeta+k'); - return await palette.isVisible(); - }, { timeout: 7000, intervals: [150, 300, 500] }).toBe(true); + await page.keyboard.press(shortcut); + try { + await expect(palette).toBeVisible({ timeout: 500 }); + return true; + } catch { + return false; + } + }, { timeout: 7000, intervals: [200, 300] }).toBe(true); // Combobox input should be focused const searchInput = page.getByRole('combobox'); @@ -320,4 +339,271 @@ test.describe('Command Palette', () => { await second.input.fill('Log out'); await expect(page.getByRole('option', { name: /Log out/i })).toBeVisible(); }); + + test('handles malformed / non-array orgs responses gracefully without crashing', async ({ page }) => { + await setupSession(page); + // Override organizations/mine to return a non-array object + await page.route('**/api/organizations/mine', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ error: 'not an array' }), + }); + }); + + await page.goto('/'); + await expect(page.locator('aside')).toBeVisible(); + + const { palette, input } = await openCommandPalette(page); + await expect(palette).toBeVisible(); + await input.fill('dash'); + await expect(page.getByRole('option', { name: /Dashboard/i })).toBeVisible(); + }); + + test('resets search query every time palette opens, via keyboard and header button', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + // 1. Open via shortcut, type a query + const first = await openCommandPalette(page); + await first.input.fill('random search string'); + await expect(first.input).toHaveValue('random search string'); + + // Close via Esc + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // 2. Re-open via shortcut, check query is empty + const second = await openCommandPalette(page); + await expect(second.input).toHaveValue(''); + + // Type query again + await second.input.fill('another query'); + await expect(second.input).toHaveValue('another query'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // 3. Re-open via header button, check query is empty + const searchButton = page.getByRole('button', { name: 'Open command palette' }).first(); + await searchButton.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const input = page.getByRole('combobox'); + await expect(input).toHaveValue(''); + }); + + test('restores keyboard focus to opening element on close', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + // 1. When opened via header button + const searchBtn = page.getByRole('button', { name: 'Open command palette' }).first(); + await searchBtn.focus(); + await expect(searchBtn).toBeFocused(); + await searchBtn.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(searchBtn).toBeFocused(); + + // 2. When opened via shortcut while another element has focus + const newConnectorBtn = page.getByRole('button', { name: 'New connector' }); + await newConnectorBtn.focus(); + await expect(newConnectorBtn).toBeFocused(); + + const shortcut = await getPaletteShortcut(page); + await page.keyboard.press(shortcut); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(newConnectorBtn).toBeFocused(); + }); + + test('keyboard shortcut platform detection and modifier rejection', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + await expect(page.locator('aside')).toBeVisible(); + + const palette = page.getByRole('dialog'); + + // 1. Shift+K and Alt+K must NOT open palette + await page.keyboard.press('Shift+k'); + await expect(palette).not.toBeVisible(); + await page.keyboard.press('Alt+k'); + await expect(palette).not.toBeVisible(); + + // 2. On simulated Mac platform: Command+K opens, Control+K does NOT + await page.evaluate(() => { + Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel', configurable: true }); + Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', configurable: true }); + if ((navigator as any).userAgentData) { + Object.defineProperty(navigator, 'userAgentData', { get: () => ({ platform: 'macOS' }), configurable: true }); + } + }); + + // Control+K on Mac should NOT open palette (avoids hijacking kill-line) + await page.keyboard.press('Control+k'); + await expect(palette).not.toBeVisible(); + + // Re-focus the page in case browser defaults captured the rejected shortcut + await page.locator('aside').click(); + + // Meta+K on Mac should open palette + await page.keyboard.press('Meta+k'); + await expect(palette).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(palette).not.toBeVisible(); + + // 3. On simulated non-Mac platform: Control+K opens, Meta+K does NOT + await page.evaluate(() => { + Object.defineProperty(navigator, 'platform', { get: () => 'Win32', configurable: true }); + Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', configurable: true }); + if ((navigator as any).userAgentData) { + Object.defineProperty(navigator, 'userAgentData', { get: () => ({ platform: 'Windows' }), configurable: true }); + } + }); + + // Meta+K on Windows should NOT open palette + await page.keyboard.press('Meta+k'); + await expect(palette).not.toBeVisible(); + + // Re-focus the page in case browser defaults captured the rejected shortcut + await page.locator('aside').click(); + + // Control+K on Windows should open palette + await page.keyboard.press('Control+k'); + await expect(palette).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(palette).not.toBeVisible(); + }); + + test('ignores Enter key when IME composition is active', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const { input } = await openCommandPalette(page); + await input.fill('audit'); + await expect(page.getByRole('option', { name: /Audit Log/i })).toBeVisible(); + + // Dispatch an Enter event with isComposing: true + await input.evaluate((el) => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + isComposing: true, + }); + el.dispatchEvent(event); + }); + + // Dialog should still be open, and URL should not have changed to /logs + await expect(page.getByRole('dialog')).toBeVisible(); + expect(new URL(page.url()).pathname).toBe('/'); + }); + + test('accessibility: groups use role=group and aria-labelledby, loading status is outside listbox', async ({ page }) => { + await setupSession(page); + await page.goto('/'); + + const { palette } = await openCommandPalette(page); + + const listbox = palette.getByRole('listbox', { name: 'Commands' }); + await expect(listbox).toBeVisible(); + + // Verify groups have role="group" and aria-labelledby + const groups = listbox.locator('[role="group"]'); + const groupCount = await groups.count(); + expect(groupCount).toBeGreaterThan(0); + + for (let i = 0; i < groupCount; i++) { + const group = groups.nth(i); + const labelledBy = await group.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + const header = page.locator(`#${labelledBy}`); + await expect(header).toBeVisible(); + } + + // Verify that loading status is NOT inside listbox + const loadingInsideListbox = listbox.locator('[role="status"]'); + await expect(loadingInsideListbox).toHaveCount(0); + }); + + test('caches connectors and MCP servers in module-level cache keyed by organizationId', async ({ page }) => { + let connectorsFetches = 0; + let mcpFetches = 0; + + await setupSession(page); + + await page.route('**/api/connectors', async (route) => { + connectorsFetches++; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([{ id: 'c1', name: 'Stripe Payments', type: 'REST', slug: 'stripe' }]), + }); + }); + + await page.route('**/api/mcp-servers', async (route) => { + mcpFetches++; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([{ id: 's1', name: 'Search Server', slug: 'search-server' }]), + }); + }); + + await page.goto('/settings'); + await expect(page.locator('aside')).toBeVisible(); + await page.waitForLoadState('networkidle'); + + // Reset fetch counters after page load so we only measure command palette fetches + connectorsFetches = 0; + mcpFetches = 0; + + // 1. Open first time - fetches records + const first = await openCommandPalette(page); + await first.input.fill('stripe'); + await expect(page.getByRole('option', { name: /Stripe Payments/i })).toBeVisible(); + expect(connectorsFetches).toBe(1); + expect(mcpFetches).toBe(1); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // 2. Re-open without page reload - should use module cache, no new network calls + const second = await openCommandPalette(page); + await second.input.fill('stripe'); + await expect(page.getByRole('option', { name: /Stripe Payments/i })).toBeVisible(); + expect(connectorsFetches).toBe(1); + expect(mcpFetches).toBe(1); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // 3. Open a third time - still cached + const third = await openCommandPalette(page); + await third.input.fill('search'); + await expect(page.getByRole('option', { name: /Search Server/i })).toBeVisible(); + expect(connectorsFetches).toBe(1); + expect(mcpFetches).toBe(1); + }); + + test('section-level and item-level adminOnly and selfHostedOnly visibility filtering', async ({ page }) => { + // Viewer session on self-hosted + await setupSession(page, { user: VIEWER_USER, deploymentMode: 'self-hosted' }); + await page.goto('/settings'); + + // Viewer should only see Profile and Connections (not Organization admin items) + const settingsNav = page.locator('nav'); + await expect(settingsNav.getByRole('link', { name: /Profile/i })).toBeVisible(); + await expect(settingsNav.getByRole('link', { name: /Connections/i })).toBeVisible(); + await expect(settingsNav.getByRole('link', { name: /Users/i })).not.toBeVisible(); + await expect(settingsNav.getByRole('link', { name: /Roles/i })).not.toBeVisible(); + await expect(settingsNav.getByRole('link', { name: /Single sign-on/i })).not.toBeVisible(); + await expect(settingsNav.getByRole('link', { name: /License/i })).not.toBeVisible(); + await expect(settingsNav.getByRole('link', { name: /Administration/i })).not.toBeVisible(); + }); }); From 14e8dc2428835665cbf565d2db13a0655d0387c6 Mon Sep 17 00:00:00 2001 From: AYUshJaiswal454 Date: Fri, 16 Oct 2026 13:51:38 +0530 Subject: [PATCH 3/4] fix(frontend): make mobile command palette trigger square icon to prevent title truncation (#1094) --- packages/frontend/src/components/app-shell.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/frontend/src/components/app-shell.tsx b/packages/frontend/src/components/app-shell.tsx index 9e36f363d..727f4d0af 100644 --- a/packages/frontend/src/components/app-shell.tsx +++ b/packages/frontend/src/components/app-shell.tsx @@ -147,13 +147,12 @@ export function AppShell({ onClick={() => setPaletteOpen(true)} aria-label="Open command palette" title={`Command palette (${shortcutLabel})`} - className="flex h-9 items-center gap-1.5 rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-2 text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)] md:hidden" + className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[9px] border border-[var(--border)] bg-[var(--surface)] text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)] md:hidden" > - {shortcutLabel} From 23fe6c05c3f444942be3fba85a12bcef59bf145c Mon Sep 17 00:00:00 2001 From: AYUshJaiswal454 Date: Sat, 17 Oct 2026 00:50:09 +0530 Subject: [PATCH 4/4] fix(frontend): implement stale-while-revalidate caching and address reviewer feedback (#1094) - Update fetchOrgRecords to never cache failed requests as empty arrays - Implement stale-while-revalidate caching with immediate render and background revalidation - Clear command palette cache on logout and organization switch - Set header search button aria-label to Search (${shortcutLabel}) for WCAG 2.5.3 compliance - Reuse shared isMacPlatform() helper in AppShell - Add e.code === 'KeyK' fallback for non-Latin keyboard layouts - Remove unused isSettingsSectionVisible helper in navigation.ts - Add regression tests for failed requests, stale-while-revalidate refresh, and logout/org-switch cache clearing --- .../frontend/src/components/app-shell.tsx | 6 +- .../src/components/command-palette.tsx | 65 ++++--- packages/frontend/src/lib/auth-context.tsx | 5 + .../frontend/src/lib/command-palette-cache.ts | 23 +++ packages/frontend/src/lib/navigation.ts | 8 - .../tests/e2e/command-palette.spec.ts | 182 ++++++++++++++++-- 6 files changed, 233 insertions(+), 56 deletions(-) create mode 100644 packages/frontend/src/lib/command-palette-cache.ts diff --git a/packages/frontend/src/components/app-shell.tsx b/packages/frontend/src/components/app-shell.tsx index 727f4d0af..4caac9df5 100644 --- a/packages/frontend/src/components/app-shell.tsx +++ b/packages/frontend/src/components/app-shell.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { useState, useSyncExternalStore } from 'react'; import { useTheme } from '@/lib/theme-context'; import { AppSidebar } from '@/components/app-sidebar'; -import { CommandPalette } from '@/components/command-palette'; +import { CommandPalette, isMacPlatform } from '@/components/command-palette'; import { Footer } from '@/components/footer'; import { TrialBanner } from '@/components/trial-banner'; import { SubscriptionBanner } from '@/components/subscription-banner'; @@ -84,7 +84,7 @@ export function AppShell({ const isMac = useSyncExternalStore( () => () => {}, - () => /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent || navigator.platform || ''), + () => isMacPlatform(), () => true ); const shortcutLabel = isMac ? '⌘K' : 'Ctrl K'; @@ -161,7 +161,7 @@ export function AppShell({