Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/11912-inert-search-trigger.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 21 additions & 4 deletions packages/app-shell/src/context/CommandPaletteProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<CommandPaletteProvider>` (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.
* `<CommandPaletteProvider>` (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);
Expand All @@ -114,3 +115,19 @@ export function useCommandPalette(): CommandPaletteContextValue {
}
return ctx;
}

/**
* Whether a `<CommandPaletteProvider>` 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;
}
72 changes: 40 additions & 32 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -674,37 +679,40 @@ export function AppHeader({
)}

{/* Group 1: Search */}
<div data-topbar-group className="flex items-center gap-0.5 sm:gap-1 shrink-0">
{/* Search — desktop */}
<button
type="button"
data-testid="action:command-palette:open"
aria-label={t('console.search', { defaultValue: 'Search…' })}
aria-keyshortcuts="Meta+K Control+K"
onClick={openCommandPalette}
className="hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors"
>
<Search className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 text-left text-xs">
{t('console.search', { defaultValue: 'Search…' })}
</span>
<kbd className="pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground">
<span className="text-xs">⌘</span>K
</kbd>
</button>

{/* Search — mobile/tablet */}
<Button
variant="ghost"
size="icon"
className="lg:hidden h-8 w-8 shrink-0"
data-testid="action:command-palette:open-mobile"
onClick={openCommandPalette}
aria-label={t('console.search', { defaultValue: 'Search…' })}
>
<Search className="h-4 w-4" />
</Button>
</div>
{/* Only where a palette is mounted (objectui#11912) — see `hasCommandPalette`. */}
{hasCommandPalette && (
<div data-topbar-group className="flex items-center gap-0.5 sm:gap-1 shrink-0">
{/* Search — desktop */}
<button
type="button"
data-testid="action:command-palette:open"
aria-label={t('console.search', { defaultValue: 'Search…' })}
aria-keyshortcuts="Meta+K Control+K"
onClick={openCommandPalette}
className="hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors"
>
<Search className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 text-left text-xs">
{t('console.search', { defaultValue: 'Search…' })}
</span>
<kbd className="pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground">
<span className="text-xs">⌘</span>K
</kbd>
</button>

{/* Search — mobile/tablet */}
<Button
variant="ghost"
size="icon"
className="lg:hidden h-8 w-8 shrink-0"
data-testid="action:command-palette:open-mobile"
onClick={openCommandPalette}
aria-label={t('console.search', { defaultValue: 'Search…' })}
>
<Search className="h-4 w-4" />
</Button>
</div>
)}

{/* Group 2: Inbox (notifications + approvals + activity) & Help */}
<div data-topbar-group className="flex items-center gap-0.5 shrink-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -166,7 +170,9 @@ const AUTHOR = mePermissions(['manage_metadata', 'studio.access', 'setup.access'
function mount(perms: MePermissionsResponse) {
return render(
<MePermissionsProvider initialPermissions={perms}>
<AppHeader variant="app" appName="Cloud" activeAppName="cloud_control" objects={[]} />
<CommandPaletteProvider>
<AppHeader variant="app" appName="Cloud" activeAppName="cloud_control" objects={[]} />
</CommandPaletteProvider>
</MePermissionsProvider>,
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
useObjectTranslation: () => ({
language: 'en',
t: (key: string, options?: Record<string, unknown>) => 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<Record<string, unknown>>();
const strip = (p: any) => {
const { asChild, variant, size, align, sideOffset, ...rest } = p ?? {};
return rest;
};
const Pass = ({ children, ...p }: any) => <div {...strip(p)}>{children}</div>;
return {
...actual,
Button: ({ children, asChild, variant, size, ...p }: any) =>
asChild ? <span {...p}>{children}</span> : <button type="button" {...p}>{children}</button>,
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 = () => <span />;
return new Proxy({ __esModule: true } as Record<string | symbol, unknown>, {
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<Record<string, unknown>>()),
useOffline: () => ({ isOnline: true }),
}));

vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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: () => <div data-testid="inbox-bell" /> }));
vi.mock('../../hooks/useAiSurface', () => ({
useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }),
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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 <output data-testid="palette-probe" data-open={String(open)} data-search={search} />;
}

/** The frame each variant is mounted in by the console. */
const FRAME: Record<AppHeaderVariant, string> = {
home: '/home',
orgs: '/organizations',
app: '/apps/crm',
};

function header(variant: AppHeaderVariant) {
return <AppHeader variant={variant} appName={variant === 'app' ? 'crm' : undefined} />;
}

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<AppHeaderVariant>(['home', 'orgs', 'app'])(
'the `%s` variant with no CommandPaletteProvider draws neither trigger',
(variant) => {
render(<MemoryRouter initialEntries={[FRAME[variant]]}>{header(variant)}</MemoryRouter>);
// 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<AppHeaderVariant>(['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(
<MemoryRouter initialEntries={[FRAME[variant]]}>
<CommandPaletteProvider>
{header(variant)}
<PaletteProbe />
</CommandPaletteProvider>
</MemoryRouter>,
);
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();
}
},
);
});
Loading