From dc9c85aefc2e75724a44737b779e13b253cba317 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:09:39 +0000 Subject: [PATCH 1/2] fix(app-shell): the header draws its Search trigger only where a command palette is mounted On /home, /ai and the organizations frames no CommandPaletteProvider is mounted, so the header's Search trigger called the hook's inert fallback and neither a click nor Ctrl+K opened anything. AppHeader now asks a package-internal useCommandPaletteProviderMounted() and renders the search group only when it answers true. useCommandPalette()'s published return shape and no-op fallback are unchanged; the new hook is not re-exported. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../src/context/CommandPaletteProvider.tsx | 25 +++++-- packages/app-shell/src/layout/AppHeader.tsx | 72 ++++++++++--------- 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/packages/app-shell/src/context/CommandPaletteProvider.tsx b/packages/app-shell/src/context/CommandPaletteProvider.tsx index 76460db601..3e03354a65 100644 --- a/packages/app-shell/src/context/CommandPaletteProvider.tsx +++ b/packages/app-shell/src/context/CommandPaletteProvider.tsx @@ -96,10 +96,11 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) { * Access the shared command-palette controls. * * Falls back to a no-op implementation when used outside a - * `` (e.g. an `AppHeader` rendered in the `home`/`orgs` - * variants, where no palette is mounted, or isolated unit tests). The trigger is - * then inert rather than throwing — matching the prior behavior where the - * synthetic `⌘K` had nothing to open. + * `` (e.g. the `home`/`orgs` frames, where no palette is + * mounted, or isolated unit tests): a caller there gets inert controls rather + * than a throw. A control that only exists to open the palette should not be + * shown there at all — `AppHeader` asks {@link useCommandPaletteProviderMounted} + * and renders its search trigger only when it answers `true` (objectui#11912). */ export function useCommandPalette(): CommandPaletteContextValue { const ctx = useContext(CommandPaletteContext); @@ -114,3 +115,19 @@ export function useCommandPalette(): CommandPaletteContextValue { } return ctx; } + +/** + * Whether a `` is mounted above the caller — that is, + * whether {@link useCommandPalette}'s controls reach a palette, or are the inert + * fallback that opens nothing (on click, or on `⌘K`, whose keydown handler only + * the provider installs). + * + * Package-internal (objectui#11912): `AppHeader` reads it so the "Search ⌘K" + * trigger is not drawn on `/home`, `/ai` or the organizations frames, where it + * would do nothing. It is deliberately not re-exported from `./index.ts` or the + * package entry, and it leaves `useCommandPalette()`'s published return shape + * and its no-op fallback as they were. + */ +export function useCommandPaletteProviderMounted(): boolean { + return useContext(CommandPaletteContext) !== null; +} diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 269b124e27..0de4209e7f 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -73,7 +73,7 @@ import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, import { getIcon } from '../utils/getIcon.js'; import { useMobileViewSwitcher } from './MobileViewSwitcherContext.js'; import { useNavigationContext } from '../context/NavigationContext.js'; -import { useCommandPalette } from '../context/CommandPaletteProvider.js'; +import { useCommandPalette, useCommandPaletteProviderMounted } from '../context/CommandPaletteProvider.js'; import { useUrlOverlay } from '../hooks/useUrlOverlay.js'; import { KEYBOARD_SHORTCUTS_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; @@ -162,6 +162,11 @@ export function AppHeader({ // Idempotent, direct open of the ⌘K command palette (ADR-0054 C1). Replaces a // synthetic `⌘K` KeyboardEvent re-dispatch that did nothing under automation. const { openCommandPalette } = useCommandPalette(); + // objectui#11912 — the search trigger is drawn only where a palette is + // mounted. Outside a `CommandPaletteProvider` (the `home` / `orgs` frames: + // `/home`, `/ai`, the organizations pages) `openCommandPalette` is the inert + // fallback and no ⌘K handler is installed, so a "Search ⌘K" there opens nothing. + const hasCommandPalette = useCommandPaletteProviderMounted(); // Click-reachable entry for the keyboard-shortcuts dialog (was `?`-key only). // Shares the `?shortcuts=1` URL param with KeyboardShortcutsDialog (C2/C3). const { openOverlay: openShortcuts } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM); @@ -674,37 +679,40 @@ export function AppHeader({ )} {/* Group 1: Search */} -
- {/* Search — desktop */} - - - {/* Search — mobile/tablet */} - -
+ {/* Only where a palette is mounted (objectui#11912) — see `hasCommandPalette`. */} + {hasCommandPalette && ( +
+ {/* Search — desktop */} + + + {/* Search — mobile/tablet */} + +
+ )} {/* Group 2: Inbox (notifications + approvals + activity) & Help */}
From 3664e20c071ae53e56c6de351058c06266084cd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:10:47 +0000 Subject: [PATCH 2/2] test(app-shell): pin the header search trigger to a mounted palette, and add the patch changeset AppHeader.searchTriggerNeedsPalette-11912 renders the header through a real MemoryRouter: with no CommandPaletteProvider the home, orgs and app variants draw neither trigger testid; under the real provider both draw and each click opens the palette (open state true, ?palette=1). The 10899 pin reads the desktop trigger as its "header rendered" control, so its app-variant mount now sits under the provider ConsoleLayout gives that variant. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11912-inert-search-trigger.md | 9 + ...esignInStudioNeedsAuthoring-10899.test.tsx | 8 +- ...r.searchTriggerNeedsPalette-11912.test.tsx | 206 ++++++++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 .changeset/11912-inert-search-trigger.md create mode 100644 packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx diff --git a/.changeset/11912-inert-search-trigger.md b/.changeset/11912-inert-search-trigger.md new file mode 100644 index 0000000000..9cba26f113 --- /dev/null +++ b/.changeset/11912-inert-search-trigger.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +The console header no longer shows a "Search ⌘K" trigger where it would open nothing (objectui#11912). + +On `/home`, `/ai`, `/organizations` and an organization's own pages, the header showed the search trigger, but no command palette is mounted in those frames: a click did nothing, and neither did `Ctrl+K`. `AppHeader` now draws the trigger (desktop and compact) only under a `CommandPaletteProvider`. Inside an app, where `ConsoleLayout` mounts the provider and `AppContent` mounts the palette, the trigger is unchanged and opens the palette as before. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. `useCommandPalette()` keeps its return shape and its no-op fallback outside a provider. diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx index e78e0f2f30..2012950069 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx @@ -144,6 +144,10 @@ vi.mock('../../providers/AdapterProvider', () => ({ })); import { AppHeader } from '../AppHeader'; +// The `app` variant lives under `ConsoleLayout`'s palette provider; without one +// the header draws no search trigger (objectui#11912), and the first case below +// reads that trigger as its "the header rendered" control. +import { CommandPaletteProvider } from '../../context/CommandPaletteProvider'; function mePermissions(systemPermissions: string[]): MePermissionsResponse { return { @@ -166,7 +170,9 @@ const AUTHOR = mePermissions(['manage_metadata', 'studio.access', 'setup.access' function mount(perms: MePermissionsResponse) { return render( - + + + , ); } diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx new file mode 100644 index 0000000000..97a01a1734 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The header's "Search ⌘K" trigger is drawn only where a command palette is + * mounted (objectui#11912). + * + * `useCommandPalette()` answers an inert fallback outside a + * `CommandPaletteProvider`, and only `ConsoleLayout` mounts one. `HomeLayout` + * and `AiChatPage` mount the `home` variant, `OrganizationsLayout` and + * `OrganizationLayout` the `orgs` variant, all without a provider — so on + * `/home`, `/ai` and the organizations frames the trigger was shown and a click + * (or `Ctrl+K`) opened nothing. + * + * Real subjects: `AppHeader`, the real `CommandPaletteProvider` and a real + * `MemoryRouter`, so the control case reads the palette's open state the way + * `CommandPalette` itself reads it (`useCommandPalette().open`, backed by + * `?palette=1`). The chrome this card does not touch (menus, presence, + * switchers, icons, the inbox) is stubbed the way + * `AppHeader.designInStudioNeedsAuthoring-10899.test.tsx` stubs it. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + language: 'en', + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => { + const label = (x: { name?: string; label?: string } | string) => + typeof x === 'string' ? x : String(x?.label ?? x?.name ?? ''); + return { + objectLabel: label, + dashboardLabel: label, + pageLabel: label, + reportLabel: label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback ?? '', + appLabel: label, + }; + }, +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + const strip = (p: any) => { + const { asChild, variant, size, align, sideOffset, ...rest } = p ?? {}; + return rest; + }; + const Pass = ({ children, ...p }: any) =>
{children}
; + return { + ...actual, + Button: ({ children, asChild, variant, size, ...p }: any) => + asChild ? {children} : , + DropdownMenu: Pass, + DropdownMenuTrigger: Pass, + DropdownMenuContent: () => null, + DropdownMenuItem: Pass, + DropdownMenuLabel: Pass, + DropdownMenuSeparator: () => null, + DropdownMenuGroup: Pass, + Avatar: Pass, + AvatarImage: () => null, + AvatarFallback: Pass, + Popover: Pass, + PopoverTrigger: Pass, + PopoverContent: () => null, + cn: (...c: any[]) => c.filter(Boolean).join(' '), + }; +}); + +vi.mock('lucide-react', () => { + const Icon = () => ; + return new Proxy({ __esModule: true } as Record, { + get: (target, prop) => { + if (prop === 'then' || prop === '__esModule' || typeof prop === 'symbol') return target[prop]; + return Icon; + }, + has: (_target, prop) => prop !== 'then', + }); +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useOffline: () => ({ isOnline: true }), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + PresenceAvatars: () => null, + useTenantPresence: () => [], +})); + +vi.mock('../ModeToggle', () => ({ ModeToggle: () => null })); +vi.mock('../WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null })); +vi.mock('../LocaleSwitcher', () => ({ LocaleSwitcher: () => null })); +vi.mock('../ConnectionStatus', () => ({ ConnectionStatus: () => null })); +vi.mock('../AppSwitcher', () => ({ AppSwitcher: () => null })); +vi.mock('../LocalizedSidebarTrigger', () => ({ LocalizedSidebarTrigger: () => null })); +vi.mock('../PreviewBadge', () => ({ PreviewBadge: () => null })); +// Rendered in every variant, so it doubles as the "the header drew" control. +vi.mock('../InboxPopover', () => ({ InboxPopover: () =>
})); +vi.mock('../../hooks/useAiSurface', () => ({ + useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Lin Yue', email: 'linyue@example.com' }, + signOut: vi.fn(), + isAuthEnabled: true, + organizations: [], + activeOrganization: null, + isOrganizationsLoading: false, + getAuthConfig: undefined, + }), + getUserInitials: () => 'LY', + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: [], dashboards: [], pages: [], reports: [] }), +})); + +vi.mock('../../providers/AdapterProvider', () => ({ + useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), +})); + +import { AppHeader, type AppHeaderVariant } from '../AppHeader'; +import { CommandPaletteProvider, useCommandPalette } from '../../context/CommandPaletteProvider'; + +const DESKTOP = 'action:command-palette:open'; +const MOBILE = 'action:command-palette:open-mobile'; + +/** The palette's open state and the URL, as `CommandPalette` would read them. */ +function PaletteProbe() { + const { open } = useCommandPalette(); + const { search } = useLocation(); + return ; +} + +/** The frame each variant is mounted in by the console. */ +const FRAME: Record = { + home: '/home', + orgs: '/organizations', + app: '/apps/crm', +}; + +function header(variant: AppHeaderVariant) { + return ; +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('the header search trigger renders only where a command palette is mounted (objectui#11912)', () => { + // `home` is /home and /ai; `orgs` is /organizations and /organizations/SLUG. + // `app` without a provider is not a console frame, but it pins that the gate + // is the palette's presence, not the variant name. + it.each(['home', 'orgs', 'app'])( + 'the `%s` variant with no CommandPaletteProvider draws neither trigger', + (variant) => { + render({header(variant)}); + // The header did render — the absence below is not an empty tree. + expect(screen.getByTestId('inbox-bell')).toBeInTheDocument(); + expect(screen.queryByTestId(DESKTOP)).not.toBeInTheDocument(); + expect(screen.queryByTestId(MOBILE)).not.toBeInTheDocument(); + expect(document.querySelector('[aria-keyshortcuts]')).toBeNull(); + }, + ); + + it.each(['app', 'home'])( + 'CONTROL — the `%s` variant under a CommandPaletteProvider draws both triggers, and each opens the palette', + (variant) => { + for (const testId of [DESKTOP, MOBILE]) { + const { unmount } = render( + + + {header(variant)} + + + , + ); + expect(screen.getByTestId(DESKTOP)).toBeInTheDocument(); + expect(screen.getByTestId(MOBILE)).toBeInTheDocument(); + + const probe = screen.getByTestId('palette-probe'); + expect(probe).toHaveAttribute('data-open', 'false'); + fireEvent.click(screen.getByTestId(testId)); + expect(probe).toHaveAttribute('data-open', 'true'); + expect(probe.getAttribute('data-search')).toContain('palette=1'); + unmount(); + } + }, + ); +});