From 4fafa855e87cf4effc3dee9366e04b9fe04d618d Mon Sep 17 00:00:00 2001
From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
Date: Tue, 11 Aug 2026 09:41:29 -0700
Subject: [PATCH 1/4] chore: bump @clickhouse/click-ui to 0.9.1
Inline @clickhouse/click-ui in vitest server deps and exclude .claude/** from test discovery. click-ui 0.9.1 ships ESM that imports .css files, which Node's loader cannot parse, so Vitest must process the package through Vite.
---
bun.lock | 22 +++++-----------------
package.json | 2 +-
vitest.config.ts | 7 ++++++-
3 files changed, 12 insertions(+), 19 deletions(-)
diff --git a/bun.lock b/bun.lock
index 08d047a6..ec864745 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,7 +5,7 @@
"": {
"name": "tanstack-start-app",
"dependencies": {
- "@clickhouse/click-ui": "0.2.0-rc.4",
+ "@clickhouse/click-ui": "0.9.1",
"@librechat/data-schemas": "^0.0.56",
"@radix-ui/react-dialog": "1.1.15",
"@tailwindcss/vite": "^4.3.1",
@@ -160,7 +160,7 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
- "@clickhouse/click-ui": ["@clickhouse/click-ui@0.2.0-rc.4", "", { "dependencies": { "@h6s/calendar": "2.0.1", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-WcPjvS7W/InvHP/JVeMIHA/30jl3Ks6CN+zch7kfqogjcbTc4byOzSUSkOJlYblzLxfdins0z0oeBF+jVsBMDA=="],
+ "@clickhouse/click-ui": ["@clickhouse/click-ui@0.9.1", "", { "dependencies": { "@h6s/calendar": "2.2.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-gAyIlzIDYqnXcmHM2rOWNpGAoC0UAeacT5yJpDgu09I+TjmuOdzh9tYaJlS938SmUyLsg2U6t8oMA7do108Hgg=="],
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
@@ -188,8 +188,6 @@
"@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="],
- "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
-
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -270,7 +268,7 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
- "@h6s/calendar": ["@h6s/calendar@2.0.1", "", { "peerDependencies": { "date-fns": ">= 2", "react": ">= 18" } }, "sha512-9q5ksdnUsLDeuSm5arXzkxHyS22u7yi5TtVr4DZgW514AjdL+76kx7d+ScX9sKusasRQVxrhM2LTVmd8A/u42Q=="],
+ "@h6s/calendar": ["@h6s/calendar@2.2.0", "", { "peerDependencies": { "react": ">= 18" } }, "sha512-o1fb4RlMWDRIOiMEX65vN5qDTiD/OyC++EkJzmoz9l1vUOkN89gTE+wNE83XZBi3hbYqMV3CNhgKf51n/0hcBw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -644,8 +642,6 @@
"@types/sortablejs": ["@types/sortablejs@1.15.9", "", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="],
- "@types/stylis": ["@types/stylis@4.2.7", "", {}, "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA=="],
-
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -824,6 +820,8 @@
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
"classnames": ["classnames@2.3.1", "", {}, "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
@@ -1454,8 +1452,6 @@
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
- "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
-
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -1654,10 +1650,6 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@clickhouse/click-ui/dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
-
- "@clickhouse/click-ui/styled-components": ["styled-components@6.3.11", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", "@types/stylis": "4.2.7", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.6", "tslib": "2.8.1" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["react-dom"] }, "sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA=="],
-
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
@@ -2020,8 +2012,6 @@
"xmlbuilder2/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
- "@clickhouse/click-ui/styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
-
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="],
@@ -2136,8 +2126,6 @@
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
- "@clickhouse/click-ui/styled-components/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
-
"@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@humanwhocodes/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
diff --git a/package.json b/package.json
index 78d3e020..f46add31 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"format:check": "prettier --check 'src/**/*.{ts,tsx,css,json}'"
},
"dependencies": {
- "@clickhouse/click-ui": "0.2.0-rc.4",
+ "@clickhouse/click-ui": "0.9.1",
"@librechat/data-schemas": "^0.0.56",
"@radix-ui/react-dialog": "1.1.15",
"@tailwindcss/vite": "^4.3.1",
diff --git a/vitest.config.ts b/vitest.config.ts
index c7e8e233..25a36ac3 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,6 +7,11 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
- exclude: ['e2e/**', 'node_modules/**', 'tools/**'],
+ exclude: ['e2e/**', 'node_modules/**', 'tools/**', '.claude/**'],
+ server: {
+ deps: {
+ inline: ['@clickhouse/click-ui'],
+ },
+ },
},
})
From a0ba92ba095b2601dde0aa9522f3c0b6327aa386 Mon Sep 17 00:00:00 2001
From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
Date: Tue, 11 Aug 2026 10:21:39 -0700
Subject: [PATCH 2/4] fix: center collapsed sidebar icons and unclip sidebar
focus rings
The collapsed sidebar kept its labels mounted with a gap-2.5 that never collapsed, so the 10px gap next to the zero-width truncated labels pushed the logo, nav icons, and avatar left of center and justify-* had no effect. Rows now drop to gap-0 with justify-center when collapsed, and the user row's flex-1 label container becomes w-0 flex-none so it cannot grow into the free space and offset the avatar.
The user row wrapper's overflow-hidden clipped the avatar button's focus ring, which the global convention draws at a 2px offset outside the box. The wrapper no longer clips; the name and email already truncate inside their min-w-0 container and the aside still clips during the width transition.
The collapse toggle is w-full and flush with the panel edges and the viewport bottom, so ancestor clipping leaves only the top segment of an outside ring visible. A scoped sidebar-toggle override draws the focus outline inset instead.
---
src/components/Sidebar.test.tsx | 71 +++++++++++++++++++++++++++++++++
src/components/Sidebar.tsx | 43 ++++++++++++++++----
src/styles.css | 7 ++++
3 files changed, 113 insertions(+), 8 deletions(-)
create mode 100644 src/components/Sidebar.test.tsx
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx
new file mode 100644
index 00000000..b17046af
--- /dev/null
+++ b/src/components/Sidebar.test.tsx
@@ -0,0 +1,71 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import type { ReactNode } from 'react';
+import { Sidebar } from './Sidebar';
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({ to, className, children }: { to: string; className?: string; children: ReactNode }) => (
+
+ {children}
+
+ ),
+ useRouter: () => ({
+ state: { location: { pathname: '/' } },
+ invalidate: vi.fn(),
+ navigate: vi.fn(),
+ }),
+}));
+
+vi.mock('@clickhouse/click-ui', () => ({
+ Icon: ({ name }: { name: string }) => ,
+ Dropdown: Object.assign(({ children }: { children: ReactNode }) =>
{children}
, {
+ Trigger: ({ children }: { children: ReactNode }) => {children}
,
+ Content: () => null,
+ Item: () => null,
+ }),
+}));
+
+vi.mock('@/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+ useCapabilities: () => ({ hasCapability: () => true }),
+ useStripAriaExpanded: () => ({ current: null }),
+}));
+
+vi.mock('@/server', () => ({ adminLogoutFn: vi.fn() }));
+
+vi.mock('./SettingsDialog', () => ({ SettingsDialog: () => null }));
+
+const user = { name: 'Ada Lovelace', email: 'ada@example.com' };
+
+const renderSidebar = (collapsed: boolean) =>
+ render( {}} />);
+
+describe('Sidebar', () => {
+ it('centers the logo, nav icons, and avatar with no gap when collapsed', () => {
+ const { container } = renderSidebar(true);
+ const logoRow = container.querySelector('img')?.parentElement;
+ expect(logoRow).toHaveClass('justify-center', 'gap-0');
+ expect(logoRow).not.toHaveClass('gap-2.5');
+ const link = container.querySelector('a[href="/"]');
+ expect(link).toHaveClass('justify-center', 'gap-0');
+ expect(link).not.toHaveClass('gap-2.5');
+ const userRow = container.querySelector('div.border-t')?.firstElementChild;
+ expect(userRow).toHaveClass('justify-center', 'gap-0');
+ expect(userRow).not.toHaveClass('overflow-hidden');
+ });
+
+ it('restores gaps and left alignment when expanded', () => {
+ const { container } = renderSidebar(false);
+ const link = container.querySelector('a[href="/"]');
+ expect(link).toHaveClass('gap-2.5');
+ expect(link).not.toHaveClass('justify-center');
+ const userRow = container.querySelector('div.border-t')?.firstElementChild;
+ expect(userRow).toHaveClass('gap-2.5');
+ expect(userRow).not.toHaveClass('overflow-hidden');
+ });
+
+ it('applies the sidebar-toggle class to the collapse toggle', () => {
+ const { getByLabelText } = renderSidebar(true);
+ expect(getByLabelText('com_nav_expand_sidebar')).toHaveClass('sidebar-toggle');
+ });
+});
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index ce99c1e2..677e14e1 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -87,9 +87,23 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
)}
>
-
-

