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 (
+
+
+
+ );
+}