@agent-native/toolkit 0.216.0 → 0.217.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/agent-native.eject.json +5 -0
  2. package/dist/app/auth/AuthPage.d.ts.map +1 -1
  3. package/dist/app/auth/AuthPage.js +33 -44
  4. package/dist/app/auth/AuthPage.js.map +1 -1
  5. package/dist/app/onboarding/GettingStartedCard.d.ts +35 -0
  6. package/dist/app/onboarding/GettingStartedCard.d.ts.map +1 -0
  7. package/dist/app/onboarding/GettingStartedCard.js +38 -0
  8. package/dist/app/onboarding/GettingStartedCard.js.map +1 -0
  9. package/dist/app/onboarding/index.d.ts +1 -0
  10. package/dist/app/onboarding/index.d.ts.map +1 -1
  11. package/dist/app/onboarding/index.js +1 -0
  12. package/dist/app/onboarding/index.js.map +1 -1
  13. package/dist/app/providers/AppProviders.d.ts.map +1 -1
  14. package/dist/app/providers/AppProviders.js +3 -1
  15. package/dist/app/providers/AppProviders.js.map +1 -1
  16. package/dist/app/settings/AccountSettingsCard.d.ts.map +1 -1
  17. package/dist/app/settings/AccountSettingsCard.js +2 -1
  18. package/dist/app/settings/AccountSettingsCard.js.map +1 -1
  19. package/dist/app/settings/index.d.ts +1 -0
  20. package/dist/app/settings/index.d.ts.map +1 -1
  21. package/dist/app/settings/index.js +1 -0
  22. package/dist/app/settings/index.js.map +1 -1
  23. package/dist/app/settings/shell/SettingsShell.d.ts +40 -1
  24. package/dist/app/settings/shell/SettingsShell.d.ts.map +1 -1
  25. package/dist/app/settings/shell/SettingsShell.js +87 -20
  26. package/dist/app/settings/shell/SettingsShell.js.map +1 -1
  27. package/dist/app/settings/shell/bridge.d.ts +5 -3
  28. package/dist/app/settings/shell/bridge.d.ts.map +1 -1
  29. package/dist/app/settings/shell/bridge.js +8 -5
  30. package/dist/app/settings/shell/bridge.js.map +1 -1
  31. package/dist/app/settings/shell/index.d.ts +2 -2
  32. package/dist/app/settings/shell/index.d.ts.map +1 -1
  33. package/dist/app/settings/shell/index.js.map +1 -1
  34. package/dist/app/settings/shell/registry.d.ts +9 -1
  35. package/dist/app/settings/shell/registry.d.ts.map +1 -1
  36. package/dist/app/settings/shell/registry.js +16 -0
  37. package/dist/app/settings/shell/registry.js.map +1 -1
  38. package/dist/app/settings/shell/routing.d.ts +5 -4
  39. package/dist/app/settings/shell/routing.d.ts.map +1 -1
  40. package/dist/app/settings/shell/routing.js +38 -10
  41. package/dist/app/settings/shell/routing.js.map +1 -1
  42. package/dist/app/shared/CommandMenu.d.ts +86 -6
  43. package/dist/app/shared/CommandMenu.d.ts.map +1 -1
  44. package/dist/app/shared/CommandMenu.js +303 -41
  45. package/dist/app/shared/CommandMenu.js.map +1 -1
  46. package/dist/app/shared/ConsentBanner.d.ts +9 -0
  47. package/dist/app/shared/ConsentBanner.d.ts.map +1 -0
  48. package/dist/app/shared/ConsentBanner.js +35 -0
  49. package/dist/app/shared/ConsentBanner.js.map +1 -0
  50. package/dist/ui/sonner.d.ts +2 -2
  51. package/dist/ui/sonner.d.ts.map +1 -1
  52. package/dist/ui/sonner.js +2 -2
  53. package/dist/ui/sonner.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/app/auth/AuthPage.interaction.spec.tsx +74 -0
  56. package/src/app/auth/AuthPage.tsx +32 -39
  57. package/src/app/onboarding/GettingStartedCard.tsx +203 -0
  58. package/src/app/onboarding/index.ts +1 -0
  59. package/src/app/providers/AppProviders.tsx +7 -1
  60. package/src/app/settings/AccountSettingsCard.spec.tsx +31 -0
  61. package/src/app/settings/AccountSettingsCard.tsx +8 -0
  62. package/src/app/settings/index.ts +1 -0
  63. package/src/app/settings/shell/SettingsShell.spec.tsx +472 -1
  64. package/src/app/settings/shell/SettingsShell.tsx +245 -31
  65. package/src/app/settings/shell/bridge.spec.ts +12 -0
  66. package/src/app/settings/shell/bridge.ts +17 -4
  67. package/src/app/settings/shell/index.ts +6 -1
  68. package/src/app/settings/shell/registry.ts +33 -1
  69. package/src/app/settings/shell/routing.spec.ts +92 -1
  70. package/src/app/settings/shell/routing.ts +52 -9
  71. package/src/app/shared/CommandMenu.spec.tsx +1662 -1
  72. package/src/app/shared/CommandMenu.tsx +763 -164
  73. package/src/app/shared/ConsentBanner.spec.tsx +141 -0
  74. package/src/app/shared/ConsentBanner.tsx +141 -0
  75. package/src/ui/sonner.spec.ts +10 -0
  76. package/src/ui/sonner.tsx +2 -2