-
+
+

+
{localize('com_auth_title')}
@@ -105,7 +119,8 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
aria-label={collapsed ? localize(item.labelKey) : undefined}
title={collapsed ? localize(item.labelKey) : undefined}
className={cn(
- 'flex h-8 items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-sm whitespace-nowrap no-underline transition-colors duration-100',
+ 'flex h-8 items-center overflow-hidden rounded-md px-2.5 text-sm whitespace-nowrap no-underline transition-colors duration-100',
+ collapsed ? 'justify-center gap-0' : 'gap-2.5',
isActive(item.path)
? 'bg-(--cui-color-background-active) font-medium text-(--cui-color-text-default)'
: 'font-normal text-(--cui-color-text-muted) hover:bg-(--cui-color-background-hover) hover:text-(--cui-color-text-default)',
@@ -114,7 +129,9 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
- {localize(item.labelKey)}
+
+ {localize(item.labelKey)}
+
))}
@@ -122,7 +139,12 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
{initials && (
-
+
{user && (
-
+
{user.name || ''}
@@ -186,7 +210,10 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
onClick={onToggle}
aria-label={localize(collapsed ? 'com_nav_expand_sidebar' : 'com_nav_collapse_sidebar')}
title={localize(collapsed ? 'com_nav_expand_sidebar' : 'com_nav_collapse_sidebar')}
- className="flex w-full shrink-0 cursor-pointer items-center justify-center border-t border-(--cui-color-stroke-default) bg-transparent py-3 text-(--cui-color-text-muted) transition-colors hover:bg-(--cui-color-background-hover) hover:text-(--cui-color-text-default)"
+ className={cn(
+ 'sidebar-toggle',
+ 'flex w-full shrink-0 cursor-pointer items-center justify-center border-t border-(--cui-color-stroke-default) bg-transparent py-3 text-(--cui-color-text-muted) transition-colors hover:bg-(--cui-color-background-hover) hover:text-(--cui-color-text-default)',
+ )}
>
diff --git a/src/styles.css b/src/styles.css
index 91e8d01f..1ecc0556 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -419,6 +419,13 @@ textarea:focus-visible {
border-radius: var(--cui-radii-sm) !important;
}
+/* Sidebar collapse toggle is flush with the panel edges and viewport bottom,
+ so ancestors clip the default outside ring; draw it inset instead. */
+.sidebar-toggle:focus-visible {
+ outline-offset: -2px !important;
+ border-radius: var(--cui-radii-sm);
+}
+
.scope-selector-dialog {
width: min(640px, calc(100vw - 2rem));
}
From daf6611adff209e6dae6ca7f1a2200420bf7928c Mon Sep 17 00:00:00 2001
From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
Date: Fri, 14 Aug 2026 08:34:06 -0700
Subject: [PATCH 3/4] fix: keep collapsed sidebar content in a fixed-width rail
and test the real sidebar
The collapsed centering switched to w-full justify-center while the aside width was still animating between w-63 and w-14, so the logo, nav icons, and avatar jumped toward the middle of the still-wide sidebar and swept back as it narrowed. A w-10 rail matches the collapsed content width exactly (w-14 minus the px-2 container padding), so icons land in their final centered position immediately and never reflow during the width transition.
The sidebar tests now live under src/components/__tests__ per the repo convention and render the real component through a shared layout-test-utils helper with the real router, click-ui provider, theme provider, query client, and i18n, mocking only the server function boundary. This exercises the real dropdown trigger wrapper structure and capability gating instead of asserting classes on mocked stand-ins.
---
src/components/Sidebar.test.tsx | 71 -------------------
src/components/Sidebar.tsx | 6 +-
src/components/__tests__/Sidebar.test.tsx | 84 +++++++++++++++++++++++
src/test/layout-test-utils.tsx | 52 ++++++++++++++
src/test/setup.ts | 24 +++++++
5 files changed, 163 insertions(+), 74 deletions(-)
delete mode 100644 src/components/Sidebar.test.tsx
create mode 100644 src/components/__tests__/Sidebar.test.tsx
create mode 100644 src/test/layout-test-utils.tsx
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx
deleted file mode 100644
index b17046af..00000000
--- a/src/components/Sidebar.test.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { render } from '@testing-library/react';
-import { describe, it, expect, vi } from 'vitest';
-import type { ReactNode } from 'react';
-import { Sidebar } from './Sidebar';
-
-vi.mock('@tanstack/react-router', () => ({
- Link: ({ to, className, children }: { to: string; className?: string; children: ReactNode }) => (
-
- {children}
-
- ),
- useRouter: () => ({
- state: { location: { pathname: '/' } },
- invalidate: vi.fn(),
- navigate: vi.fn(),
- }),
-}));
-
-vi.mock('@clickhouse/click-ui', () => ({
- Icon: ({ name }: { name: string }) =>
,
- Dropdown: Object.assign(({ children }: { children: ReactNode }) =>
{children}
, {
- Trigger: ({ children }: { children: ReactNode }) =>
{children}
,
- Content: () => null,
- Item: () => null,
- }),
-}));
-
-vi.mock('@/hooks', () => ({
- useLocalize: () => (key: string) => key,
- useCapabilities: () => ({ hasCapability: () => true }),
- useStripAriaExpanded: () => ({ current: null }),
-}));
-
-vi.mock('@/server', () => ({ adminLogoutFn: vi.fn() }));
-
-vi.mock('./SettingsDialog', () => ({ SettingsDialog: () => null }));
-
-const user = { name: 'Ada Lovelace', email: 'ada@example.com' };
-
-const renderSidebar = (collapsed: boolean) =>
- render(
{}} />);
-
-describe('Sidebar', () => {
- it('centers the logo, nav icons, and avatar with no gap when collapsed', () => {
- const { container } = renderSidebar(true);
- const logoRow = container.querySelector('img')?.parentElement;
- expect(logoRow).toHaveClass('justify-center', 'gap-0');
- expect(logoRow).not.toHaveClass('gap-2.5');
- const link = container.querySelector('a[href="/"]');
- expect(link).toHaveClass('justify-center', 'gap-0');
- expect(link).not.toHaveClass('gap-2.5');
- const userRow = container.querySelector('div.border-t')?.firstElementChild;
- expect(userRow).toHaveClass('justify-center', 'gap-0');
- expect(userRow).not.toHaveClass('overflow-hidden');
- });
-
- it('restores gaps and left alignment when expanded', () => {
- const { container } = renderSidebar(false);
- const link = container.querySelector('a[href="/"]');
- expect(link).toHaveClass('gap-2.5');
- expect(link).not.toHaveClass('justify-center');
- const userRow = container.querySelector('div.border-t')?.firstElementChild;
- expect(userRow).toHaveClass('gap-2.5');
- expect(userRow).not.toHaveClass('overflow-hidden');
- });
-
- it('applies the sidebar-toggle class to the collapse toggle', () => {
- const { getByLabelText } = renderSidebar(true);
- expect(getByLabelText('com_nav_expand_sidebar')).toHaveClass('sidebar-toggle');
- });
-});
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 677e14e1..c34a38aa 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -90,7 +90,7 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
diff --git a/src/components/__tests__/Sidebar.test.tsx b/src/components/__tests__/Sidebar.test.tsx
new file mode 100644
index 00000000..907449f7
--- /dev/null
+++ b/src/components/__tests__/Sidebar.test.tsx
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { renderWithLayoutProviders } from '@/test/layout-test-utils';
+import { Sidebar } from '../Sidebar';
+
+vi.mock('@/server', async () => {
+ const { SystemCapabilities } = await import('@/constants');
+ return {
+ adminLogoutFn: vi.fn().mockResolvedValue({ error: false, redirect: '' }),
+ getEffectiveCapabilitiesFn: vi.fn().mockResolvedValue({
+ capabilities: [
+ SystemCapabilities.ACCESS_ADMIN,
+ SystemCapabilities.READ_CONFIGS,
+ SystemCapabilities.READ_ROLES,
+ ],
+ }),
+ };
+});
+
+const user = { name: 'Ada Lovelace', email: 'ada@example.com' };
+
+const renderSidebar = (collapsed: boolean, onToggle: () => void = () => {}) =>
+ renderWithLayoutProviders(, {
+ user,
+ });
+
+describe('Sidebar', () => {
+ it('centers the logo, nav icons, and avatar in a fixed-width rail when collapsed', async () => {
+ renderSidebar(true);
+ const dashboardLink = await screen.findByRole('link', { name: 'Dashboard' });
+ expect(dashboardLink).toHaveClass('w-10', 'justify-center', 'gap-0');
+ expect(dashboardLink).not.toHaveClass('gap-2.5');
+ const logoRow = screen.getByAltText('LibreChat logo').parentElement;
+ expect(logoRow).toHaveClass('w-10', 'justify-center', 'gap-0');
+ expect(logoRow).not.toHaveClass('w-full');
+ const menuButton = screen.getByRole('button', { name: /User menu/ });
+ const avatarRow = menuButton.closest('.border-t')?.firstElementChild;
+ expect(avatarRow).toHaveClass('w-10', 'justify-center', 'gap-0');
+ });
+
+ it('strips aria-expanded from the real dropdown trigger wrapper', async () => {
+ renderSidebar(true);
+ const menuButton = await screen.findByRole('button', { name: /User menu/ });
+ await waitFor(() => expect(menuButton.parentElement).not.toHaveAttribute('aria-expanded'));
+ });
+
+ it('renders capability-gated nav items once effective capabilities resolve', async () => {
+ renderSidebar(true);
+ expect(await screen.findByRole('link', { name: 'Configuration' })).toBeInTheDocument();
+ expect(await screen.findByRole('link', { name: 'Access' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Grants' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Help' })).toBeInTheDocument();
+ });
+
+ it('restores gaps and left alignment when expanded', async () => {
+ renderSidebar(false);
+ const dashboardLink = await screen.findByRole('link', { name: 'Dashboard' });
+ expect(dashboardLink).toHaveClass('gap-2.5');
+ expect(dashboardLink).not.toHaveClass('justify-center');
+ expect(dashboardLink).not.toHaveClass('w-10');
+ const logoRow = screen.getByAltText('LibreChat logo').parentElement;
+ expect(logoRow).toHaveClass('gap-2.5', 'px-1.5');
+ expect(logoRow).not.toHaveClass('justify-center');
+ expect(screen.getByText('ada@example.com')).toBeInTheDocument();
+ });
+
+ it('opens the real user menu with settings and sign out actions', async () => {
+ renderSidebar(true);
+ const menuButton = await screen.findByRole('button', { name: /User menu/ });
+ fireEvent.pointerDown(menuButton, { button: 0, ctrlKey: false });
+ fireEvent.click(menuButton);
+ expect(await screen.findByText('Sign out')).toBeInTheDocument();
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+
+ it('applies the inset focus-ring class to the toggle and calls onToggle', async () => {
+ const onToggle = vi.fn();
+ renderSidebar(true, onToggle);
+ const toggle = await screen.findByRole('button', { name: 'Expand sidebar' });
+ expect(toggle).toHaveClass('sidebar-toggle');
+ fireEvent.click(toggle);
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/test/layout-test-utils.tsx b/src/test/layout-test-utils.tsx
new file mode 100644
index 00000000..3ed070d2
--- /dev/null
+++ b/src/test/layout-test-utils.tsx
@@ -0,0 +1,52 @@
+import '@/locales/i18n';
+import { render } from '@testing-library/react';
+import { ClickUIProvider } from '@clickhouse/click-ui';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+ createRoute,
+} from '@tanstack/react-router';
+import type { RenderResult } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import type * as t from '@/types';
+import { ThemeProvider } from '@/contexts/ThemeContext';
+
+const APP_PATHS = ['/', '/configuration', '/access', '/grants', '/help'];
+
+interface LayoutRenderOptions {
+ user?: t.SidebarProps['user'];
+ path?: string;
+}
+
+export function renderWithLayoutProviders(
+ ui: ReactNode,
+ { user = null, path = '/' }: LayoutRenderOptions = {},
+): RenderResult {
+ const rootRoute = createRootRoute();
+ const appRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: '_app',
+ beforeLoad: () => ({ user }),
+ });
+ const pageRoutes = APP_PATHS.map((pagePath) =>
+ createRoute({ getParentRoute: () => appRoute, path: pagePath, component: () => ui }),
+ );
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([appRoute.addChildren(pageRoutes)]),
+ history: createMemoryHistory({ initialEntries: [path] }),
+ });
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
index bb02c60c..aa9762a4 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1 +1,25 @@
import '@testing-library/jest-dom/vitest';
+
+if (typeof window !== 'undefined') {
+ window.scrollTo = () => {};
+
+ window.matchMedia ??= (query: string): MediaQueryList =>
+ ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList;
+
+ window.ResizeObserver ??= class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+
+ Element.prototype.scrollIntoView ??= () => {};
+}
From efdc6b449f6890c11a0a3c0d8f23f5e7c4aa6e3a Mon Sep 17 00:00:00 2001
From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
Date: Fri, 14 Aug 2026 08:53:19 -0700
Subject: [PATCH 4/4] refactor: move the layout render options into the types
barrel
Locally defined interfaces live in src/types and are consumed through the types namespace import per the repo convention, so the test helper's options type moves next to SidebarProps in layout.ts.
---
src/test/layout-test-utils.tsx | 7 +------
src/types/layout.ts | 5 +++++
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/test/layout-test-utils.tsx b/src/test/layout-test-utils.tsx
index 3ed070d2..55872b04 100644
--- a/src/test/layout-test-utils.tsx
+++ b/src/test/layout-test-utils.tsx
@@ -16,14 +16,9 @@ import { ThemeProvider } from '@/contexts/ThemeContext';
const APP_PATHS = ['/', '/configuration', '/access', '/grants', '/help'];
-interface LayoutRenderOptions {
- user?: t.SidebarProps['user'];
- path?: string;
-}
-
export function renderWithLayoutProviders(
ui: ReactNode,
- { user = null, path = '/' }: LayoutRenderOptions = {},
+ { user = null, path = '/' }: t.LayoutRenderOptions = {},
): RenderResult {
const rootRoute = createRootRoute();
const appRoute = createRoute({
diff --git a/src/types/layout.ts b/src/types/layout.ts
index 7563e578..80833cd9 100644
--- a/src/types/layout.ts
+++ b/src/types/layout.ts
@@ -39,3 +39,8 @@ export interface SettingsDialogProps {
open: boolean;
onClose: () => void;
}
+
+export interface LayoutRenderOptions {
+ user?: SidebarProps['user'];
+ path?: string;
+}