diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx index e6a234a9b5..01e2cdd5af 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx @@ -27,7 +27,6 @@ import { AppShellNavbar, AppShellRail, AppShellAIChatTrigger, - AppShellRailItem, AppShellSidebar, AppShellSidebarHeader, AppShellUserMenu, @@ -54,6 +53,7 @@ import { TrustSidebar } from '../trust/components/TrustSidebar'; import { getAppShellSearchGroups } from './app-shell-search-groups'; import { AppSidebar } from './AppSidebar'; import { ConditionalOnboardingTracker } from './ConditionalOnboardingTracker'; +import { ShellRailNavItem } from './ShellRailNavItem'; interface AppShellWrapperProps { children: React.ReactNode; @@ -342,28 +342,3 @@ function AppShellWrapperContent({ ); } - -function ShellRailNavItem({ - href, - isActive, - icon, - label, -}: { - href: string; - isActive: boolean; - icon: React.ReactNode; - label: string; -}) { - const railItemId = `app-shell-rail-${label.toLowerCase()}`; - - return ( - - - - ); -} diff --git a/apps/app/src/app/(app)/[orgId]/components/ShellRailNavItem.test.tsx b/apps/app/src/app/(app)/[orgId]/components/ShellRailNavItem.test.tsx new file mode 100644 index 0000000000..b9064a1099 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/ShellRailNavItem.test.tsx @@ -0,0 +1,63 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// next/link needs an App Router context that jsdom doesn't provide; render a plain anchor. +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +import { AppShell, AppShellBody, AppShellRail } from '@trycompai/design-system'; +import { ShellRailNavItem } from './ShellRailNavItem'; + +const Icon = () => ; + +// Regression for CS-773: the far-left product rail tooltips flashed open then vanished +// (~0.1s) on hover. Root cause: ShellRailNavItem passed a label-derived `id` to +// AppShellRailItem, and AppShellRail re-renders the same rail items into the always-mounted +// mobile drawer. That produced two DOM elements sharing one `id`, which the design system +// forwards to the Base UI tooltip trigger — the duplicate trigger id collides in Base UI's +// floating tree and closes the active tooltip. The fix is to not set a hard-coded id. +describe('ShellRailNavItem (CS-773 tooltip flicker)', () => { + it('does not set a hard-coded, label-derived id on the rail item', () => { + render(} label="Compliance" />); + + const button = document.querySelector('[data-slot="app-shell-rail-item"]'); + expect(button).not.toBeNull(); + // The old bug set id="app-shell-rail-compliance". Any hard-coded id here is duplicated + // into the mobile drawer copy and breaks the tooltip, so it must be absent. + expect(button?.getAttribute('id')).not.toBe('app-shell-rail-compliance'); + }); + + it('renders the rail with unique element ids across the desktop rail and mobile drawer', () => { + // AppShellRail mirrors its children into the always-mounted mobile drawer, so each logical + // item renders twice. With the fix, the design system generates a unique id per instance; + // with the bug, the two copies would share the same hard-coded id. + render( + + + + } label="Compliance" /> + } label="Trust" /> + } + label="Security" + /> + + + , + ); + + const ids = Array.from(document.querySelectorAll('[data-slot="app-shell-rail-item"]')) + .map((el) => el.getAttribute('id')) + .filter((id): id is string => Boolean(id)); + + // There must be no duplicate ids among rail item buttons. + expect(new Set(ids).size).toBe(ids.length); + // Sanity: the duplicate render means each of the 3 items appears twice. + expect(document.querySelectorAll('[data-slot="app-shell-rail-item"]').length).toBe(6); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/components/ShellRailNavItem.tsx b/apps/app/src/app/(app)/[orgId]/components/ShellRailNavItem.tsx new file mode 100644 index 0000000000..1b0892ec38 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/ShellRailNavItem.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { AppShellRailItem } from '@trycompai/design-system'; +import Link from 'next/link'; + +interface ShellRailNavItemProps { + href: string; + isActive: boolean; + icon: React.ReactNode; + label: string; +} + +/** + * A single icon in the far-left product rail (Compliance, Trust, Security, Settings, Admin). + * + * CS-773: Do NOT pass an explicit `id` to `AppShellRailItem`. `AppShellRail` re-renders these + * same rail items into the always-mounted mobile drawer, so any hard-coded `id` becomes a + * duplicate DOM id. The design system uses that `id` as the Base UI tooltip trigger's id, and + * two triggers sharing one id collide in Base UI's floating tree — the active (desktop) trigger + * is treated as unmounted and the tooltip auto-closes ~0.1s after opening (the "tooltip glitching + * on hover" bug). Leaving the id unset lets the design system generate a unique id per instance. + */ +export function ShellRailNavItem({ href, isActive, icon, label }: ShellRailNavItemProps) { + return ( + + + + ); +}