@@ -35,6 +35,7 @@ import {
35
35
  import { cn } from "@agent-native/toolkit/utils";
36
36
  import {
37
37
  IconBook2,
38
+ IconArrowLeft,
38
39
  IconExternalLink,
39
40
  IconInfoCircle,
40
41
  IconMessage,
@@ -42,6 +43,7 @@ import {
42
43
  IconLogout,
43
44
  IconSettings,
44
45
  } from "@tabler/icons-react";
46
+ import { useCommandState } from "cmdk";
45
47
  import React, {
46
48
  createContext,
47
49
  useCallback,
@@ -53,6 +55,7 @@ import React, {
53
55
  Suspense,
54
56
  type ReactNode,
55
57
  } from "react";
58
+ import { flushSync } from "react-dom";
56
59
 
57
60
  import { AboutAgentNativeDialog } from "./AboutAgentNativeDialog.js";
58
61
  import { LazyChunkErrorBoundary } from "./LazyChunkErrorBoundary.js";
@@ -67,6 +70,28 @@ interface CommandMenuContextValue {
67
70
  search: string;
68
71
  onOpenChange: (open: boolean) => void;
69
72
  registerNestedDialog: (dismiss: () => void) => () => void;
73
+ selectionEnabled: boolean;
74
+ }
75
+
76
+ function CommandSelectionTracker({
77
+ onChange,
78
+ }: {
79
+ onChange: (value: string) => void;
80
+ }) {
81
+ const value = useCommandState((state) => state.value);
82
+
83
+ useEffect(() => onChange(value), [onChange, value]);
84
+ return null;
85
+ }
86
+
87
+ function stopCommandNavigation(event: React.KeyboardEvent<HTMLDivElement>) {
88
+ const key = event.key.toLowerCase();
89
+ if (
90
+ ["arrowdown", "arrowup", "home", "end", "enter"].includes(key) ||
91
+ (event.ctrlKey && ["n", "j", "p", "k"].includes(key))
92
+ ) {
93
+ event.stopPropagation();
94
+ }
70
95
  }
71
96
 
72
97
  const CommandMenuContext = createContext<CommandMenuContextValue | null>(null);
@@ -145,10 +170,11 @@ function CommandGroup({ heading, children }: CommandGroupProps) {
145
170
  );
146
171
  }
147
172
 
148
- interface CommandItemProps {
173
+ type CommandItemProps = {
149
174
  onSelect: () => void;
150
175
  children: ReactNode;
151
176
  keywords?: string[];
177
+ disabled?: boolean;
152
178
  className?: string;
153
179
  deferSelect?: boolean;
154
180
  /**
@@ -157,7 +183,14 @@ interface CommandItemProps {
157
183
  * example a search result gaining a snippet) loses the highlight.
158
184
  */
159
185
  value?: string;
160
- }
186
+ } & (
187
+ | { preview?: never }
188
+ | {
189
+ /** Content shown in the selected row; provide a stable value to keep it out of search text. */
190
+ preview: ReactNode;
191
+ value: string;
192
+ }
193
+ );
161
194
 
162
195
  function CommandItem({
163
196
  onSelect,
@@ -166,8 +199,11 @@ function CommandItem({
166
199
  className,
167
200
  deferSelect = true,
168
201
  value,
202
+ disabled = false,
203
+ preview,
169
204
  }: CommandItemProps) {
170
- const { onOpenChange } = useCommandMenuContext();
205
+ const { onOpenChange, selectionEnabled } = useCommandMenuContext();
206
+ const selectedValue = useCommandState((state) => state.value);
171
207
 
172
208
  const handleSelect = () => {
173
209
  if (!deferSelect) {
@@ -185,8 +221,17 @@ function CommandItem({
185
221
  className={cn("cursor-pointer gap-2", className)}
186
222
  onSelect={handleSelect}
187
223
  value={value}
224
+ disabled={disabled || !selectionEnabled}
188
225
  >
189
226
  {children}
227
+ {preview !== undefined && selectedValue === value ? (
228
+ <div
229
+ data-testid="command-menu-item-preview"
230
+ className="ms-auto min-w-0"
231
+ >
232
+ {preview}
233
+ </div>
234
+ ) : null}
190
235
  </CommandItemPrimitive>
191
236
  );
192
237
  }
@@ -215,6 +260,145 @@ export interface CommandMenuDoc {
215
260
  keywords?: string[];
216
261
  }
217
262
 
263
+ export interface CommandMenuItemMatchContext {
264
+ query: string;
265
+ label: string;
266
+ value?: string;
267
+ keywords: readonly string[];
268
+ }
269
+
270
+ export type CommandMenuItemMatcher = (
271
+ item: CommandMenuItemMatchContext,
272
+ ) => boolean;
273
+
274
+ export interface AppCommandContext<TAppContext = unknown> {
275
+ search: string;
276
+ appContext: TAppContext | undefined;
277
+ close: () => void;
278
+ }
279
+
280
+ export interface AppCommandPage<TAppContext = unknown> {
281
+ id: string;
282
+ title: string;
283
+ groups: AppCommandGroup<TAppContext>[];
284
+ }
285
+
286
+ export interface AppCommandGroup<TAppContext = unknown> {
287
+ id: string;
288
+ order: number;
289
+ heading?: string;
290
+ keywords?: string[];
291
+ visibleWhen?: (context: AppCommandContext<TAppContext>) => boolean;
292
+ commands: AppCommand<TAppContext>[];
293
+ }
294
+
295
+ interface AppCommandBase<TAppContext> {
296
+ id: string;
297
+ order: number;
298
+ label: string;
299
+ keywords?: string[];
300
+ shortcutHint?: string;
301
+ description?: string;
302
+ metadata?: ReactNode;
303
+ icon?: ReactNode;
304
+ preview?:
305
+ | ReactNode
306
+ | ((context: AppCommandContext<TAppContext>) => ReactNode);
307
+ visibleWhen?: (context: AppCommandContext<TAppContext>) => boolean;
308
+ enabledWhen?: (context: AppCommandContext<TAppContext>) => boolean;
309
+ deferSelect?: boolean;
310
+ }
311
+
312
+ export type AppCommand<TAppContext = unknown> =
313
+ | (AppCommandBase<TAppContext> & {
314
+ onSelect: (context: AppCommandContext<TAppContext>) => void;
315
+ page?: never;
316
+ })
317
+ | (AppCommandBase<TAppContext> & {
318
+ page: AppCommandPage<TAppContext>;
319
+ onSelect?: never;
320
+ });
321
+
322
+ interface OrderedAppCommandGroup<TAppContext> {
323
+ group: AppCommandGroup<TAppContext>;
324
+ commands: AppCommand<TAppContext>[];
325
+ }
326
+
327
+ interface AppCommandEntry<TAppContext> {
328
+ group: AppCommandGroup<TAppContext>;
329
+ command: AppCommand<TAppContext>;
330
+ value: string;
331
+ }
332
+
333
+ function ordered<T extends { order: number }>(items: T[]): T[] {
334
+ return items
335
+ .map((item, index) => ({ item, index }))
336
+ .sort((a, b) => a.item.order - b.item.order || a.index - b.index)
337
+ .map(({ item }) => item);
338
+ }
339
+
340
+ function appCommandSearchText<TAppContext>(
341
+ command: AppCommand<TAppContext>,
342
+ ): string {
343
+ return [command.label, command.description]
344
+ .concat(command.keywords ?? [])
345
+ .filter(Boolean)
346
+ .join(" ")
347
+ .toLowerCase();
348
+ }
349
+
350
+ function findAppCommandPage<TAppContext>(
351
+ groups: AppCommandGroup<TAppContext>[],
352
+ pageId: string,
353
+ ): AppCommandPage<TAppContext> | undefined {
354
+ for (const group of groups) {
355
+ for (const command of group.commands) {
356
+ if (!("page" in command) || !command.page) continue;
357
+ if (command.page.id === pageId) return command.page;
358
+ const nestedPage = findAppCommandPage(command.page.groups, pageId);
359
+ if (nestedPage) return nestedPage;
360
+ }
361
+ }
362
+ return undefined;
363
+ }
364
+
365
+ function filterAppCommandGroups<TAppContext>(
366
+ groups: AppCommandGroup<TAppContext>[],
367
+ search: string,
368
+ context: AppCommandContext<TAppContext>,
369
+ itemMatcher?: CommandMenuItemMatcher,
370
+ ): OrderedAppCommandGroup<TAppContext>[] {
371
+ const searchLower = search.trim().toLowerCase();
372
+ return ordered(groups)
373
+ .filter((group) => group.visibleWhen?.(context) ?? true)
374
+ .map((group) => {
375
+ const groupMatches =
376
+ searchLower.length > 0 &&
377
+ [group.heading, ...(group.keywords ?? [])]
378
+ .filter(Boolean)
379
+ .join(" ")
380
+ .toLowerCase()
381
+ .includes(searchLower);
382
+ const commands = ordered(group.commands).filter((command) => {
383
+ if (!(command.visibleWhen?.(context) ?? true)) return false;
384
+ if (!searchLower) return true;
385
+ if (itemMatcher) {
386
+ return itemMatcher({
387
+ query: search,
388
+ label: command.label,
389
+ value: command.id,
390
+ keywords: command.keywords ?? [],
391
+ });
392
+ }
393
+ return (
394
+ groupMatches || appCommandSearchText(command).includes(searchLower)
395
+ );
396
+ });
397
+ return { group, commands };
398
+ })
399
+ .filter(({ commands }) => commands.length > 0);
400
+ }
401
+
218
402
  interface CommandDocsGroupProps {
219
403
  docs: CommandMenuDoc[];
220
404
  heading?: string;
@@ -270,6 +454,26 @@ function CommandDocsGroup({ docs, heading = "Docs" }: CommandDocsGroupProps) {
270
454
  );
271
455
  }
272
456
 
457
+ function hasCommandItems(children: ReactNode): boolean {
458
+ return React.Children.toArray(children).some((child) => {
459
+ if (!React.isValidElement(child)) return false;
460
+ if (child.type === CommandItem || child.type === CommandItemPrimitive) {
461
+ return true;
462
+ }
463
+ if (child.type === React.Fragment || child.type === CommandGroup) {
464
+ return hasCommandItems(
465
+ (child.props as { children?: ReactNode }).children,
466
+ );
467
+ }
468
+ if (child.type === CommandDocsGroup) {
469
+ const docs = (child.props as CommandDocsGroupProps).docs;
470
+ return docs.length > 0;
471
+ }
472
+ if (typeof child.type !== "string") return true;
473
+ return hasCommandItems((child.props as { children?: ReactNode }).children);
474
+ });
475
+ }
476
+
273
477
  const SETTINGS_SEARCH_TERMS = [
274
478
  "settings",
275
479
  "preferences",
@@ -277,6 +481,7 @@ const SETTINGS_SEARCH_TERMS = [
277
481
  "profile",
278
482
  "integrations",
279
483
  ];
484
+ const NO_COMMAND_SELECTION = "\u0000";
280
485
 
281
486
  function isApplePlatform(): boolean {
282
487
  return (
@@ -285,49 +490,99 @@ function isApplePlatform(): boolean {
285
490
  );
286
491
  }
287
492
 
288
- export interface CommandMenuProps {
493
+ export interface CommandMenuProps<TAppContext = unknown> {
289
494
  open: boolean;
290
495
  onOpenChange: (open: boolean) => void;
291
496
  children: ReactNode;
292
497
  renderResults?: (search: string) => ReactNode;
498
+ /** Reports whether default `renderResults` output has selectable rows; required with `emptyState` when results use opaque components. */
499
+ renderResultsHasItems?: (search: string) => boolean;
500
+ renderResultsPosition?: "before" | "after";
293
501
  renderContent?: (options: {
294
502
  search: string;
295
- renderList: (results?: ReactNode) => ReactNode;
503
+ renderList: (
504
+ results?: ReactNode,
505
+ options?: {
506
+ /** Reports rows inside an opaque custom results component. */
507
+ hasSelectableItems?: boolean;
508
+ },
509
+ ) => ReactNode;
296
510
  }) => ReactNode;
297
511
  placeholder?: string;
298
512
  inputLabel?: string;
299
513
  emptyText?: string;
514
+ emptyState?: ReactNode;
515
+ /** App suggestions shown only when a non-empty query has no selectable results. */
516
+ noMatchSuggestionsSlot?: (search: string) => ReactNode;
517
+ initialSelection?: "first" | "none";
518
+ container?: HTMLElement;
519
+ onSearchChange?: (search: string) => void;
520
+ itemMatcher?: CommandMenuItemMatcher;
300
521
  showAgentFallback?: boolean;
301
522
  chatStorageKey?: string;
302
523
  clearSearchOnEscape?: boolean;
303
524
  onCloseAutoFocus?: (event: Event) => void;
525
+ /** Shared dialog width; `wide` is opt-in for palettes with denser app results. */
526
+ size?: "default" | "wide";
304
527
  className?: string;
305
528
  changelog?: string;
306
529
  changelogLabel?: string;
307
530
  changelogKey?: string;
308
531
  showAbout?: boolean;
532
+ showSettingsCommand?: boolean;
533
+ showSignOutCommand?: boolean;
534
+ /** App-owned entries are additive; shortcut hints are display-only. */
535
+ appCommandGroups?: AppCommandGroup<TAppContext>[];
536
+ /** State read by visibility and enabled predicates for app commands. */
537
+ commandContext?: TAppContext;
538
+ /** Optional app-owned filter controls shown between the search field and results. */
539
+ filterLauncherSlot?: (context: AppCommandContext<TAppContext>) => ReactNode;
540
+ /** Optional app chat surface rendered beneath the command results. */
541
+ embeddedChatSlot?: (context: AppCommandContext<TAppContext>) => ReactNode;
542
+ /** Content shown below the results with shared spacing and note semantics. */
543
+ footerSlot?: ReactNode;
309
544
  }
310
545
 
311
- export function CommandMenu({
546
+ export function CommandMenu<TAppContext = unknown>({
312
547
  open,
313
548
  onOpenChange,
314
549
  children,
315
550
  renderResults,
551
+ renderResultsHasItems,
552
+ renderResultsPosition = "before",
316
553
  renderContent,
317
554
  placeholder = "Type a command or ask AI...",
318
555
  inputLabel = placeholder,
319
556
  emptyText: _emptyText = "No commands found.",
557
+ emptyState,
558
+ noMatchSuggestionsSlot,
559
+ initialSelection,
560
+ container,
561
+ onSearchChange,
562
+ itemMatcher,
320
563
  showAgentFallback = true,
321
564
  chatStorageKey,
322
565
  clearSearchOnEscape = false,
323
566
  onCloseAutoFocus,
567
+ size = "default",
324
568
  className,
325
569
  changelog,
326
570
  changelogLabel = "What's new",
327
571
  changelogKey,
328
572
  showAbout: showAboutProp,
329
- }: CommandMenuProps) {
573
+ showSettingsCommand = true,
574
+ showSignOutCommand = true,
575
+ appCommandGroups,
576
+ commandContext,
577
+ filterLauncherSlot,
578
+ embeddedChatSlot,
579
+ footerSlot,
580
+ }: CommandMenuProps<TAppContext>) {
330
581
  const [search, setSearch] = useState("");
582
+ const [appPageStack, setAppPageStack] = useState<
583
+ Array<{ pageId: string; parentSearch: string }>
584
+ >([]);
585
+ const [selectedCommandValue, setSelectedCommandValue] = useState("");
331
586
  const models = useChatModels({
332
587
  enabled: open && showAgentFallback,
333
588
  storageKey: chatModelSelectionStorageKey(chatStorageKey),
@@ -343,6 +598,22 @@ export function CommandMenu({
343
598
  const chatReady = providerStatus === "configured";
344
599
  const inputRef = useRef<HTMLInputElement>(null);
345
600
  const containerRef = useRef<HTMLDivElement>(null);
601
+ const searchValueRef = useRef("");
602
+ const selectionEnabledRef = useRef(false);
603
+ const [selectionEnabled, setSelectionEnabled] = useState(false);
604
+ const selectionResetRef = useRef(0);
605
+ const nextEmptySelectionValue = useCallback(
606
+ () => `${NO_COMMAND_SELECTION}${++selectionResetRef.current}`,
607
+ [],
608
+ );
609
+ const onSearchChangeRef = useRef(onSearchChange);
610
+ onSearchChangeRef.current = onSearchChange;
611
+ const updateSearch = useCallback((value: string) => {
612
+ if (searchValueRef.current === value) return;
613
+ searchValueRef.current = value;
614
+ setSearch(value);
615
+ onSearchChangeRef.current?.(value);
616
+ }, []);
346
617
  const nestedDialogsRef = useRef<Array<() => void>>([]);
347
618
  const t = useT();
348
619
  const registerNestedDialog = useCallback((dismiss: () => void) => {
@@ -362,6 +633,25 @@ export function CommandMenu({
362
633
  const latestChangelogId = getChangelogLatestId(changelog);
363
634
  const { unseen: changelogUnseen, markSeen: markChangelogSeen } =
364
635
  useChangelogSeen(changelogKey ?? "app", latestChangelogId);
636
+ const closeMenu = useCallback(() => onOpenChange(false), [onOpenChange]);
637
+ const appCommandContext: AppCommandContext<TAppContext> = {
638
+ search,
639
+ appContext: commandContext,
640
+ close: closeMenu,
641
+ };
642
+
643
+ const resetSelection = useCallback(() => {
644
+ selectionEnabledRef.current = false;
645
+ setSelectionEnabled(false);
646
+ setSelectedCommandValue(
647
+ initialSelection === "none" ? nextEmptySelectionValue() : "",
648
+ );
649
+ }, [initialSelection, nextEmptySelectionValue]);
650
+ const enableSelection = useCallback(() => {
651
+ if (selectionEnabledRef.current) return;
652
+ selectionEnabledRef.current = true;
653
+ flushSync(() => setSelectionEnabled(true));
654
+ }, []);
365
655
 
366
656
  const openChangelog = useCallback(() => {
367
657
  onOpenChange(false);
@@ -376,17 +666,31 @@ export function CommandMenu({
376
666
 
377
667
  useEffect(() => {
378
668
  if (!open) {
379
- setSearch("");
669
+ updateSearch("");
670
+ setAppPageStack([]);
671
+ resetSelection();
380
672
  return;
381
673
  }
382
674
 
383
675
  if (open) {
384
- setSearch("");
676
+ updateSearch("");
677
+ setAppPageStack([]);
678
+ resetSelection();
385
679
  requestAnimationFrame(() => {
386
680
  inputRef.current?.focus();
387
681
  });
388
682
  }
389
- }, [open]);
683
+ }, [open, resetSelection, updateSearch]);
684
+
685
+ const handleCommandValueChange = useCallback(
686
+ (value: string) => {
687
+ if (initialSelection === "none" && !selectionEnabledRef.current) {
688
+ return;
689
+ }
690
+ setSelectedCommandValue(value);
691
+ },
692
+ [initialSelection],
693
+ );
390
694
 
391
695
  const handleSubmitToAgent = useCallback(() => {
392
696
  if (!search.trim()) {
@@ -453,22 +757,24 @@ export function CommandMenu({
453
757
  const showAboutRow = showAbout && aboutRowMatches;
454
758
  const settingsLabel = t("settingsShortcut.command");
455
759
  const showSettingsRow =
456
- !search ||
457
- [settingsLabel, ...SETTINGS_SEARCH_TERMS]
458
- .join(" ")
459
- .toLowerCase()
460
- .includes(search.toLowerCase());
760
+ showSettingsCommand &&
761
+ (!search ||
762
+ [settingsLabel, ...SETTINGS_SEARCH_TERMS]
763
+ .join(" ")
764
+ .toLowerCase()
765
+ .includes(search.toLowerCase()));
461
766
  const handleOpenSettings = useCallback(() => {
462
767
  onOpenChange(false);
463
768
  openSettingsPage();
464
769
  }, [onOpenChange]);
465
770
  const signOutLabel = t("agentChat.auth.logOut");
466
771
  const showSignOutRow =
467
- !search ||
468
- [signOutLabel, ...SIGN_OUT_SEARCH_TERMS]
469
- .join(" ")
470
- .toLowerCase()
471
- .includes(search.toLowerCase());
772
+ showSignOutCommand &&
773
+ (!search ||
774
+ [signOutLabel, ...SIGN_OUT_SEARCH_TERMS]
775
+ .join(" ")
776
+ .toLowerCase()
777
+ .includes(search.toLowerCase()));
472
778
  const handleSignOut = useCallback(() => {
473
779
  onOpenChange(false);
474
780
  void signOut();
@@ -512,12 +818,19 @@ export function CommandMenu({
512
818
 
513
819
  if (child.type === CommandItem) {
514
820
  if (!search) return child;
515
- const text = getTextContent(props.children as ReactNode).toLowerCase();
516
- const keywords = ((props.keywords as string[]) || [])
517
- .join(" ")
518
- .toLowerCase();
821
+ const label = getTextContent(props.children as ReactNode);
822
+ const value = typeof props.value === "string" ? props.value : undefined;
823
+ const keywords = Array.isArray(props.keywords)
824
+ ? props.keywords.filter(
825
+ (keyword): keyword is string => typeof keyword === "string",
826
+ )
827
+ : [];
519
828
  const searchLower = search.toLowerCase();
520
- if (text.includes(searchLower) || keywords.includes(searchLower)) {
829
+ const matches = itemMatcher
830
+ ? itemMatcher({ query: search, label, value, keywords })
831
+ : label.toLowerCase().includes(searchLower) ||
832
+ keywords.join(" ").toLowerCase().includes(searchLower);
833
+ if (matches) {
521
834
  return child;
522
835
  }
523
836
  return null;
@@ -531,149 +844,365 @@ export function CommandMenu({
531
844
  });
532
845
  };
533
846
 
534
- const filteredChildren = filterChildren(children);
535
- const hasResults = React.Children.toArray(filteredChildren).some(
536
- (child) =>
537
- React.isValidElement(child) &&
538
- (child.type === CommandGroup || child.type === CommandDocsGroup),
847
+ const activePageId = appPageStack.at(-1)?.pageId;
848
+ const activeAppPage = activePageId
849
+ ? findAppCommandPage(appCommandGroups ?? [], activePageId)
850
+ : undefined;
851
+ const currentAppGroups = activeAppPage?.groups ?? appCommandGroups ?? [];
852
+ const filteredAppGroups = filterAppCommandGroups(
853
+ currentAppGroups,
854
+ search,
855
+ appCommandContext,
856
+ itemMatcher,
539
857
  );
540
- const dynamicResults = open ? renderResults?.(search) : null;
541
-
542
- const renderList = (results: ReactNode = dynamicResults) => (
543
- <CommandListPrimitive>
544
- {results}
545
- {hasResults && filteredChildren}
546
-
547
- {showSettingsRow && (
548
- <>
549
- {hasResults && <CommandSeparator />}
550
- <div className="p-1">
551
- <CommandItemPrimitive
552
- className="cursor-pointer gap-2 py-2"
553
- onSelect={handleOpenSettings}
554
- >
555
- <IconSettings className="h-4 w-4 text-muted-foreground" />
556
- <span>{settingsLabel}</span>
557
- <CommandShortcutPrimitive>
558
- {getSettingsShortcutHint(isApplePlatform())}
559
- </CommandShortcutPrimitive>
560
- </CommandItemPrimitive>
561
- </div>
562
- </>
563
- )}
564
-
565
- {showChangelogRow && (
566
- <>
567
- {(hasResults || showSettingsRow) && <CommandSeparator />}
568
- <div className="p-1">
569
- <CommandItemPrimitive
570
- className="cursor-pointer gap-2 py-2"
571
- onSelect={openChangelog}
572
- >
573
- <IconHistory className="h-4 w-4 text-muted-foreground" />
574
- <span>{changelogLabel}</span>
575
- {changelogUnseen && (
576
- <span
577
- className="ms-auto h-2 w-2 rounded-full bg-primary"
578
- aria-label="New updates available"
579
- />
580
- )}
581
- </CommandItemPrimitive>
582
- </div>
583
- </>
584
- )}
858
+ const appCommandEntries: AppCommandEntry<TAppContext>[] =
859
+ filteredAppGroups.flatMap(({ group, commands }) =>
860
+ commands.map((command) => ({
861
+ group,
862
+ command,
863
+ value: `app-command:${group.id}:${command.id}`,
864
+ })),
865
+ );
866
+ const selectedAppCommand = appCommandEntries.find(
867
+ (entry) => entry.value === selectedCommandValue,
868
+ );
869
+ const firstEnabledAppCommand = appCommandEntries.find(
870
+ ({ command }) => command.enabledWhen?.(appCommandContext) ?? true,
871
+ );
872
+ const commandMenuValue =
873
+ initialSelection === "none"
874
+ ? selectedCommandValue
875
+ : activeAppPage
876
+ ? (selectedAppCommand?.value ??
877
+ firstEnabledAppCommand?.value ??
878
+ NO_COMMAND_SELECTION)
879
+ : undefined;
880
+ const previewAppCommand =
881
+ appCommandEntries.find(
882
+ (entry) => entry.value === (commandMenuValue ?? selectedCommandValue),
883
+ ) ?? selectedAppCommand;
884
+ const appPreview = previewAppCommand?.command.preview;
885
+ const renderedAppPreview =
886
+ typeof appPreview === "function"
887
+ ? appPreview(appCommandContext)
888
+ : appPreview;
889
+ const filteredChildren = activeAppPage ? null : filterChildren(children);
890
+ const hasAppCommandResults = filteredAppGroups.length > 0;
891
+ const hasChildrenResults = hasCommandItems(filteredChildren);
892
+ const hasCommandResults = hasChildrenResults || hasAppCommandResults;
893
+ const dynamicResults =
894
+ open && !activeAppPage ? renderResults?.(search) : null;
895
+
896
+ const goBackToAppPage = useCallback(() => {
897
+ const currentPage = appPageStack.at(-1);
898
+ if (!currentPage) return;
899
+ setAppPageStack((stack) => stack.slice(0, -1));
900
+ updateSearch(currentPage.parentSearch);
901
+ resetSelection();
902
+ requestAnimationFrame(() => inputRef.current?.focus());
903
+ }, [appPageStack, resetSelection, updateSearch]);
904
+
905
+ const openAppPage = (page: AppCommandPage<TAppContext>) => {
906
+ setAppPageStack((stack) => [
907
+ ...stack,
908
+ { pageId: page.id, parentSearch: search },
909
+ ]);
910
+ updateSearch("");
911
+ resetSelection();
912
+ };
585
913
 
586
- {showAboutRow && (
587
- <>
588
- {(hasResults || showSettingsRow || showChangelogRow) && (
589
- <CommandSeparator />
590
- )}
591
- <div className="p-1">
592
- <CommandItemPrimitive
593
- className="cursor-pointer gap-2 py-2"
594
- onSelect={openAbout}
595
- >
596
- <IconInfoCircle className="h-4 w-4 text-muted-foreground" />
597
- <span>{aboutLabel}</span>
598
- </CommandItemPrimitive>
599
- </div>
600
- </>
601
- )}
914
+ const selectAppCommand = (command: AppCommand<TAppContext>) => {
915
+ if ("page" in command && command.page) {
916
+ openAppPage(command.page);
917
+ return;
918
+ }
919
+ if (!("onSelect" in command) || !command.onSelect) return;
920
+ if (command.enabledWhen && !command.enabledWhen(appCommandContext)) return;
921
+ if (command.deferSelect === false) {
922
+ command.onSelect(appCommandContext);
923
+ closeMenu();
924
+ return;
925
+ }
926
+ closeMenu();
927
+ setTimeout(() => command.onSelect?.(appCommandContext), 50);
928
+ };
602
929
 
603
- {showSignOutRow && (
604
- <>
605
- {(hasResults ||
606
- showSettingsRow ||
607
- showChangelogRow ||
608
- showAboutRow) && <CommandSeparator />}
609
- <div className="p-1">
610
- <CommandItemPrimitive
611
- className="cursor-pointer gap-2 py-2"
612
- onSelect={handleSignOut}
930
+ const renderList = (
931
+ results?: ReactNode,
932
+ options?: {
933
+ hasSelectableItems?: boolean;
934
+ },
935
+ ) => {
936
+ const usesDefaultResults = results === undefined;
937
+ const renderedResults = usesDefaultResults ? dynamicResults : results;
938
+ const hasDynamicItems =
939
+ options?.hasSelectableItems ??
940
+ (usesDefaultResults && !activeAppPage
941
+ ? (renderResultsHasItems?.(search) ?? hasCommandItems(renderedResults))
942
+ : hasCommandItems(renderedResults));
943
+ const hasResults = hasCommandResults || hasDynamicItems;
944
+ const noMatchSuggestions =
945
+ !hasResults && search.trim() ? noMatchSuggestionsSlot?.(search) : null;
946
+ const list = (
947
+ <CommandListPrimitive>
948
+ {activeAppPage ? (
949
+ <CommandGroup heading={activeAppPage.title}>
950
+ <button
951
+ type="button"
952
+ aria-label={t("onboarding.back")}
953
+ data-testid="command-menu-page-back"
954
+ className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-start text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
955
+ onClick={goBackToAppPage}
956
+ onKeyDown={(event) => {
957
+ if (event.key === "Enter" || event.key === " ") {
958
+ event.stopPropagation();
959
+ }
960
+ }}
613
961
  >
614
- <IconLogout className="h-4 w-4 text-muted-foreground rtl:-scale-x-100" />
615
- <span>{signOutLabel}</span>
616
- </CommandItemPrimitive>
962
+ <IconArrowLeft className="h-4 w-4 text-muted-foreground" />
963
+ <span>{t("onboarding.back")}</span>
964
+ <CommandShortcutPrimitive>Esc</CommandShortcutPrimitive>
965
+ </button>
966
+ </CommandGroup>
967
+ ) : null}
968
+
969
+ {renderResultsPosition === "before" ? renderedResults : null}
970
+
971
+ {filteredAppGroups.map(({ group, commands }) => (
972
+ <CommandGroup key={group.id} heading={group.heading}>
973
+ {commands.map((command) => {
974
+ const value = `app-command:${group.id}:${command.id}`;
975
+ const enabled = command.enabledWhen?.(appCommandContext) ?? true;
976
+ return (
977
+ <CommandItemPrimitive
978
+ key={command.id}
979
+ value={value}
980
+ disabled={
981
+ !enabled ||
982
+ (initialSelection === "none" && !selectionEnabled)
983
+ }
984
+ className="cursor-pointer gap-2 py-2"
985
+ onSelect={() => selectAppCommand(command)}
986
+ >
987
+ {command.icon}
988
+ <span className="min-w-0 flex-1">
989
+ <span className="block truncate">{command.label}</span>
990
+ {command.description ? (
991
+ <span className="block truncate text-xs text-muted-foreground">
992
+ {command.description}
993
+ </span>
994
+ ) : null}
995
+ </span>
996
+ {command.metadata ? (
997
+ <span className="shrink-0 text-xs text-muted-foreground">
998
+ {command.metadata}
999
+ </span>
1000
+ ) : null}
1001
+ {command.shortcutHint ? (
1002
+ <CommandShortcutPrimitive>
1003
+ {command.shortcutHint}
1004
+ </CommandShortcutPrimitive>
1005
+ ) : null}
1006
+ </CommandItemPrimitive>
1007
+ );
1008
+ })}
1009
+ </CommandGroup>
1010
+ ))}
1011
+
1012
+ {hasChildrenResults && filteredChildren}
1013
+
1014
+ {renderResultsPosition === "after" ? renderedResults : null}
1015
+
1016
+ {!hasResults && (emptyState != null || noMatchSuggestions != null) ? (
1017
+ <div
1018
+ role="status"
1019
+ aria-live="polite"
1020
+ className="px-3 py-6 text-center text-sm text-muted-foreground"
1021
+ >
1022
+ {emptyState}
1023
+ {noMatchSuggestions != null ? (
1024
+ <div className="mt-3">{noMatchSuggestions}</div>
1025
+ ) : null}
617
1026
  </div>
618
- </>
619
- )}
620
-
621
- {showAgentFallback && (
622
- <>
623
- {(hasResults ||
624
- showSettingsRow ||
625
- showChangelogRow ||
626
- showAboutRow ||
627
- showSignOutRow ||
628
- Boolean(results)) && <CommandSeparator />}
629
- <div className="p-1">
630
- {providerStatus === "unavailable" ? (
631
- <div
632
- className="mb-1 flex items-center justify-between gap-3 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm text-muted-foreground"
633
- role="status"
634
- >
635
- <span>{t("agentChat.setup.providerStatusUnavailable")}</span>
636
- {providerStatus === "unavailable" ? (
637
- <button
638
- type="button"
639
- className="shrink-0 font-medium text-foreground underline-offset-4 hover:underline"
640
- onClick={retryProviderStatus}
1027
+ ) : null}
1028
+
1029
+ {!activeAppPage && (
1030
+ <>
1031
+ {showSettingsRow && (
1032
+ <>
1033
+ {hasResults && <CommandSeparator />}
1034
+ <div className="p-1">
1035
+ <CommandItemPrimitive
1036
+ className="cursor-pointer gap-2 py-2"
1037
+ disabled={initialSelection === "none" && !selectionEnabled}
1038
+ onSelect={handleOpenSettings}
641
1039
  >
642
- {t("agentChat.common.retry")}
643
- </button>
644
- ) : null}
645
- </div>
646
- ) : null}
647
- <CommandItemPrimitive
648
- className={cn(
649
- "gap-2 py-2",
650
- chatReady ? "cursor-pointer" : "cursor-not-allowed opacity-50",
651
- )}
652
- disabled={!chatReady}
653
- onSelect={handleSubmitToAgent}
654
- >
655
- <IconMessage className="h-4 w-4 text-muted-foreground" />
656
- <span>
657
- {search.trim() ? (
658
- <>
659
- Ask AI:{" "}
660
- <span className="text-muted-foreground">"{search}"</span>
661
- </>
662
- ) : (
663
- <span className="text-muted-foreground">
664
- Ask AI anything...
665
- </span>
1040
+ <IconSettings className="h-4 w-4 text-muted-foreground" />
1041
+ <span>{settingsLabel}</span>
1042
+ <CommandShortcutPrimitive>
1043
+ {getSettingsShortcutHint(isApplePlatform())}
1044
+ </CommandShortcutPrimitive>
1045
+ </CommandItemPrimitive>
1046
+ </div>
1047
+ </>
1048
+ )}
1049
+
1050
+ {showChangelogRow && (
1051
+ <>
1052
+ {(hasResults || showSettingsRow) && <CommandSeparator />}
1053
+ <div className="p-1">
1054
+ <CommandItemPrimitive
1055
+ className="cursor-pointer gap-2 py-2"
1056
+ disabled={initialSelection === "none" && !selectionEnabled}
1057
+ onSelect={openChangelog}
1058
+ >
1059
+ <IconHistory className="h-4 w-4 text-muted-foreground" />
1060
+ <span>{changelogLabel}</span>
1061
+ {changelogUnseen && (
1062
+ <span
1063
+ className="ms-auto h-2 w-2 rounded-full bg-primary"
1064
+ aria-label="New updates available"
1065
+ />
1066
+ )}
1067
+ </CommandItemPrimitive>
1068
+ </div>
1069
+ </>
1070
+ )}
1071
+
1072
+ {showAboutRow && (
1073
+ <>
1074
+ {(hasResults || showSettingsRow || showChangelogRow) && (
1075
+ <CommandSeparator />
666
1076
  )}
667
- </span>
668
- {search.trim() && (
669
- <span className="ms-auto text-xs text-muted-foreground">↵</span>
670
- )}
671
- </CommandItemPrimitive>
1077
+ <div className="p-1">
1078
+ <CommandItemPrimitive
1079
+ className="cursor-pointer gap-2 py-2"
1080
+ disabled={initialSelection === "none" && !selectionEnabled}
1081
+ onSelect={openAbout}
1082
+ >
1083
+ <IconInfoCircle className="h-4 w-4 text-muted-foreground" />
1084
+ <span>{aboutLabel}</span>
1085
+ </CommandItemPrimitive>
1086
+ </div>
1087
+ </>
1088
+ )}
1089
+
1090
+ {showSignOutRow && (
1091
+ <>
1092
+ {(hasResults ||
1093
+ showSettingsRow ||
1094
+ showChangelogRow ||
1095
+ showAboutRow) && <CommandSeparator />}
1096
+ <div className="p-1">
1097
+ <CommandItemPrimitive
1098
+ className="cursor-pointer gap-2 py-2"
1099
+ disabled={initialSelection === "none" && !selectionEnabled}
1100
+ onSelect={handleSignOut}
1101
+ >
1102
+ <IconLogout className="h-4 w-4 text-muted-foreground rtl:-scale-x-100" />
1103
+ <span>{signOutLabel}</span>
1104
+ </CommandItemPrimitive>
1105
+ </div>
1106
+ </>
1107
+ )}
1108
+
1109
+ {showAgentFallback && (
1110
+ <>
1111
+ {(hasResults ||
1112
+ showSettingsRow ||
1113
+ showChangelogRow ||
1114
+ showAboutRow ||
1115
+ showSignOutRow) && <CommandSeparator />}
1116
+ <div className="p-1">
1117
+ {providerStatus === "unavailable" ? (
1118
+ <div
1119
+ className="mb-1 flex items-center justify-between gap-3 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm text-muted-foreground"
1120
+ role="status"
1121
+ >
1122
+ <span>
1123
+ {t("agentChat.setup.providerStatusUnavailable")}
1124
+ </span>
1125
+ {providerStatus === "unavailable" ? (
1126
+ <button
1127
+ type="button"
1128
+ className="shrink-0 font-medium text-foreground underline-offset-4 hover:underline"
1129
+ onClick={retryProviderStatus}
1130
+ >
1131
+ {t("agentChat.common.retry")}
1132
+ </button>
1133
+ ) : null}
1134
+ </div>
1135
+ ) : null}
1136
+ <CommandItemPrimitive
1137
+ className={cn(
1138
+ "gap-2 py-2",
1139
+ chatReady
1140
+ ? "cursor-pointer"
1141
+ : "cursor-not-allowed opacity-50",
1142
+ )}
1143
+ disabled={
1144
+ !chatReady ||
1145
+ (initialSelection === "none" && !selectionEnabled)
1146
+ }
1147
+ onSelect={handleSubmitToAgent}
1148
+ >
1149
+ <IconMessage className="h-4 w-4 text-muted-foreground" />
1150
+ <span>
1151
+ {search.trim() ? (
1152
+ <>
1153
+ Ask AI:{" "}
1154
+ <span className="text-muted-foreground">
1155
+ "{search}"
1156
+ </span>
1157
+ </>
1158
+ ) : (
1159
+ <span className="text-muted-foreground">
1160
+ Ask AI anything...
1161
+ </span>
1162
+ )}
1163
+ </span>
1164
+ {search.trim() && (
1165
+ <span className="ms-auto text-xs text-muted-foreground">
1166
+ ↵
1167
+ </span>
1168
+ )}
1169
+ </CommandItemPrimitive>
1170
+ </div>
1171
+ </>
1172
+ )}
1173
+ </>
1174
+ )}
1175
+ </CommandListPrimitive>
1176
+ );
1177
+
1178
+ if (!activeAppPage && appCommandGroups === undefined && !embeddedChatSlot) {
1179
+ return list;
1180
+ }
1181
+
1182
+ return (
1183
+ <>
1184
+ {list}
1185
+ {renderedAppPreview ? (
1186
+ <div
1187
+ data-testid="command-menu-inline-preview"
1188
+ role="region"
1189
+ aria-label={previewAppCommand?.command.label}
1190
+ className="border-t border-border p-3"
1191
+ >
1192
+ {renderedAppPreview}
672
1193
  </div>
673
- </>
674
- )}
675
- </CommandListPrimitive>
676
- );
1194
+ ) : null}
1195
+ {embeddedChatSlot ? (
1196
+ <div
1197
+ data-testid="command-menu-embedded-chat"
1198
+ onKeyDown={stopCommandNavigation}
1199
+ >
1200
+ {embeddedChatSlot(appCommandContext)}
1201
+ </div>
1202
+ ) : null}
1203
+ </>
1204
+ );
1205
+ };
677
1206
 
678
1207
  return (
679
1208
  <>
@@ -686,7 +1215,24 @@ export function CommandMenu({
686
1215
  >
687
1216
  <DialogContent
688
1217
  ref={containerRef}
1218
+ container={container}
689
1219
  aria-describedby={undefined}
1220
+ onPointerDownCapture={(event) => {
1221
+ if (
1222
+ initialSelection === "none" &&
1223
+ (event.target as HTMLElement).closest?.("[cmdk-item]")
1224
+ ) {
1225
+ enableSelection();
1226
+ }
1227
+ }}
1228
+ onPointerMoveCapture={(event) => {
1229
+ if (
1230
+ initialSelection === "none" &&
1231
+ (event.target as HTMLElement).closest?.("[cmdk-item]")
1232
+ ) {
1233
+ enableSelection();
1234
+ }
1235
+ }}
690
1236
  onEscapeKeyDown={(event) => {
691
1237
  const dismissNested = nestedDialogsRef.current.at(-1);
692
1238
  if (dismissNested) {
@@ -694,9 +1240,14 @@ export function CommandMenu({
694
1240
  queueMicrotask(dismissNested);
695
1241
  return;
696
1242
  }
1243
+ if (appPageStack.length > 0) {
1244
+ event.preventDefault();
1245
+ goBackToAppPage();
1246
+ return;
1247
+ }
697
1248
  if (clearSearchOnEscape && search.length > 0) {
698
1249
  event.preventDefault();
699
- setSearch("");
1250
+ updateSearch("");
700
1251
  }
701
1252
  }}
702
1253
  hideClose
@@ -709,7 +1260,8 @@ export function CommandMenu({
709
1260
  transition: "none",
710
1261
  }}
711
1262
  className={cn(
712
- "fixed left-1/2 top-[15vh] !max-h-none -translate-x-1/2 !translate-y-0 !gap-0 w-full max-w-lg",
1263
+ "fixed left-1/2 top-[15vh] !max-h-none -translate-x-1/2 !translate-y-0 !gap-0 w-full",
1264
+ size === "wide" ? "max-w-3xl" : "max-w-lg",
713
1265
  "rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-lg",
714
1266
  className,
715
1267
  )}
@@ -724,22 +1276,69 @@ export function CommandMenu({
724
1276
  loop
725
1277
  shouldFilter={false}
726
1278
  className="h-auto rounded-lg"
1279
+ value={commandMenuValue}
727
1280
  >
728
1281
  <CommandMenuContext.Provider
729
- value={{ search, onOpenChange, registerNestedDialog }}
1282
+ value={{
1283
+ search,
1284
+ onOpenChange,
1285
+ registerNestedDialog,
1286
+ selectionEnabled:
1287
+ initialSelection !== "none" || selectionEnabled,
1288
+ }}
730
1289
  >
1290
+ {(appCommandGroups !== undefined ||
1291
+ embeddedChatSlot ||
1292
+ initialSelection !== undefined) && (
1293
+ <CommandSelectionTracker onChange={handleCommandValueChange} />
1294
+ )}
731
1295
  {/* Search input */}
732
1296
  <CommandInputPrimitive
733
1297
  ref={inputRef}
734
1298
  value={search}
735
- onValueChange={setSearch}
1299
+ onValueChange={(value) => {
1300
+ updateSearch(value);
1301
+ selectionEnabledRef.current = false;
1302
+ if (initialSelection === "none") setSelectionEnabled(false);
1303
+ setSelectedCommandValue(
1304
+ initialSelection === "none"
1305
+ ? nextEmptySelectionValue()
1306
+ : "",
1307
+ );
1308
+ }}
1309
+ onKeyDown={(event) => {
1310
+ if (
1311
+ initialSelection === "none" &&
1312
+ (event.key === "ArrowDown" || event.key === "ArrowUp")
1313
+ ) {
1314
+ enableSelection();
1315
+ }
1316
+ }}
736
1317
  placeholder={placeholder}
737
1318
  aria-label={inputLabel}
738
1319
  />
739
1320
 
1321
+ {open && filterLauncherSlot ? (
1322
+ <div
1323
+ data-testid="command-menu-filter-launchers"
1324
+ className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2"
1325
+ onKeyDown={stopCommandNavigation}
1326
+ >
1327
+ {filterLauncherSlot(appCommandContext)}
1328
+ </div>
1329
+ ) : null}
1330
+
740
1331
  {open && renderContent
741
1332
  ? renderContent({ search, renderList })
742
1333
  : open && renderList()}
1334
+ {open && footerSlot != null ? (
1335
+ <div
1336
+ role="note"
1337
+ className="border-t border-border px-3 py-2 text-xs text-muted-foreground"
1338
+ >
1339
+ {footerSlot}
1340
+ </div>
1341
+ ) : null}
743
1342
  </CommandMenuContext.Provider>
744
1343
  </CommandPrimitive>
745
1344
  </DialogContent>