@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
@@ -44,6 +44,9 @@ import {
44
44
  openAgentSettings,
45
45
  useCommandMenuNestedDialog,
46
46
  useCommandMenuShortcut,
47
+ type AppCommandGroup,
48
+ type CommandMenuItemMatchContext,
49
+ type CommandMenuItemMatcher,
47
50
  type CommandMenuDoc,
48
51
  } from "./CommandMenu.js";
49
52
 
@@ -56,12 +59,13 @@ const DOCS: CommandMenuDoc[] = [
56
59
  },
57
60
  ];
58
61
 
59
- describe("CommandMenu docs group", () => {
62
+ describe("CommandMenu", () => {
60
63
  let container: HTMLDivElement;
61
64
  let root: Root;
62
65
 
63
66
  beforeEach(() => {
64
67
  actionMocks.callAction.mockReset();
68
+ onOpenChange.mockReset();
65
69
  agentEngineStatusMock.state = "missing";
66
70
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
67
71
  vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
@@ -109,6 +113,87 @@ describe("CommandMenu docs group", () => {
109
113
  });
110
114
  }
111
115
 
116
+ function renderAppCommands<TAppContext>({
117
+ groups,
118
+ context,
119
+ filterLauncherSlot,
120
+ embeddedChatSlot,
121
+ initialSelection,
122
+ footerSlot,
123
+ itemMatcher,
124
+ onSearchChange,
125
+ renderResults,
126
+ renderResultsHasItems,
127
+ renderResultsPosition,
128
+ emptyState,
129
+ noMatchSuggestionsSlot,
130
+ container: portalContainer,
131
+ size,
132
+ showSettingsCommand,
133
+ showSignOutCommand,
134
+ showAgentFallback = false,
135
+ open = true,
136
+ }: {
137
+ groups: AppCommandGroup<TAppContext>[];
138
+ context?: TAppContext;
139
+ initialSelection?: "first" | "none";
140
+ itemMatcher?: CommandMenuItemMatcher;
141
+ onSearchChange?: (search: string) => void;
142
+ renderResults?: (search: string) => React.ReactNode;
143
+ renderResultsHasItems?: (search: string) => boolean;
144
+ renderResultsPosition?: "before" | "after";
145
+ emptyState?: React.ReactNode;
146
+ noMatchSuggestionsSlot?: (search: string) => React.ReactNode;
147
+ container?: HTMLElement;
148
+ size?: "default" | "wide";
149
+ showSettingsCommand?: boolean;
150
+ showSignOutCommand?: boolean;
151
+ showAgentFallback?: boolean;
152
+ open?: boolean;
153
+ filterLauncherSlot?: (options: {
154
+ search: string;
155
+ appContext: TAppContext | undefined;
156
+ close: () => void;
157
+ }) => React.ReactNode;
158
+ embeddedChatSlot?: (options: {
159
+ search: string;
160
+ appContext: TAppContext | undefined;
161
+ close: () => void;
162
+ }) => React.ReactNode;
163
+ footerSlot?: React.ReactNode;
164
+ }) {
165
+ act(() => {
166
+ root.render(
167
+ <CommandMenu
168
+ open={open}
169
+ onOpenChange={onOpenChange}
170
+ showAgentFallback={showAgentFallback}
171
+ showSettingsCommand={showSettingsCommand}
172
+ showSignOutCommand={showSignOutCommand}
173
+ appCommandGroups={groups}
174
+ commandContext={context}
175
+ filterLauncherSlot={filterLauncherSlot}
176
+ embeddedChatSlot={embeddedChatSlot}
177
+ initialSelection={initialSelection}
178
+ footerSlot={footerSlot}
179
+ itemMatcher={itemMatcher}
180
+ onSearchChange={onSearchChange}
181
+ renderResults={renderResults}
182
+ renderResultsHasItems={renderResultsHasItems}
183
+ renderResultsPosition={renderResultsPosition}
184
+ emptyState={emptyState}
185
+ noMatchSuggestionsSlot={noMatchSuggestionsSlot}
186
+ container={portalContainer}
187
+ size={size}
188
+ >
189
+ {null}
190
+ </CommandMenu>,
191
+ );
192
+ });
193
+ }
194
+
195
+ const onOpenChange = vi.fn();
196
+
112
197
  it("opens chat surfaces before requesting settings on the next task", () => {
113
198
  let scheduledFrame: FrameRequestCallback | undefined;
114
199
  vi.stubGlobal(
@@ -144,6 +229,9 @@ describe("CommandMenu docs group", () => {
144
229
 
145
230
  it("filters app docs entries through the shared search field", () => {
146
231
  renderMenu();
232
+ expect(
233
+ document.querySelector('[data-testid="command-menu-filter-launchers"]'),
234
+ ).toBeNull();
147
235
 
148
236
  search("logs");
149
237
  expect(document.body.textContent).toContain(
@@ -156,6 +244,18 @@ describe("CommandMenu docs group", () => {
156
244
  );
157
245
  });
158
246
 
247
+ it("keeps the default dialog width and supports a shared wide variant", () => {
248
+ renderMenu();
249
+ expect(document.querySelector('[role="dialog"]')?.classList).toContain(
250
+ "max-w-lg",
251
+ );
252
+
253
+ renderAppCommands({ groups: [], size: "wide" });
254
+ expect(document.querySelector('[role="dialog"]')?.classList).toContain(
255
+ "max-w-3xl",
256
+ );
257
+ });
258
+
159
259
  it("matches every sign-out search alias", () => {
160
260
  renderMenu();
161
261
 
@@ -282,6 +382,32 @@ describe("CommandMenu docs group", () => {
282
382
  expect(document.body.textContent).toContain("Open transcript");
283
383
  });
284
384
 
385
+ it("does not show the empty state beside groups wrapped in a fragment", () => {
386
+ act(() => {
387
+ root.render(
388
+ <CommandMenu
389
+ open
390
+ onOpenChange={() => undefined}
391
+ emptyState="No matching commands"
392
+ showAgentFallback={false}
393
+ >
394
+ <>
395
+ <CommandMenu.Group heading="Actions">
396
+ <CommandMenu.Item onSelect={() => undefined}>
397
+ Open item
398
+ </CommandMenu.Item>
399
+ </CommandMenu.Group>
400
+ </>
401
+ </CommandMenu>,
402
+ );
403
+ });
404
+
405
+ search("open");
406
+
407
+ expect(document.body.textContent).toContain("Open item");
408
+ expect(document.querySelector('[role="status"]')).toBeNull();
409
+ });
410
+
285
411
  it("offers the shared About Agent-Native surface and matches version searches", () => {
286
412
  act(() => {
287
413
  root.render(
@@ -821,6 +947,88 @@ describe("CommandMenu docs group", () => {
821
947
  expect(onOpenChange).not.toHaveBeenCalled();
822
948
  });
823
949
 
950
+ it("dismisses a nested dialog before returning from an app page", async () => {
951
+ const dismissNested = vi.fn();
952
+ function NestedDialog() {
953
+ useCommandMenuNestedDialog(dismissNested);
954
+ return <div role="dialog" aria-label="Nested picker" />;
955
+ }
956
+
957
+ act(() => {
958
+ root.render(
959
+ <CommandMenu
960
+ open
961
+ onOpenChange={onOpenChange}
962
+ showAgentFallback={false}
963
+ showSettingsCommand={false}
964
+ showSignOutCommand={false}
965
+ appCommandGroups={[
966
+ {
967
+ id: "main",
968
+ order: 1,
969
+ commands: [
970
+ {
971
+ id: "open-tools",
972
+ order: 1,
973
+ label: "Open tools",
974
+ page: {
975
+ id: "tools",
976
+ title: "Tools",
977
+ groups: [
978
+ {
979
+ id: "tool-actions",
980
+ order: 1,
981
+ commands: [
982
+ {
983
+ id: "refresh",
984
+ order: 1,
985
+ label: "Refresh data",
986
+ onSelect() {},
987
+ },
988
+ ],
989
+ },
990
+ ],
991
+ },
992
+ },
993
+ ],
994
+ },
995
+ ]}
996
+ renderContent={({ renderList }) => (
997
+ <>
998
+ {renderList()}
999
+ <NestedDialog />
1000
+ </>
1001
+ )}
1002
+ >
1003
+ {null}
1004
+ </CommandMenu>,
1005
+ );
1006
+ });
1007
+
1008
+ const openPage = document.querySelector<HTMLElement>(
1009
+ '[cmdk-item][data-value="app-command:main:open-tools"]',
1010
+ );
1011
+ expect(openPage).toBeTruthy();
1012
+ act(() => openPage!.click());
1013
+ expect(document.body.textContent).toContain("Refresh data");
1014
+
1015
+ act(() => {
1016
+ document.dispatchEvent(
1017
+ new KeyboardEvent("keydown", {
1018
+ key: "Escape",
1019
+ bubbles: true,
1020
+ cancelable: true,
1021
+ }),
1022
+ );
1023
+ });
1024
+ await act(async () => Promise.resolve());
1025
+
1026
+ expect(dismissNested).toHaveBeenCalledOnce();
1027
+ expect(document.body.textContent).toContain("Refresh data");
1028
+ expect(document.body.textContent).toContain("Back");
1029
+ expect(onOpenChange).not.toHaveBeenCalled();
1030
+ });
1031
+
824
1032
  it("can opt into opening from a contenteditable target", () => {
825
1033
  function ShortcutHarness() {
826
1034
  const [open, setOpen] = React.useState(false);
@@ -1040,4 +1248,1457 @@ describe("CommandMenu docs group", () => {
1040
1248
  expect(editorHandled).toHaveBeenCalledWith(false);
1041
1249
  expect(event.defaultPrevented).toBe(false);
1042
1250
  });
1251
+
1252
+ it("orders app groups and commands by their explicit positions", () => {
1253
+ renderAppCommands({
1254
+ groups: [
1255
+ {
1256
+ id: "later",
1257
+ order: 20,
1258
+ heading: "Later group",
1259
+ commands: [
1260
+ { id: "last", order: 20, label: "Last command", onSelect() {} },
1261
+ { id: "first", order: 1, label: "First command", onSelect() {} },
1262
+ ],
1263
+ },
1264
+ {
1265
+ id: "earlier",
1266
+ order: 1,
1267
+ heading: "Earlier group",
1268
+ commands: [
1269
+ { id: "only", order: 1, label: "Earlier command", onSelect() {} },
1270
+ ],
1271
+ },
1272
+ ],
1273
+ });
1274
+
1275
+ const rows = Array.from(
1276
+ document.querySelectorAll<HTMLElement>(
1277
+ '[cmdk-item][data-value^="app-command:"]',
1278
+ ),
1279
+ );
1280
+ expect(rows.map((row) => row.textContent?.trim())).toEqual([
1281
+ "Earlier command",
1282
+ "First command",
1283
+ "Last command",
1284
+ ]);
1285
+ });
1286
+
1287
+ it("shows shortcut hints, searches aliases, and activates the matching command", () => {
1288
+ const onSelect = vi.fn();
1289
+ renderAppCommands({
1290
+ groups: [
1291
+ {
1292
+ id: "actions",
1293
+ order: 1,
1294
+ commands: [
1295
+ {
1296
+ id: "jump-to-today",
1297
+ order: 1,
1298
+ label: "Today",
1299
+ keywords: ["current date", "jump"],
1300
+ shortcutHint: "⌘ J",
1301
+ deferSelect: false,
1302
+ onSelect,
1303
+ },
1304
+ ],
1305
+ },
1306
+ ],
1307
+ });
1308
+
1309
+ expect(document.body.textContent).toContain("⌘ J");
1310
+ search("current date");
1311
+ expect(document.body.textContent).toContain("Today");
1312
+
1313
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
1314
+ expect(input).toBeTruthy();
1315
+ act(() => {
1316
+ input!.dispatchEvent(
1317
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
1318
+ );
1319
+ input!.dispatchEvent(
1320
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
1321
+ );
1322
+ });
1323
+
1324
+ expect(onSelect).toHaveBeenCalledOnce();
1325
+ expect(onOpenChange).toHaveBeenCalledWith(false);
1326
+
1327
+ search("jump-to-today");
1328
+ expect(
1329
+ document.querySelector(
1330
+ '[cmdk-item][data-value="app-command:actions:jump-to-today"]',
1331
+ ),
1332
+ ).toBeNull();
1333
+ search("⌘ J");
1334
+ expect(
1335
+ document.querySelector(
1336
+ '[cmdk-item][data-value="app-command:actions:jump-to-today"]',
1337
+ ),
1338
+ ).toBeNull();
1339
+ });
1340
+
1341
+ it("uses the latest nested page commands while the page is open", () => {
1342
+ const createGroups = (
1343
+ label: string,
1344
+ onSelect: () => void,
1345
+ ): AppCommandGroup[] => [
1346
+ {
1347
+ id: "main",
1348
+ order: 1,
1349
+ commands: [
1350
+ {
1351
+ id: "open-tools",
1352
+ order: 1,
1353
+ label: "Open tools",
1354
+ page: {
1355
+ id: "tools",
1356
+ title: "Tools",
1357
+ groups: [
1358
+ {
1359
+ id: "tool-actions",
1360
+ order: 1,
1361
+ commands: [
1362
+ {
1363
+ id: "refresh",
1364
+ order: 1,
1365
+ label,
1366
+ deferSelect: false,
1367
+ onSelect,
1368
+ },
1369
+ ],
1370
+ },
1371
+ ],
1372
+ },
1373
+ },
1374
+ ],
1375
+ },
1376
+ ];
1377
+ const originalSelect = vi.fn();
1378
+ const latestSelect = vi.fn();
1379
+
1380
+ renderAppCommands({ groups: createGroups("Old command", originalSelect) });
1381
+ const parentRow = document.querySelector<HTMLElement>(
1382
+ '[cmdk-item][data-value="app-command:main:open-tools"]',
1383
+ );
1384
+ expect(parentRow).toBeTruthy();
1385
+ act(() => parentRow!.click());
1386
+
1387
+ renderAppCommands({ groups: createGroups("Latest command", latestSelect) });
1388
+
1389
+ expect(document.body.textContent).toContain("Latest command");
1390
+ expect(document.body.textContent).not.toContain("Old command");
1391
+ const currentRow = document.querySelector<HTMLElement>(
1392
+ '[cmdk-item][data-value="app-command:tool-actions:refresh"]',
1393
+ );
1394
+ expect(currentRow).toBeTruthy();
1395
+ act(() => currentRow!.click());
1396
+ expect(latestSelect).toHaveBeenCalledOnce();
1397
+ expect(originalSelect).not.toHaveBeenCalled();
1398
+ });
1399
+
1400
+ it("pushes nested command pages and returns by Back or Escape", () => {
1401
+ renderAppCommands({
1402
+ groups: [
1403
+ {
1404
+ id: "main",
1405
+ order: 1,
1406
+ commands: [
1407
+ {
1408
+ id: "open-tools",
1409
+ order: 1,
1410
+ label: "Open tools",
1411
+ keywords: ["tools alias"],
1412
+ page: {
1413
+ id: "tools",
1414
+ title: "Tools",
1415
+ groups: [
1416
+ {
1417
+ id: "tool-actions",
1418
+ order: 1,
1419
+ heading: "Available tools",
1420
+ commands: [
1421
+ {
1422
+ id: "refresh",
1423
+ order: 1,
1424
+ label: "Refresh data",
1425
+ onSelect() {},
1426
+ },
1427
+ ],
1428
+ },
1429
+ ],
1430
+ },
1431
+ },
1432
+ ],
1433
+ },
1434
+ ],
1435
+ });
1436
+
1437
+ search("tools alias");
1438
+ const parentRow = document.querySelector<HTMLElement>(
1439
+ '[cmdk-item][data-value="app-command:main:open-tools"]',
1440
+ );
1441
+ expect(parentRow).toBeTruthy();
1442
+ act(() => parentRow!.click());
1443
+ expect(document.body.textContent).toContain("Refresh data");
1444
+ expect(document.body.textContent).toContain("Back");
1445
+
1446
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
1447
+ expect(input).toBeTruthy();
1448
+ const backRow = document.querySelector<HTMLElement>(
1449
+ '[data-testid="command-menu-page-back"]',
1450
+ );
1451
+ expect(backRow).toBeTruthy();
1452
+ backRow!.focus();
1453
+ expect(document.activeElement).toBe(backRow);
1454
+ act(() => backRow!.click());
1455
+
1456
+ expect(input?.value).toBe("tools alias");
1457
+ expect(document.activeElement).toBe(input);
1458
+ expect(document.body.textContent).toContain("Open tools");
1459
+ expect(document.body.textContent).not.toContain("Refresh data");
1460
+
1461
+ const returnedParentRow = document.querySelector<HTMLElement>(
1462
+ '[cmdk-item][data-value="app-command:main:open-tools"]',
1463
+ );
1464
+ expect(returnedParentRow).toBeTruthy();
1465
+ act(() => returnedParentRow!.click());
1466
+ expect(document.body.textContent).toContain("Refresh data");
1467
+
1468
+ act(() => {
1469
+ document.dispatchEvent(
1470
+ new KeyboardEvent("keydown", {
1471
+ key: "Escape",
1472
+ bubbles: true,
1473
+ cancelable: true,
1474
+ }),
1475
+ );
1476
+ });
1477
+
1478
+ expect(input?.value).toBe("tools alias");
1479
+ expect(document.body.textContent).toContain("Open tools");
1480
+ expect(document.body.textContent).not.toContain("Refresh data");
1481
+ });
1482
+
1483
+ it("shows an empty state when a nested page has no matching commands", () => {
1484
+ const hasRootResults = vi.fn(() => true);
1485
+ renderAppCommands({
1486
+ groups: [
1487
+ {
1488
+ id: "main",
1489
+ order: 1,
1490
+ commands: [
1491
+ {
1492
+ id: "open-tools",
1493
+ order: 1,
1494
+ label: "Open tools",
1495
+ page: {
1496
+ id: "tools",
1497
+ title: "Tools",
1498
+ groups: [
1499
+ {
1500
+ id: "tool-actions",
1501
+ order: 1,
1502
+ commands: [
1503
+ {
1504
+ id: "refresh",
1505
+ order: 1,
1506
+ label: "Refresh data",
1507
+ onSelect() {},
1508
+ },
1509
+ ],
1510
+ },
1511
+ ],
1512
+ },
1513
+ },
1514
+ ],
1515
+ },
1516
+ ],
1517
+ renderResults: () => (
1518
+ <CommandMenu.Group heading="Root results">
1519
+ <CommandMenu.Item onSelect={() => undefined}>
1520
+ Root fallback
1521
+ </CommandMenu.Item>
1522
+ </CommandMenu.Group>
1523
+ ),
1524
+ renderResultsHasItems: hasRootResults,
1525
+ emptyState: "No matching commands",
1526
+ });
1527
+
1528
+ const parentRow = document.querySelector<HTMLElement>(
1529
+ '[cmdk-item][data-value="app-command:main:open-tools"]',
1530
+ );
1531
+ expect(parentRow).toBeTruthy();
1532
+ act(() => parentRow!.click());
1533
+ expect(document.body.textContent).toContain("Refresh data");
1534
+ expect(document.body.textContent).not.toContain("Root fallback");
1535
+
1536
+ hasRootResults.mockClear();
1537
+ search("no-such-command");
1538
+
1539
+ expect(hasRootResults).not.toHaveBeenCalled();
1540
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
1541
+ "No matching commands",
1542
+ );
1543
+ expect(
1544
+ document.querySelector('[data-testid="command-menu-page-back"]'),
1545
+ ).toBeTruthy();
1546
+ });
1547
+
1548
+ it("selects the first matching page command instead of Back", () => {
1549
+ const onSelect = vi.fn();
1550
+ renderAppCommands({
1551
+ groups: [
1552
+ {
1553
+ id: "main",
1554
+ order: 1,
1555
+ commands: [
1556
+ {
1557
+ id: "open-tools",
1558
+ order: 1,
1559
+ label: "Open tools",
1560
+ page: {
1561
+ id: "tools",
1562
+ title: "Tools",
1563
+ groups: [
1564
+ {
1565
+ id: "tool-actions",
1566
+ order: 1,
1567
+ commands: [
1568
+ {
1569
+ id: "refresh",
1570
+ order: 1,
1571
+ label: "Refresh data",
1572
+ onSelect() {},
1573
+ },
1574
+ {
1575
+ id: "open-settings",
1576
+ order: 2,
1577
+ label: "Open settings",
1578
+ deferSelect: false,
1579
+ onSelect,
1580
+ },
1581
+ ],
1582
+ },
1583
+ ],
1584
+ },
1585
+ },
1586
+ ],
1587
+ },
1588
+ ],
1589
+ });
1590
+
1591
+ const parentRow = document.querySelector<HTMLElement>(
1592
+ '[cmdk-item][data-value="app-command:main:open-tools"]',
1593
+ );
1594
+ expect(parentRow).toBeTruthy();
1595
+ act(() => parentRow!.click());
1596
+
1597
+ search("settings");
1598
+ const pageCommand = document.querySelector<HTMLElement>(
1599
+ '[cmdk-item][data-value="app-command:tool-actions:open-settings"]',
1600
+ );
1601
+ expect(pageCommand?.getAttribute("aria-selected")).toBe("true");
1602
+
1603
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
1604
+ expect(input).toBeTruthy();
1605
+ act(() =>
1606
+ input!.dispatchEvent(
1607
+ new KeyboardEvent("keydown", {
1608
+ key: "Enter",
1609
+ bubbles: true,
1610
+ cancelable: true,
1611
+ }),
1612
+ ),
1613
+ );
1614
+ expect(onSelect).toHaveBeenCalledOnce();
1615
+ });
1616
+
1617
+ it("keeps page transitions unselected in none-selection mode", () => {
1618
+ renderAppCommands({
1619
+ initialSelection: "none",
1620
+ groups: [
1621
+ {
1622
+ id: "main",
1623
+ order: 1,
1624
+ commands: [
1625
+ {
1626
+ id: "open-tools",
1627
+ order: 1,
1628
+ label: "Open tools",
1629
+ page: {
1630
+ id: "tools",
1631
+ title: "Tools",
1632
+ groups: [
1633
+ {
1634
+ id: "tool-actions",
1635
+ order: 1,
1636
+ commands: [
1637
+ {
1638
+ id: "refresh",
1639
+ order: 1,
1640
+ label: "Refresh data",
1641
+ onSelect() {},
1642
+ },
1643
+ ],
1644
+ },
1645
+ ],
1646
+ },
1647
+ },
1648
+ ],
1649
+ },
1650
+ ],
1651
+ });
1652
+
1653
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
1654
+ expect(input).toBeTruthy();
1655
+ act(() =>
1656
+ input!.dispatchEvent(
1657
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
1658
+ ),
1659
+ );
1660
+ act(() =>
1661
+ input!.dispatchEvent(
1662
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
1663
+ ),
1664
+ );
1665
+ expect(document.body.textContent).toContain("Refresh data");
1666
+ expect(
1667
+ document.querySelector('[cmdk-item][aria-selected="true"]'),
1668
+ ).toBeNull();
1669
+
1670
+ const backButton = document.querySelector<HTMLButtonElement>(
1671
+ '[data-testid="command-menu-page-back"]',
1672
+ );
1673
+ expect(backButton).toBeTruthy();
1674
+ act(() => backButton!.click());
1675
+ expect(document.body.textContent).toContain("Open tools");
1676
+ expect(
1677
+ document.querySelector('[cmdk-item][aria-selected="true"]'),
1678
+ ).toBeNull();
1679
+ });
1680
+
1681
+ it("uses app context to control command visibility and enabled state", () => {
1682
+ const onSelect = vi.fn();
1683
+ renderAppCommands({
1684
+ context: { canEdit: false },
1685
+ groups: [
1686
+ {
1687
+ id: "actions",
1688
+ order: 1,
1689
+ commands: [
1690
+ {
1691
+ id: "hidden-record",
1692
+ order: 0,
1693
+ label: "Hidden record",
1694
+ visibleWhen: ({ appContext }) => appContext?.canEdit === true,
1695
+ onSelect,
1696
+ },
1697
+ {
1698
+ id: "edit-record",
1699
+ order: 1,
1700
+ label: "Edit record",
1701
+ enabledWhen: ({ appContext }) => appContext?.canEdit ?? false,
1702
+ onSelect,
1703
+ },
1704
+ ],
1705
+ },
1706
+ ],
1707
+ });
1708
+
1709
+ const row = document.querySelector<HTMLElement>(
1710
+ '[cmdk-item][data-value="app-command:actions:edit-record"]',
1711
+ );
1712
+ expect(
1713
+ document.querySelector(
1714
+ '[cmdk-item][data-value="app-command:actions:hidden-record"]',
1715
+ ),
1716
+ ).toBeNull();
1717
+ expect(row?.getAttribute("aria-disabled")).toBe("true");
1718
+ act(() => row?.click());
1719
+ expect(onSelect).not.toHaveBeenCalled();
1720
+ });
1721
+
1722
+ it("applies the public item matcher to app command rows", () => {
1723
+ const itemMatcher = vi.fn(
1724
+ ({ query, label, value, keywords }: CommandMenuItemMatchContext) =>
1725
+ query === "alias" &&
1726
+ label === "Open record" &&
1727
+ value === "record" &&
1728
+ keywords.includes("unrelated"),
1729
+ );
1730
+ renderAppCommands({
1731
+ itemMatcher,
1732
+ groups: [
1733
+ {
1734
+ id: "actions",
1735
+ order: 1,
1736
+ commands: [
1737
+ {
1738
+ id: "record",
1739
+ order: 1,
1740
+ label: "Open record",
1741
+ keywords: ["unrelated"],
1742
+ onSelect() {},
1743
+ },
1744
+ ],
1745
+ },
1746
+ ],
1747
+ });
1748
+
1749
+ search("alias");
1750
+
1751
+ expect(itemMatcher).toHaveBeenCalledWith({
1752
+ query: "alias",
1753
+ label: "Open record",
1754
+ value: "record",
1755
+ keywords: ["unrelated"],
1756
+ });
1757
+ expect(
1758
+ document.querySelector(
1759
+ '[cmdk-item][data-value="app-command:actions:record"]',
1760
+ ),
1761
+ ).toBeTruthy();
1762
+ });
1763
+
1764
+ it("uses the app matcher even when the group heading matches", () => {
1765
+ const itemMatcher = vi.fn(() => false);
1766
+ renderAppCommands({
1767
+ itemMatcher,
1768
+ groups: [
1769
+ {
1770
+ id: "reports",
1771
+ order: 1,
1772
+ heading: "Reports",
1773
+ commands: [
1774
+ {
1775
+ id: "open-report",
1776
+ order: 1,
1777
+ label: "Open report",
1778
+ onSelect() {},
1779
+ },
1780
+ ],
1781
+ },
1782
+ ],
1783
+ });
1784
+
1785
+ search("reports");
1786
+
1787
+ expect(itemMatcher).toHaveBeenCalled();
1788
+ expect(
1789
+ document.querySelector(
1790
+ '[cmdk-item][data-value="app-command:reports:open-report"]',
1791
+ ),
1792
+ ).toBeNull();
1793
+ });
1794
+
1795
+ it("renders the selected command inline preview", () => {
1796
+ renderAppCommands({
1797
+ groups: [
1798
+ {
1799
+ id: "results",
1800
+ order: 1,
1801
+ commands: [
1802
+ {
1803
+ id: "record",
1804
+ order: 1,
1805
+ label: "Meeting notes",
1806
+ preview: <p>First preview</p>,
1807
+ onSelect() {},
1808
+ },
1809
+ {
1810
+ id: "meeting-agenda",
1811
+ order: 2,
1812
+ label: "Meeting agenda",
1813
+ preview: <p>Selected preview</p>,
1814
+ onSelect() {},
1815
+ },
1816
+ ],
1817
+ },
1818
+ ],
1819
+ });
1820
+
1821
+ expect(
1822
+ document.querySelector('[data-testid="command-menu-inline-preview"]')
1823
+ ?.textContent,
1824
+ ).toContain("First preview");
1825
+
1826
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
1827
+ expect(input).toBeTruthy();
1828
+ act(() =>
1829
+ input!.dispatchEvent(
1830
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
1831
+ ),
1832
+ );
1833
+ expect(
1834
+ document.querySelector('[data-testid="command-menu-inline-preview"]')
1835
+ ?.textContent,
1836
+ ).toContain("Selected preview");
1837
+ });
1838
+
1839
+ it("hides the app preview when a non-app result is selected", () => {
1840
+ renderAppCommands({
1841
+ groups: [
1842
+ {
1843
+ id: "results",
1844
+ order: 1,
1845
+ commands: [
1846
+ {
1847
+ id: "record",
1848
+ order: 1,
1849
+ label: "Meeting notes",
1850
+ preview: <p>Record preview</p>,
1851
+ onSelect() {},
1852
+ },
1853
+ ],
1854
+ },
1855
+ ],
1856
+ renderResults: () => (
1857
+ <CommandMenu.Group heading="Live results">
1858
+ <CommandMenu.Item onSelect={() => undefined}>
1859
+ Open live result
1860
+ </CommandMenu.Item>
1861
+ </CommandMenu.Group>
1862
+ ),
1863
+ });
1864
+
1865
+ expect(
1866
+ document.querySelector('[cmdk-item][aria-selected="true"]')?.textContent,
1867
+ ).toContain("Open live result");
1868
+ expect(
1869
+ document.querySelector('[data-testid="command-menu-inline-preview"]'),
1870
+ ).toBeNull();
1871
+ });
1872
+
1873
+ it("passes current search and close behavior to the embedded chat slot", () => {
1874
+ renderAppCommands({
1875
+ groups: [],
1876
+ context: { workspace: "alpha" },
1877
+ embeddedChatSlot: ({ search: currentSearch, close, appContext }) => (
1878
+ <div>
1879
+ <span>{currentSearch}</span>
1880
+ <span>{appContext?.workspace}</span>
1881
+ <button type="button" onClick={close}>
1882
+ Close embedded chat
1883
+ </button>
1884
+ </div>
1885
+ ),
1886
+ });
1887
+
1888
+ search("summarize this");
1889
+ expect(document.body.textContent).toContain("summarize this");
1890
+ expect(document.body.textContent).toContain("alpha");
1891
+
1892
+ const closeButton = Array.from(document.querySelectorAll("button")).find(
1893
+ (button) => button.textContent === "Close embedded chat",
1894
+ );
1895
+ expect(closeButton).toBeTruthy();
1896
+ act(() => closeButton!.click());
1897
+ expect(onOpenChange).toHaveBeenCalledWith(false);
1898
+ });
1899
+
1900
+ it("keeps embedded chat keyboard input out of command selection", () => {
1901
+ const onSelect = vi.fn();
1902
+ renderAppCommands({
1903
+ groups: [
1904
+ {
1905
+ id: "actions",
1906
+ order: 1,
1907
+ commands: [
1908
+ {
1909
+ id: "open-record",
1910
+ order: 1,
1911
+ label: "Open record",
1912
+ onSelect,
1913
+ },
1914
+ ],
1915
+ },
1916
+ ],
1917
+ embeddedChatSlot: () => <input aria-label="Embedded chat" />,
1918
+ });
1919
+
1920
+ const input = document.querySelector<HTMLInputElement>(
1921
+ '[aria-label="Embedded chat"]',
1922
+ );
1923
+ expect(input).toBeTruthy();
1924
+ const selectedBefore = document.querySelector(
1925
+ '[cmdk-item][aria-selected="true"]',
1926
+ );
1927
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
1928
+ const dialogKeyDown = vi.fn();
1929
+ dialog?.addEventListener("keydown", dialogKeyDown);
1930
+ const tab = new KeyboardEvent("keydown", {
1931
+ key: "Tab",
1932
+ bubbles: true,
1933
+ cancelable: true,
1934
+ });
1935
+ act(() => input!.dispatchEvent(tab));
1936
+ expect(dialogKeyDown).toHaveBeenCalledOnce();
1937
+ const arrowDown = new KeyboardEvent("keydown", {
1938
+ key: "ArrowDown",
1939
+ bubbles: true,
1940
+ cancelable: true,
1941
+ });
1942
+ const enter = new KeyboardEvent("keydown", {
1943
+ key: "Enter",
1944
+ bubbles: true,
1945
+ cancelable: true,
1946
+ });
1947
+ act(() => {
1948
+ input!.dispatchEvent(arrowDown);
1949
+ input!.dispatchEvent(enter);
1950
+ });
1951
+
1952
+ expect(arrowDown.defaultPrevented).toBe(false);
1953
+ expect(enter.defaultPrevented).toBe(false);
1954
+ expect(onSelect).not.toHaveBeenCalled();
1955
+ expect(onOpenChange).not.toHaveBeenCalled();
1956
+ expect(document.querySelector('[cmdk-item][aria-selected="true"]')).toBe(
1957
+ selectedBefore,
1958
+ );
1959
+ });
1960
+
1961
+ it("passes palette context to filter launchers and keeps their keys out of command navigation", () => {
1962
+ renderAppCommands({
1963
+ groups: [
1964
+ {
1965
+ id: "reports",
1966
+ order: 1,
1967
+ commands: [
1968
+ {
1969
+ id: "revenue-report",
1970
+ order: 1,
1971
+ label: "Revenue report",
1972
+ onSelect() {},
1973
+ },
1974
+ {
1975
+ id: "revenue-forecast",
1976
+ order: 2,
1977
+ label: "Revenue forecast",
1978
+ onSelect() {},
1979
+ },
1980
+ ],
1981
+ },
1982
+ ],
1983
+ context: { workspace: "alpha" },
1984
+ showSettingsCommand: false,
1985
+ showSignOutCommand: false,
1986
+ filterLauncherSlot: ({ search: currentSearch, appContext, close }) => (
1987
+ <button type="button" onClick={close}>
1988
+ {currentSearch}:{appContext?.workspace}:Filter records
1989
+ </button>
1990
+ ),
1991
+ });
1992
+
1993
+ search("revenue");
1994
+ const slot = document.querySelector(
1995
+ '[data-testid="command-menu-filter-launchers"]',
1996
+ );
1997
+ expect(slot?.textContent).toContain("revenue:alpha:Filter records");
1998
+
1999
+ const selectedBefore = document.querySelector<HTMLElement>(
2000
+ '[cmdk-item][aria-selected="true"]',
2001
+ );
2002
+ expect(selectedBefore?.textContent).toContain("Revenue report");
2003
+ const arrowDown = new KeyboardEvent("keydown", {
2004
+ key: "ArrowDown",
2005
+ bubbles: true,
2006
+ cancelable: true,
2007
+ });
2008
+ const launcher = slot?.querySelector("button");
2009
+ expect(launcher).toBeTruthy();
2010
+ act(() => launcher!.dispatchEvent(arrowDown));
2011
+ expect(document.querySelector('[cmdk-item][aria-selected="true"]')).toBe(
2012
+ selectedBefore,
2013
+ );
2014
+
2015
+ act(() => launcher!.click());
2016
+ expect(onOpenChange).toHaveBeenCalledWith(false);
2017
+ });
2018
+
2019
+ it("keeps disabled items searchable and unselectable", () => {
2020
+ const onSelect = vi.fn();
2021
+ act(() => {
2022
+ root.render(
2023
+ <CommandMenu
2024
+ open
2025
+ onOpenChange={() => undefined}
2026
+ showAgentFallback={false}
2027
+ showSettingsCommand={false}
2028
+ showSignOutCommand={false}
2029
+ >
2030
+ <CommandMenu.Group heading="Actions">
2031
+ <CommandMenu.Item
2032
+ value="locked-action"
2033
+ keywords={["locked alias"]}
2034
+ disabled
2035
+ onSelect={onSelect}
2036
+ >
2037
+ Locked action
2038
+ </CommandMenu.Item>
2039
+ </CommandMenu.Group>
2040
+ </CommandMenu>,
2041
+ );
2042
+ });
2043
+
2044
+ search("locked alias");
2045
+ const row = document.querySelector<HTMLElement>(
2046
+ '[cmdk-item][data-value="locked-action"]',
2047
+ );
2048
+ expect(row?.textContent).toContain("Locked action");
2049
+ expect(row?.getAttribute("aria-disabled")).toBe("true");
2050
+ act(() => row?.click());
2051
+ expect(onSelect).not.toHaveBeenCalled();
2052
+ });
2053
+
2054
+ it("shows a dynamic item's preview inside only its selected row", () => {
2055
+ act(() => {
2056
+ root.render(
2057
+ <CommandMenu
2058
+ open
2059
+ onOpenChange={() => undefined}
2060
+ showAgentFallback={false}
2061
+ showSettingsCommand={false}
2062
+ showSignOutCommand={false}
2063
+ initialSelection="first"
2064
+ >
2065
+ <CommandMenu.Group heading="Records">
2066
+ <CommandMenu.Item
2067
+ value="record-one"
2068
+ preview={<span>First record details</span>}
2069
+ onSelect={() => undefined}
2070
+ >
2071
+ First record
2072
+ </CommandMenu.Item>
2073
+ <CommandMenu.Item
2074
+ value="record-two"
2075
+ preview={<span>Second record details</span>}
2076
+ onSelect={() => undefined}
2077
+ >
2078
+ Second record
2079
+ </CommandMenu.Item>
2080
+ </CommandMenu.Group>
2081
+ </CommandMenu>,
2082
+ );
2083
+ });
2084
+
2085
+ const selectedRow = document.querySelector<HTMLElement>(
2086
+ '[cmdk-item][data-value="record-one"]',
2087
+ );
2088
+ const otherRow = document.querySelector<HTMLElement>(
2089
+ '[cmdk-item][data-value="record-two"]',
2090
+ );
2091
+ expect(
2092
+ selectedRow?.querySelector('[data-testid="command-menu-item-preview"]')
2093
+ ?.textContent,
2094
+ ).toBe("First record details");
2095
+ expect(
2096
+ otherRow?.querySelector('[data-testid="command-menu-item-preview"]'),
2097
+ ).toBeNull();
2098
+
2099
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
2100
+ act(() =>
2101
+ input!.dispatchEvent(
2102
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
2103
+ ),
2104
+ );
2105
+
2106
+ expect(
2107
+ document
2108
+ .querySelector('[cmdk-item][data-value="record-two"]')
2109
+ ?.querySelector('[data-testid="command-menu-item-preview"]')
2110
+ ?.textContent,
2111
+ ).toBe("Second record details");
2112
+ });
2113
+
2114
+ it("uses an app matcher and reports each search change", () => {
2115
+ const itemMatcher = vi.fn(
2116
+ ({
2117
+ query,
2118
+ label,
2119
+ value,
2120
+ keywords,
2121
+ }: Parameters<CommandMenuItemMatcher>[0]) =>
2122
+ query === "alias" &&
2123
+ label === "Open record" &&
2124
+ value === "record" &&
2125
+ keywords.includes("unrelated"),
2126
+ );
2127
+ const onSearchChange = vi.fn();
2128
+
2129
+ act(() => {
2130
+ root.render(
2131
+ <CommandMenu
2132
+ open
2133
+ onOpenChange={() => undefined}
2134
+ showAgentFallback={false}
2135
+ showSettingsCommand={false}
2136
+ showSignOutCommand={false}
2137
+ itemMatcher={itemMatcher}
2138
+ onSearchChange={onSearchChange}
2139
+ >
2140
+ <CommandMenu.Group heading="Actions">
2141
+ <CommandMenu.Item
2142
+ value="record"
2143
+ keywords={["unrelated"]}
2144
+ onSelect={() => undefined}
2145
+ >
2146
+ Open record
2147
+ </CommandMenu.Item>
2148
+ </CommandMenu.Group>
2149
+ </CommandMenu>,
2150
+ );
2151
+ });
2152
+
2153
+ search("alias");
2154
+
2155
+ expect(itemMatcher).toHaveBeenCalledWith({
2156
+ query: "alias",
2157
+ label: "Open record",
2158
+ value: "record",
2159
+ keywords: ["unrelated"],
2160
+ });
2161
+ expect(
2162
+ document.querySelector('[cmdk-item][data-value="record"]'),
2163
+ ).toBeTruthy();
2164
+ expect(onSearchChange).toHaveBeenLastCalledWith("alias");
2165
+ expect(onSearchChange.mock.calls.map(([query]) => query)).toEqual([
2166
+ "alias",
2167
+ ]);
2168
+
2169
+ search("unrelated");
2170
+ expect(
2171
+ document.querySelector('[cmdk-item][data-value="record"]'),
2172
+ ).toBeNull();
2173
+ expect(onSearchChange.mock.calls.map(([query]) => query)).toEqual([
2174
+ "alias",
2175
+ "unrelated",
2176
+ ]);
2177
+
2178
+ act(() => {
2179
+ root.render(
2180
+ <CommandMenu
2181
+ open={false}
2182
+ onOpenChange={() => undefined}
2183
+ showAgentFallback={false}
2184
+ onSearchChange={onSearchChange}
2185
+ >
2186
+ {null}
2187
+ </CommandMenu>,
2188
+ );
2189
+ });
2190
+ expect(onSearchChange).toHaveBeenLastCalledWith("");
2191
+ expect(onSearchChange.mock.calls.map(([query]) => query)).toEqual([
2192
+ "alias",
2193
+ "unrelated",
2194
+ "",
2195
+ ]);
2196
+ });
2197
+
2198
+ it("places dynamic results around app command groups", () => {
2199
+ const groups = [
2200
+ {
2201
+ id: "app-actions",
2202
+ order: 1,
2203
+ heading: "App actions",
2204
+ commands: [
2205
+ {
2206
+ id: "open-record",
2207
+ order: 1,
2208
+ label: "Open record",
2209
+ keywords: ["match"],
2210
+ onSelect() {},
2211
+ },
2212
+ ],
2213
+ },
2214
+ ];
2215
+ const renderResults = () => (
2216
+ <CommandMenu.Group heading="Live results">
2217
+ <CommandMenu.Item onSelect={() => undefined}>
2218
+ Live match
2219
+ </CommandMenu.Item>
2220
+ </CommandMenu.Group>
2221
+ );
2222
+
2223
+ renderAppCommands({
2224
+ groups,
2225
+ renderResultsPosition: "after",
2226
+ renderResults,
2227
+ });
2228
+ search("match");
2229
+
2230
+ const listText = document.querySelector("[cmdk-list]")?.textContent ?? "";
2231
+ expect(listText.indexOf("Open record")).toBeGreaterThanOrEqual(0);
2232
+ expect(listText.indexOf("Live match")).toBeGreaterThan(
2233
+ listText.indexOf("Open record"),
2234
+ );
2235
+
2236
+ renderAppCommands({ groups, renderResults });
2237
+ search("match");
2238
+ const defaultListText =
2239
+ document.querySelector("[cmdk-list]")?.textContent ?? "";
2240
+ expect(defaultListText.indexOf("Live match")).toBeLessThan(
2241
+ defaultListText.indexOf("Open record"),
2242
+ );
2243
+ });
2244
+
2245
+ it("allows apps to hide Settings and Sign out while preserving their defaults", () => {
2246
+ renderMenu();
2247
+ expect(document.body.textContent).toContain("Settings");
2248
+ expect(document.body.textContent).toContain("Log out");
2249
+
2250
+ renderAppCommands({
2251
+ groups: [],
2252
+ showSettingsCommand: false,
2253
+ showSignOutCommand: false,
2254
+ });
2255
+ expect(document.body.textContent).not.toContain("Settings");
2256
+ expect(document.body.textContent).not.toContain("Log out");
2257
+ });
2258
+
2259
+ it("renders a custom empty state beside the agent fallback and palette footer", async () => {
2260
+ actionMocks.callAction.mockResolvedValue({ engines: [], current: {} });
2261
+ vi.stubGlobal(
2262
+ "fetch",
2263
+ vi.fn(async (input: unknown) => {
2264
+ const url = String(input);
2265
+ if (url.includes("env-status")) return Response.json([]);
2266
+ if (url.includes("agent-engine/status")) {
2267
+ return Response.json({ configured: false });
2268
+ }
2269
+ return Response.json({});
2270
+ }),
2271
+ );
2272
+
2273
+ renderAppCommands({
2274
+ groups: [],
2275
+ emptyState: "No matching records",
2276
+ footerSlot: <span>⌘ K to search</span>,
2277
+ showAgentFallback: true,
2278
+ });
2279
+ await act(async () => {
2280
+ await new Promise((resolve) => setTimeout(resolve, 0));
2281
+ });
2282
+
2283
+ search("no-such-command");
2284
+
2285
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
2286
+ "No matching records",
2287
+ );
2288
+ expect(document.querySelector('[role="note"]')?.textContent).toContain(
2289
+ "⌘ K to search",
2290
+ );
2291
+ expect(document.body.textContent).toContain("Ask AI");
2292
+ });
2293
+
2294
+ it("shows the custom empty state when dynamic results contain no command items", () => {
2295
+ renderAppCommands({
2296
+ groups: [],
2297
+ renderResults: () => (
2298
+ <CommandMenu.Group heading="Dynamic results">{null}</CommandMenu.Group>
2299
+ ),
2300
+ emptyState: "No matching records",
2301
+ });
2302
+
2303
+ search("missing");
2304
+
2305
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
2306
+ "No matching records",
2307
+ );
2308
+ });
2309
+
2310
+ it("uses result presence for opaque components that render no rows", () => {
2311
+ function EmptyResults() {
2312
+ return null;
2313
+ }
2314
+
2315
+ renderAppCommands({
2316
+ groups: [],
2317
+ renderResults: () => <EmptyResults />,
2318
+ renderResultsHasItems: () => false,
2319
+ emptyState: "No matching records",
2320
+ });
2321
+
2322
+ search("missing");
2323
+
2324
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
2325
+ "No matching records",
2326
+ );
2327
+ });
2328
+
2329
+ it("shows app search suggestions only when a non-empty query has no results", () => {
2330
+ renderAppCommands({
2331
+ groups: [],
2332
+ emptyState: "No matching records",
2333
+ noMatchSuggestionsSlot: (query) => (
2334
+ <button type="button">Search {query} everywhere</button>
2335
+ ),
2336
+ });
2337
+
2338
+ search("missing");
2339
+
2340
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
2341
+ "Search missing everywhere",
2342
+ );
2343
+ });
2344
+
2345
+ it("does not show the empty state beside component-rendered results", () => {
2346
+ function LiveResults() {
2347
+ return (
2348
+ <CommandMenu.Group heading="Live results">
2349
+ <CommandMenu.Item onSelect={() => undefined}>
2350
+ Live result
2351
+ </CommandMenu.Item>
2352
+ </CommandMenu.Group>
2353
+ );
2354
+ }
2355
+
2356
+ renderAppCommands({
2357
+ groups: [],
2358
+ renderResults: () => <LiveResults />,
2359
+ renderResultsHasItems: () => true,
2360
+ emptyState: "No matching records",
2361
+ });
2362
+
2363
+ search("active");
2364
+
2365
+ expect(document.body.textContent).toContain("Live result");
2366
+ expect(document.querySelector('[role="status"]')).toBeNull();
2367
+ });
2368
+
2369
+ it("uses the render-results presence signal for components that render no rows", () => {
2370
+ function EmptyLiveResults() {
2371
+ return null;
2372
+ }
2373
+
2374
+ renderAppCommands({
2375
+ groups: [],
2376
+ renderResults: () => <EmptyLiveResults />,
2377
+ renderResultsHasItems: () => false,
2378
+ emptyState: "No matching records",
2379
+ });
2380
+
2381
+ search("missing");
2382
+
2383
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
2384
+ "No matching records",
2385
+ );
2386
+ });
2387
+
2388
+ it("uses the explicit list's rows instead of the default result signal", () => {
2389
+ const hasDefaultResults = vi.fn(() => true);
2390
+
2391
+ act(() => {
2392
+ root.render(
2393
+ <CommandMenu
2394
+ open
2395
+ onOpenChange={() => undefined}
2396
+ showAgentFallback={false}
2397
+ renderResults={() => (
2398
+ <CommandMenu.Group heading="Default results">
2399
+ <CommandMenu.Item onSelect={() => undefined}>
2400
+ Default result
2401
+ </CommandMenu.Item>
2402
+ </CommandMenu.Group>
2403
+ )}
2404
+ renderResultsHasItems={hasDefaultResults}
2405
+ renderContent={({ renderList }) => renderList(null)}
2406
+ emptyState="No matching records"
2407
+ >
2408
+ {null}
2409
+ </CommandMenu>,
2410
+ );
2411
+ });
2412
+
2413
+ expect(document.body.textContent).not.toContain("Default result");
2414
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
2415
+ "No matching records",
2416
+ );
2417
+ expect(hasDefaultResults).not.toHaveBeenCalled();
2418
+ });
2419
+
2420
+ it("does not show the empty state beside custom list rows", () => {
2421
+ const hasDefaultResults = vi.fn(() => false);
2422
+
2423
+ act(() => {
2424
+ root.render(
2425
+ <CommandMenu
2426
+ open
2427
+ onOpenChange={() => undefined}
2428
+ showAgentFallback={false}
2429
+ renderResults={() => null}
2430
+ renderResultsHasItems={hasDefaultResults}
2431
+ renderContent={({ renderList }) =>
2432
+ renderList(
2433
+ <CommandMenu.Group heading="Custom results">
2434
+ <CommandMenu.Item onSelect={() => undefined}>
2435
+ Custom result
2436
+ </CommandMenu.Item>
2437
+ </CommandMenu.Group>,
2438
+ )
2439
+ }
2440
+ emptyState="No matching records"
2441
+ >
2442
+ {null}
2443
+ </CommandMenu>,
2444
+ );
2445
+ });
2446
+
2447
+ expect(document.body.textContent).toContain("Custom result");
2448
+ expect(document.querySelector('[role="status"]')).toBeNull();
2449
+ expect(hasDefaultResults).not.toHaveBeenCalled();
2450
+ });
2451
+
2452
+ it("accepts a presence hint for custom component results", () => {
2453
+ function EmptyCustomResults() {
2454
+ return null;
2455
+ }
2456
+
2457
+ const hasDefaultResults = vi.fn(() => true);
2458
+
2459
+ act(() => {
2460
+ root.render(
2461
+ <CommandMenu
2462
+ open
2463
+ onOpenChange={() => undefined}
2464
+ showAgentFallback={false}
2465
+ renderResults={() => (
2466
+ <CommandMenu.Group heading="Default results">
2467
+ <CommandMenu.Item onSelect={() => undefined}>
2468
+ Default result
2469
+ </CommandMenu.Item>
2470
+ </CommandMenu.Group>
2471
+ )}
2472
+ renderResultsHasItems={hasDefaultResults}
2473
+ renderContent={({ renderList }) =>
2474
+ renderList(<EmptyCustomResults />, { hasSelectableItems: false })
2475
+ }
2476
+ emptyState="No matching records"
2477
+ >
2478
+ {null}
2479
+ </CommandMenu>,
2480
+ );
2481
+ });
2482
+
2483
+ expect(document.body.textContent).not.toContain("Default result");
2484
+ expect(document.querySelector('[role="status"]')?.textContent).toContain(
2485
+ "No matching records",
2486
+ );
2487
+ expect(hasDefaultResults).not.toHaveBeenCalled();
2488
+ });
2489
+
2490
+ it("honors first and none initial selection modes", () => {
2491
+ const groups = [
2492
+ {
2493
+ id: "actions",
2494
+ order: 1,
2495
+ commands: [
2496
+ { id: "first", order: 1, label: "First", onSelect() {} },
2497
+ { id: "second", order: 2, label: "Second", onSelect() {} },
2498
+ ],
2499
+ },
2500
+ ];
2501
+
2502
+ renderAppCommands({ groups, initialSelection: "first" });
2503
+ expect(
2504
+ document.querySelector(
2505
+ '[cmdk-item][data-value="app-command:actions:first"][aria-selected="true"]',
2506
+ ),
2507
+ ).toBeTruthy();
2508
+
2509
+ renderAppCommands({ groups, initialSelection: "none", open: false });
2510
+ renderAppCommands({ groups, initialSelection: "none" });
2511
+ expect(
2512
+ document.querySelector('[cmdk-item][aria-selected="true"]'),
2513
+ ).toBeNull();
2514
+
2515
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
2516
+ expect(input).toBeTruthy();
2517
+ act(() =>
2518
+ input!.dispatchEvent(
2519
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
2520
+ ),
2521
+ );
2522
+ expect(
2523
+ document.querySelector('[cmdk-item][aria-selected="true"]'),
2524
+ ).toBeTruthy();
2525
+
2526
+ renderAppCommands({ groups, open: false });
2527
+ renderAppCommands({ groups });
2528
+ expect(
2529
+ document.querySelector(
2530
+ '[cmdk-item][data-value="app-command:actions:first"][aria-selected="true"]',
2531
+ ),
2532
+ ).toBeTruthy();
2533
+ });
2534
+
2535
+ it("keeps typing unselected until keyboard navigation begins", () => {
2536
+ const onSelect = vi.fn();
2537
+ renderAppCommands({
2538
+ groups: [
2539
+ {
2540
+ id: "actions",
2541
+ order: 1,
2542
+ commands: [
2543
+ {
2544
+ id: "match",
2545
+ order: 1,
2546
+ label: "Matching action",
2547
+ deferSelect: false,
2548
+ onSelect,
2549
+ },
2550
+ ],
2551
+ },
2552
+ ],
2553
+ initialSelection: "none",
2554
+ });
2555
+
2556
+ search("matching");
2557
+ expect(
2558
+ document.querySelector('[cmdk-item][aria-selected="true"]'),
2559
+ ).toBeNull();
2560
+
2561
+ const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
2562
+ expect(input).toBeTruthy();
2563
+ act(() =>
2564
+ input!.dispatchEvent(
2565
+ new KeyboardEvent("keydown", {
2566
+ key: "Enter",
2567
+ bubbles: true,
2568
+ cancelable: true,
2569
+ }),
2570
+ ),
2571
+ );
2572
+ expect(onSelect).not.toHaveBeenCalled();
2573
+
2574
+ act(() =>
2575
+ input!.dispatchEvent(
2576
+ new KeyboardEvent("keydown", {
2577
+ key: "ArrowDown",
2578
+ bubbles: true,
2579
+ cancelable: true,
2580
+ }),
2581
+ ),
2582
+ );
2583
+ expect(
2584
+ document.querySelector('[cmdk-item][aria-selected="true"]'),
2585
+ ).toBeTruthy();
2586
+ act(() =>
2587
+ input!.dispatchEvent(
2588
+ new KeyboardEvent("keydown", {
2589
+ key: "Enter",
2590
+ bubbles: true,
2591
+ cancelable: true,
2592
+ }),
2593
+ ),
2594
+ );
2595
+ expect(onSelect).toHaveBeenCalledOnce();
2596
+ });
2597
+
2598
+ it("selects the first app result when it arrives after the menu opens", () => {
2599
+ renderAppCommands({
2600
+ groups: [],
2601
+ initialSelection: "first",
2602
+ showSettingsCommand: false,
2603
+ showSignOutCommand: false,
2604
+ });
2605
+ expect(
2606
+ document.querySelector('[cmdk-item][aria-selected="true"]'),
2607
+ ).toBeNull();
2608
+
2609
+ renderAppCommands({
2610
+ groups: [
2611
+ {
2612
+ id: "async-results",
2613
+ order: 1,
2614
+ commands: [
2615
+ {
2616
+ id: "first-result",
2617
+ order: 1,
2618
+ label: "First result",
2619
+ onSelect() {},
2620
+ },
2621
+ ],
2622
+ },
2623
+ ],
2624
+ initialSelection: "first",
2625
+ showSettingsCommand: false,
2626
+ showSignOutCommand: false,
2627
+ });
2628
+
2629
+ expect(
2630
+ document.querySelector(
2631
+ '[cmdk-item][data-value="app-command:async-results:first-result"][aria-selected="true"]',
2632
+ ),
2633
+ ).toBeTruthy();
2634
+ });
2635
+
2636
+ it("allows pointer selection when initialSelection is none", () => {
2637
+ renderAppCommands({
2638
+ groups: [
2639
+ {
2640
+ id: "actions",
2641
+ order: 1,
2642
+ commands: [{ id: "first", order: 1, label: "First", onSelect() {} }],
2643
+ },
2644
+ ],
2645
+ initialSelection: "none",
2646
+ });
2647
+
2648
+ const row = document.querySelector<HTMLElement>(
2649
+ '[cmdk-item][data-value="app-command:actions:first"]',
2650
+ );
2651
+ expect(row).toBeTruthy();
2652
+ act(() => {
2653
+ row!.dispatchEvent(new Event("pointermove", { bubbles: true }));
2654
+ });
2655
+
2656
+ expect(row?.getAttribute("aria-selected")).toBe("true");
2657
+ });
2658
+
2659
+ it("allows direct pointer activation when initialSelection is none", () => {
2660
+ const onSelect = vi.fn();
2661
+ renderAppCommands({
2662
+ groups: [
2663
+ {
2664
+ id: "actions",
2665
+ order: 1,
2666
+ commands: [
2667
+ {
2668
+ id: "first",
2669
+ order: 1,
2670
+ label: "First",
2671
+ deferSelect: false,
2672
+ onSelect,
2673
+ },
2674
+ ],
2675
+ },
2676
+ ],
2677
+ initialSelection: "none",
2678
+ });
2679
+
2680
+ const row = document.querySelector<HTMLElement>(
2681
+ '[cmdk-item][data-value="app-command:actions:first"]',
2682
+ );
2683
+ expect(row).toBeTruthy();
2684
+ act(() => {
2685
+ row!.dispatchEvent(new Event("pointerdown", { bubbles: true }));
2686
+ row!.click();
2687
+ });
2688
+
2689
+ expect(onSelect).toHaveBeenCalledOnce();
2690
+ });
2691
+
2692
+ it("portals the menu into an app-provided container", () => {
2693
+ const portalContainer = document.createElement("div");
2694
+ portalContainer.dataset.testid = "command-menu-portal";
2695
+ document.body.appendChild(portalContainer);
2696
+
2697
+ renderAppCommands({ groups: [], container: portalContainer });
2698
+
2699
+ expect(portalContainer.querySelector('[role="dialog"]')).toBeTruthy();
2700
+ expect(document.body.querySelector('[role="dialog"]')).toBe(
2701
+ portalContainer.querySelector('[role="dialog"]'),
2702
+ );
2703
+ });
1043
2704
  });