@agent-native/toolkit 0.211.0 → 0.212.0-nightly-20261021010049

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 (143) hide show
  1. package/agent-native.eject.json +1 -0
  2. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  3. package/dist/app/i18n/catalogs/ar-SA.js +23 -0
  4. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  5. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  6. package/dist/app/i18n/catalogs/de-DE.js +23 -0
  7. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  8. package/dist/app/i18n/catalogs/en-US.d.ts +23 -0
  9. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  10. package/dist/app/i18n/catalogs/en-US.js +23 -0
  11. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  12. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  13. package/dist/app/i18n/catalogs/es-ES.js +23 -0
  14. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  15. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  16. package/dist/app/i18n/catalogs/fr-FR.js +23 -0
  17. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  18. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  19. package/dist/app/i18n/catalogs/hi-IN.js +23 -0
  20. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  21. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  22. package/dist/app/i18n/catalogs/ja-JP.js +23 -0
  23. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  24. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  25. package/dist/app/i18n/catalogs/ko-KR.js +23 -0
  26. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  27. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  28. package/dist/app/i18n/catalogs/pt-BR.js +23 -0
  29. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  30. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  31. package/dist/app/i18n/catalogs/zh-CN.js +23 -0
  32. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  33. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  34. package/dist/app/i18n/catalogs/zh-TW.js +23 -0
  35. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  36. package/dist/app/sharing/OwnershipFilter.d.ts +8 -0
  37. package/dist/app/sharing/OwnershipFilter.d.ts.map +1 -0
  38. package/dist/app/sharing/OwnershipFilter.js +20 -0
  39. package/dist/app/sharing/OwnershipFilter.js.map +1 -0
  40. package/dist/app/sharing/ShareButton.d.ts +43 -1
  41. package/dist/app/sharing/ShareButton.d.ts.map +1 -1
  42. package/dist/app/sharing/ShareButton.js +102 -63
  43. package/dist/app/sharing/ShareButton.js.map +1 -1
  44. package/dist/app/sharing/ShareDialog.d.ts +3 -1
  45. package/dist/app/sharing/ShareDialog.d.ts.map +1 -1
  46. package/dist/app/sharing/ShareDialog.js +18 -12
  47. package/dist/app/sharing/ShareDialog.js.map +1 -1
  48. package/dist/app/sharing/ShareVisibilityConfirmDialog.d.ts +20 -0
  49. package/dist/app/sharing/ShareVisibilityConfirmDialog.d.ts.map +1 -0
  50. package/dist/app/sharing/ShareVisibilityConfirmDialog.js +57 -0
  51. package/dist/app/sharing/ShareVisibilityConfirmDialog.js.map +1 -0
  52. package/dist/app/sharing/VisibilityBadge.d.ts +8 -1
  53. package/dist/app/sharing/VisibilityBadge.d.ts.map +1 -1
  54. package/dist/app/sharing/VisibilityBadge.js +16 -1
  55. package/dist/app/sharing/VisibilityBadge.js.map +1 -1
  56. package/dist/app/sharing/index.d.ts +2 -0
  57. package/dist/app/sharing/index.d.ts.map +1 -1
  58. package/dist/app/sharing/index.js +2 -0
  59. package/dist/app/sharing/index.js.map +1 -1
  60. package/dist/chat-history/ChatHistoryList.d.ts +19 -0
  61. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -1
  62. package/dist/chat-history/ChatHistoryList.js +19 -7
  63. package/dist/chat-history/ChatHistoryList.js.map +1 -1
  64. package/dist/chat-history/ChatHistoryRail.d.ts +55 -8
  65. package/dist/chat-history/ChatHistoryRail.d.ts.map +1 -1
  66. package/dist/chat-history/ChatHistoryRail.js +33 -7
  67. package/dist/chat-history/ChatHistoryRail.js.map +1 -1
  68. package/dist/chat-history/index.d.ts +3 -3
  69. package/dist/chat-history/index.d.ts.map +1 -1
  70. package/dist/chat-history/index.js +1 -1
  71. package/dist/chat-history/index.js.map +1 -1
  72. package/dist/chat-history/useChatHistoryRailController.d.ts +44 -0
  73. package/dist/chat-history/useChatHistoryRailController.d.ts.map +1 -1
  74. package/dist/chat-history/useChatHistoryRailController.js +46 -0
  75. package/dist/chat-history/useChatHistoryRailController.js.map +1 -1
  76. package/dist/chat-history.css +29 -0
  77. package/dist/composer/TiptapComposer.d.ts +7 -0
  78. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  79. package/dist/composer/TiptapComposer.js +15 -2
  80. package/dist/composer/TiptapComposer.js.map +1 -1
  81. package/dist/sharing/OwnershipFilter.d.ts +21 -0
  82. package/dist/sharing/OwnershipFilter.d.ts.map +1 -0
  83. package/dist/sharing/OwnershipFilter.js +30 -0
  84. package/dist/sharing/OwnershipFilter.js.map +1 -0
  85. package/dist/sharing/VisibilityBadge.d.ts +12 -2
  86. package/dist/sharing/VisibilityBadge.d.ts.map +1 -1
  87. package/dist/sharing/VisibilityBadge.js +13 -4
  88. package/dist/sharing/VisibilityBadge.js.map +1 -1
  89. package/dist/sharing/index.d.ts +2 -1
  90. package/dist/sharing/index.d.ts.map +1 -1
  91. package/dist/sharing/index.js +2 -1
  92. package/dist/sharing/index.js.map +1 -1
  93. package/dist/ui/alert-dialog.d.ts +2 -0
  94. package/dist/ui/alert-dialog.d.ts.map +1 -1
  95. package/dist/ui/alert-dialog.js +1 -1
  96. package/dist/ui/alert-dialog.js.map +1 -1
  97. package/dist/ui/tooltip.d.ts +6 -1
  98. package/dist/ui/tooltip.d.ts.map +1 -1
  99. package/dist/ui/tooltip.js +13 -1
  100. package/dist/ui/tooltip.js.map +1 -1
  101. package/package.json +4 -4
  102. package/src/app/i18n/catalogs/ar-SA.ts +26 -0
  103. package/src/app/i18n/catalogs/de-DE.ts +25 -0
  104. package/src/app/i18n/catalogs/en-US.ts +25 -0
  105. package/src/app/i18n/catalogs/es-ES.ts +25 -0
  106. package/src/app/i18n/catalogs/fr-FR.ts +25 -0
  107. package/src/app/i18n/catalogs/hi-IN.ts +25 -0
  108. package/src/app/i18n/catalogs/ja-JP.ts +25 -0
  109. package/src/app/i18n/catalogs/ko-KR.ts +25 -0
  110. package/src/app/i18n/catalogs/pt-BR.ts +25 -0
  111. package/src/app/i18n/catalogs/zh-CN.ts +25 -0
  112. package/src/app/i18n/catalogs/zh-TW.ts +25 -0
  113. package/src/app/sharing/OwnershipFilter.tsx +35 -0
  114. package/src/app/sharing/ShareButton.tsx +384 -259
  115. package/src/app/sharing/ShareDialog.spec.tsx +50 -0
  116. package/src/app/sharing/ShareDialog.tsx +51 -15
  117. package/src/app/sharing/ShareVisibilityConfirmDialog.spec.tsx +138 -0
  118. package/src/app/sharing/ShareVisibilityConfirmDialog.tsx +124 -0
  119. package/src/app/sharing/ShareVisibilityKeyboard.spec.tsx +244 -0
  120. package/src/app/sharing/VisibilityBadge.spec.tsx +65 -0
  121. package/src/app/sharing/VisibilityBadge.tsx +31 -3
  122. package/src/app/sharing/index.ts +2 -0
  123. package/src/app/sharing/share-visibility-mutation-lifecycle.spec.tsx +1107 -0
  124. package/src/app/sharing/useShareButtonController.spec.tsx +226 -37
  125. package/src/app/sharing/useShareDialogController.spec.tsx +357 -9
  126. package/src/chat-history/ChatHistoryList.spec.tsx +157 -0
  127. package/src/chat-history/ChatHistoryList.tsx +104 -10
  128. package/src/chat-history/ChatHistoryRail.spec.tsx +50 -1
  129. package/src/chat-history/ChatHistoryRail.tsx +177 -26
  130. package/src/chat-history/ChatHistoryRailSections.spec.tsx +410 -0
  131. package/src/chat-history/index.ts +8 -0
  132. package/src/chat-history/useChatHistoryRailController.ts +94 -0
  133. package/src/chat-history/useChatHistoryRailSectionsController.spec.tsx +133 -0
  134. package/src/chat-history.css +29 -0
  135. package/src/composer/TiptapComposer.spec.ts +19 -0
  136. package/src/composer/TiptapComposer.tsx +26 -5
  137. package/src/sharing/OwnershipFilter.spec.tsx +108 -0
  138. package/src/sharing/OwnershipFilter.tsx +103 -0
  139. package/src/sharing/VisibilityBadge.spec.ts +53 -35
  140. package/src/sharing/VisibilityBadge.tsx +23 -3
  141. package/src/sharing/index.ts +11 -0
  142. package/src/ui/alert-dialog.tsx +7 -2
  143. package/src/ui/tooltip.tsx +22 -1
@@ -6,6 +6,7 @@ import {
6
6
  type ShareDialogController,
7
7
  type ShareDialogControllerOptions,
8
8
  } from "@agent-native/core/client/sharing/useShareDialogController";
9
+ import { ShareVisibilityNotSavedError } from "@agent-native/core/client/sharing/useShareVisibilityTransition";
9
10
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
10
11
  import React, { act } from "react";
11
12
  import { createRoot, type Root } from "react-dom/client";
@@ -20,10 +21,22 @@ const mocks = vi.hoisted(() => ({
20
21
  },
21
22
  share: { mutate: vi.fn(), isPending: false },
22
23
  unshare: { mutate: vi.fn(), isPending: false },
23
- visibility: { mutate: vi.fn(), isPending: false },
24
+ visibility: { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false },
24
25
  writeClipboardText: vi.fn(),
25
26
  }));
26
27
 
28
+ // The controllers settle each save through mutateAsync; the tests drive it
29
+ // through the callbacks recorded on `mutate`.
30
+ mocks.visibility.mutateAsync.mockImplementation(
31
+ (variables: unknown) =>
32
+ new Promise((resolve, reject) => {
33
+ mocks.visibility.mutate(variables, {
34
+ onSuccess: resolve,
35
+ onError: reject,
36
+ });
37
+ }),
38
+ );
39
+
27
40
  vi.mock("@agent-native/core/client/use-action", () => ({
28
41
  useActionQuery: vi.fn(() => mocks.query),
29
42
  useActionMutation: vi.fn((name: string) => {
@@ -111,7 +124,12 @@ describe("useShareDialogController", () => {
111
124
  });
112
125
  });
113
126
 
114
- afterEach(() => {
127
+ afterEach(async () => {
128
+ await act(async () => {
129
+ for (const [variables, callbacks] of mocks.visibility.mutate.mock.calls) {
130
+ callbacks?.onSuccess?.({ visibility: variables.visibility });
131
+ }
132
+ });
115
133
  act(() => root.unmount());
116
134
  container.remove();
117
135
  queryClient.clear();
@@ -238,6 +256,12 @@ describe("useShareDialogController", () => {
238
256
  const memberShare = mocks.query.data?.shares[0];
239
257
 
240
258
  act(() => result.visibility.set("org"));
259
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
260
+ act(() => {
261
+ void (
262
+ controller as ShareDialogController
263
+ ).visibility.transition.confirm();
264
+ });
241
265
  expect(mocks.visibility.mutate).toHaveBeenCalledWith(
242
266
  { resourceType: "document", resourceId: "doc-1", visibility: "org" },
243
267
  expect.objectContaining({ onSuccess: expect.any(Function) }),
@@ -266,18 +290,19 @@ describe("useShareDialogController", () => {
266
290
  expect(mocks.share.mutate).not.toHaveBeenCalled();
267
291
  });
268
292
 
269
- it("does not refetch after a visibility mutation is superseded", async () => {
293
+ it("refetches the actual visibility save after refusing a different target", async () => {
270
294
  let result = await render();
271
295
 
272
- act(() => result.visibility.set("org"));
273
- result = controller as ShareDialogController;
274
296
  act(() => result.visibility.set("public"));
297
+ result = controller as ShareDialogController;
298
+ act(() => result.visibility.set("org"));
275
299
 
276
- act(() => mocks.visibility.mutate.mock.calls[0]?.[1]?.onSuccess());
300
+ expect(mocks.visibility.mutate).toHaveBeenCalledOnce();
301
+ expect(result.visibility.value).toBe("public");
277
302
  expect(mocks.query.refetch).not.toHaveBeenCalled();
278
303
 
279
304
  await act(async () => {
280
- mocks.visibility.mutate.mock.calls[1]?.[1]?.onSuccess();
305
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onSuccess();
281
306
  await Promise.resolve();
282
307
  });
283
308
  expect(mocks.query.refetch).toHaveBeenCalledOnce();
@@ -309,13 +334,23 @@ describe("useShareDialogController", () => {
309
334
  { enabled: false },
310
335
  );
311
336
 
312
- await render(options);
313
- expect(fetch).toHaveBeenCalledOnce();
337
+ const opened = await render(options);
338
+ const fetched = vi.mocked(fetch).mock.calls.map(([url]) => String(url));
339
+ expect(fetched.filter((url) => url.includes("/org/members"))).toHaveLength(
340
+ 1,
341
+ );
342
+ expect(fetched.filter((url) => url.endsWith("/org/me"))).toHaveLength(0);
314
343
  expect(useActionQuery).toHaveBeenLastCalledWith(
315
344
  "list-resource-shares",
316
345
  expect.anything(),
317
346
  { enabled: true },
318
347
  );
348
+ await act(async () => opened.visibility.set("org"));
349
+ expect(
350
+ vi
351
+ .mocked(fetch)
352
+ .mock.calls.filter(([url]) => String(url).endsWith("/org/me")),
353
+ ).toHaveLength(1);
319
354
  });
320
355
 
321
356
  it("resets tab state when opened and delegates close transitions", async () => {
@@ -382,4 +417,317 @@ describe("useShareDialogController", () => {
382
417
  ]),
383
418
  ).toEqual(initial);
384
419
  });
420
+
421
+ describe("organization confirmation", () => {
422
+ const sharesKey = [
423
+ "action",
424
+ "list-resource-shares",
425
+ { resourceType: "document", resourceId: "doc-1" },
426
+ ] as const;
427
+
428
+ const transition = () =>
429
+ (controller as ShareDialogController).visibility.transition;
430
+
431
+ it("holds a private-to-organization change until it is confirmed", async () => {
432
+ const initial = { ...mocks.query.data! };
433
+ queryClient.setQueryData(sharesKey, initial);
434
+ const result = await render();
435
+
436
+ act(() => result.visibility.set("org"));
437
+
438
+ expect(transition().pending).toEqual({ from: "private", to: "org" });
439
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
440
+ expect(queryClient.getQueryData(sharesKey)).toEqual(initial);
441
+ expect((controller as ShareDialogController).visibility.value).toBe(
442
+ "private",
443
+ );
444
+ });
445
+
446
+ it("leaves the saved access and cache alone on cancel", async () => {
447
+ const initial = { ...mocks.query.data! };
448
+ queryClient.setQueryData(sharesKey, initial);
449
+ const result = await render();
450
+ act(() => result.visibility.set("org"));
451
+
452
+ act(() => transition().cancel());
453
+
454
+ expect(transition().pending).toBeNull();
455
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
456
+ expect(queryClient.getQueryData(sharesKey)).toEqual(initial);
457
+ });
458
+
459
+ it("saves once on confirm and closes after success", async () => {
460
+ const result = await render();
461
+ act(() => result.visibility.set("org"));
462
+
463
+ await act(async () => {
464
+ void transition().confirm();
465
+ void transition().confirm();
466
+ });
467
+ expect(mocks.visibility.mutate).toHaveBeenCalledOnce();
468
+ expect(transition().submitting).toBe(true);
469
+
470
+ await act(async () => {
471
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onSuccess({
472
+ visibility: "org",
473
+ });
474
+ await Promise.resolve();
475
+ });
476
+ expect(transition().pending).toBeNull();
477
+ expect(transition().submitting).toBe(false);
478
+ });
479
+
480
+ it("keeps the confirmation open with the error and retries after a failure", async () => {
481
+ const initial = { ...mocks.query.data! };
482
+ queryClient.setQueryData(sharesKey, initial);
483
+ const result = await render();
484
+ act(() => result.visibility.set("org"));
485
+ await act(async () => {
486
+ void transition().confirm();
487
+ });
488
+
489
+ await act(async () => {
490
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onError(
491
+ new Error("visibility failed"),
492
+ );
493
+ await Promise.resolve();
494
+ await Promise.resolve();
495
+ });
496
+
497
+ expect(transition().pending).toEqual({ from: "private", to: "org" });
498
+ expect(transition().error).toBe("visibility failed");
499
+ expect(transition().submitting).toBe(false);
500
+ expect(queryClient.getQueryData(sharesKey)).toEqual(initial);
501
+ expect((controller as ShareDialogController).error).toBeNull();
502
+
503
+ await act(async () => {
504
+ void transition().confirm();
505
+ });
506
+ expect(mocks.visibility.mutate).toHaveBeenCalledTimes(2);
507
+ expect(transition().error).toBeNull();
508
+ });
509
+
510
+ it("reports the actual write failure after refusing a different target", async () => {
511
+ mocks.query.data = { ...mocks.query.data!, visibility: "public" };
512
+ const result = await render();
513
+ let older!: Promise<void>;
514
+ act(() => {
515
+ older = result.visibility.transition.request("org");
516
+ });
517
+ act(() =>
518
+ (controller as ShareDialogController).visibility.set("private"),
519
+ );
520
+ expect(mocks.visibility.mutate).toHaveBeenCalledOnce();
521
+ expect((controller as ShareDialogController).visibility.value).toBe(
522
+ "org",
523
+ );
524
+
525
+ await act(async () => {
526
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onError(
527
+ new Error("stale write failed"),
528
+ );
529
+ await expect(older).rejects.toThrow("stale write failed");
530
+ });
531
+
532
+ expect((controller as ShareDialogController).error).toBe(
533
+ "stale write failed",
534
+ );
535
+ });
536
+
537
+ it("drops the request when the resource changes or access is lost", async () => {
538
+ const result = await render();
539
+ act(() => result.visibility.set("org"));
540
+ expect(transition().pending).not.toBeNull();
541
+
542
+ await render({ ...options, resourceId: "doc-2" });
543
+ expect(transition().pending).toBeNull();
544
+
545
+ act(() => (controller as ShareDialogController).visibility.set("org"));
546
+ expect(transition().pending).not.toBeNull();
547
+ mocks.query.data = { ...mocks.query.data!, role: "viewer" };
548
+ await render({ ...options, resourceId: "doc-2" });
549
+ expect(transition().pending).toBeNull();
550
+ await act(async () => {
551
+ await transition().confirm();
552
+ });
553
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
554
+ });
555
+
556
+ it("does not confirm after the saved value moved or the dialog closed", async () => {
557
+ const result = await render();
558
+ act(() => result.visibility.set("org"));
559
+ const staleConfirm = transition().confirm;
560
+
561
+ mocks.query.data = { ...mocks.query.data!, visibility: "org" };
562
+ await render();
563
+ await act(async () => {
564
+ await staleConfirm();
565
+ });
566
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
567
+
568
+ mocks.query.data = { ...mocks.query.data!, visibility: "private" };
569
+ await render();
570
+ act(() => (controller as ShareDialogController).visibility.set("org"));
571
+ const closedConfirm = transition().confirm;
572
+ await render({ ...options, open: false });
573
+ await act(async () => {
574
+ await closedConfirm();
575
+ });
576
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
577
+ });
578
+
579
+ it("does not carry a pending change from one resource into the next", async () => {
580
+ await render();
581
+ act(() => (controller as ShareDialogController).visibility.set("public"));
582
+ expect(mocks.visibility.mutate).toHaveBeenCalledTimes(1);
583
+ expect((controller as ShareDialogController).visibility.value).toBe(
584
+ "public",
585
+ );
586
+
587
+ await render({ ...options, resourceId: "doc-2" });
588
+ expect((controller as ShareDialogController).visibility.value).toBe(
589
+ "private",
590
+ );
591
+
592
+ act(() => (controller as ShareDialogController).visibility.set("org"));
593
+ expect(transition().pending).toEqual({ from: "private", to: "org" });
594
+ expect(mocks.visibility.mutate).toHaveBeenCalledTimes(1);
595
+
596
+ await act(async () => {
597
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onSuccess({
598
+ visibility: "public",
599
+ });
600
+ await Promise.resolve();
601
+ });
602
+ expect(transition().pending).toEqual({ from: "private", to: "org" });
603
+ expect((controller as ShareDialogController).visibility.value).toBe(
604
+ "private",
605
+ );
606
+
607
+ await act(async () => {
608
+ void transition().confirm();
609
+ });
610
+ expect(mocks.visibility.mutate).toHaveBeenCalledTimes(2);
611
+ expect(mocks.visibility.mutate.mock.calls[1]?.[0]).toMatchObject({
612
+ resourceId: "doc-2",
613
+ visibility: "org",
614
+ });
615
+ });
616
+
617
+ it("settles a request only when it is persisted, rejects it otherwise", async () => {
618
+ await render();
619
+ let saved!: Promise<void>;
620
+ act(() => {
621
+ saved = transition().request("public");
622
+ });
623
+ await act(async () => {
624
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onError(
625
+ new Error("visibility failed"),
626
+ );
627
+ await expect(saved).rejects.toThrow("visibility failed");
628
+ });
629
+
630
+ mocks.visibility.mutate.mockClear();
631
+ let held!: Promise<void>;
632
+ act(() => {
633
+ held = transition().request("org");
634
+ });
635
+ act(() => transition().cancel());
636
+ await expect(held).rejects.toBeInstanceOf(ShareVisibilityNotSavedError);
637
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
638
+ });
639
+
640
+ it("keeps a held request pending through a failed confirmation until a retry saves", async () => {
641
+ await render();
642
+ let settled: "pending" | "saved" | "rejected" = "pending";
643
+ act(() => {
644
+ void transition()
645
+ .request("org")
646
+ .then(
647
+ () => {
648
+ settled = "saved";
649
+ },
650
+ () => {
651
+ settled = "rejected";
652
+ },
653
+ );
654
+ });
655
+ await act(async () => {
656
+ void transition().confirm();
657
+ });
658
+ await act(async () => {
659
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onError(new Error("nope"));
660
+ await Promise.resolve();
661
+ await Promise.resolve();
662
+ });
663
+ expect(transition().error).toBe("nope");
664
+ expect(settled).toBe("pending");
665
+
666
+ await act(async () => {
667
+ void transition().confirm();
668
+ });
669
+ await act(async () => {
670
+ mocks.visibility.mutate.mock.calls[1]?.[1]?.onSuccess({
671
+ visibility: "org",
672
+ });
673
+ await Promise.resolve();
674
+ await Promise.resolve();
675
+ });
676
+ expect(settled).toBe("saved");
677
+ });
678
+
679
+ it("joins a repeated request for a value that is still being saved", async () => {
680
+ await render();
681
+ let first!: Promise<void>;
682
+ act(() => {
683
+ first = transition().request("public");
684
+ });
685
+ let second!: Promise<void>;
686
+ act(() => {
687
+ second = (
688
+ controller as ShareDialogController
689
+ ).visibility.transition.request("public");
690
+ });
691
+ expect(mocks.visibility.mutate).toHaveBeenCalledTimes(1);
692
+
693
+ await act(async () => {
694
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onError(
695
+ new Error("visibility failed"),
696
+ );
697
+ await expect(first).rejects.toThrow("visibility failed");
698
+ await expect(second).rejects.toThrow("visibility failed");
699
+ });
700
+ });
701
+
702
+ it("ignores a visibility request from a closed dialog", async () => {
703
+ await render({ ...options, open: false });
704
+
705
+ act(() => (controller as ShareDialogController).visibility.set("public"));
706
+ act(() => (controller as ShareDialogController).visibility.set("org"));
707
+
708
+ expect(mocks.visibility.mutate).not.toHaveBeenCalled();
709
+ expect(transition().pending).toBeNull();
710
+ });
711
+
712
+ it("does not ask for narrowing or for private-to-public changes", async () => {
713
+ mocks.query.data = { ...mocks.query.data!, visibility: "public" };
714
+ const result = await render();
715
+ act(() => result.visibility.set("org"));
716
+ expect(transition().pending).toBeNull();
717
+ expect(mocks.visibility.mutate).toHaveBeenCalledTimes(1);
718
+
719
+ await act(async () => {
720
+ mocks.visibility.mutate.mock.calls[0]?.[1]?.onSuccess({
721
+ visibility: "org",
722
+ });
723
+ await Promise.resolve();
724
+ });
725
+ mocks.visibility.mutate.mockClear();
726
+ mocks.query.data = { ...mocks.query.data!, visibility: "private" };
727
+ const priv = await render();
728
+ act(() => priv.visibility.set("public"));
729
+ expect(transition().pending).toBeNull();
730
+ expect(mocks.visibility.mutate).toHaveBeenCalledTimes(1);
731
+ });
732
+ });
385
733
  });
@@ -73,6 +73,100 @@ describe("ChatHistoryList", () => {
73
73
  vi.unstubAllGlobals();
74
74
  });
75
75
 
76
+ it("shows the search empty state instead of section empty labels when every section has no matches", () => {
77
+ act(() =>
78
+ root.render(
79
+ <ChatHistoryList
80
+ sections={[
81
+ {
82
+ id: "mine",
83
+ label: "My chats",
84
+ items: [],
85
+ emptyLabel: "No chats yet",
86
+ },
87
+ {
88
+ id: "shared",
89
+ label: "Shared with you",
90
+ items: [],
91
+ emptyLabel: "No shared chats",
92
+ },
93
+ ]}
94
+ searchValue="missing"
95
+ onSelect={() => {}}
96
+ />,
97
+ ),
98
+ );
99
+ expect(container.textContent).toBe("No matching chats");
100
+ expect(
101
+ container.querySelectorAll(".an-chat-history__section-label"),
102
+ ).toHaveLength(0);
103
+ });
104
+
105
+ it("keeps matching sections and their headings without empty labels in the other section", () => {
106
+ act(() =>
107
+ root.render(
108
+ <ChatHistoryList
109
+ sections={[
110
+ { id: "mine", label: "My chats", items: [item({ id: "match" })] },
111
+ {
112
+ id: "shared",
113
+ label: "Shared with you",
114
+ items: [],
115
+ emptyLabel: "No shared chats",
116
+ },
117
+ ]}
118
+ searchValue="match"
119
+ onSelect={() => {}}
120
+ />,
121
+ ),
122
+ );
123
+ expect(container.textContent).toBe("My chatsmatch");
124
+ expect(
125
+ container.querySelectorAll(".an-chat-history__section-label"),
126
+ ).toHaveLength(1);
127
+ });
128
+
129
+ it.each(["loading", "error", "footer"] as const)(
130
+ "preserves a search section's %s state and heading",
131
+ (state) => {
132
+ act(() =>
133
+ root.render(
134
+ <ChatHistoryList
135
+ sections={[
136
+ {
137
+ id: "shared",
138
+ label: "Shared",
139
+ items: [],
140
+ emptyLabel: "No shared chats",
141
+ loading: state === "loading",
142
+ error: state === "error" ? "Search failed" : undefined,
143
+ footer:
144
+ state === "footer" ? <button>Load more</button> : undefined,
145
+ },
146
+ ]}
147
+ searchValue="missing"
148
+ onSelect={() => {}}
149
+ />,
150
+ ),
151
+ );
152
+ expect(
153
+ container.querySelector(".an-chat-history__section-label")?.textContent,
154
+ ).toBe("Shared");
155
+ expect(container.textContent).not.toContain("No shared chats");
156
+ expect(container.textContent).not.toContain("No matching chats");
157
+ if (state === "loading")
158
+ expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
159
+ if (state === "error")
160
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
161
+ "Search failed",
162
+ );
163
+ if (state === "footer")
164
+ expect(container.querySelector("button")?.textContent).toBe(
165
+ "Load more",
166
+ );
167
+ },
168
+ );
169
+
76
170
  it("renders a flat item list and calls onSelect when a row is clicked", () => {
77
171
  const onSelect = vi.fn();
78
172
  const items: ChatHistoryItem[] = [
@@ -478,4 +572,67 @@ describe("ChatHistoryList", () => {
478
572
  });
479
573
  expect(container.textContent).toContain("Load older chats");
480
574
  });
575
+
576
+ it("hides actions per row while the row stays selectable and other rows keep theirs", async () => {
577
+ const onSelect = vi.fn();
578
+ act(() => {
579
+ root.render(
580
+ <ChatHistoryList
581
+ items={[
582
+ item({ id: "mine" }),
583
+ item({ id: "shared", hideActions: true, pinned: true }),
584
+ ]}
585
+ onSelect={onSelect}
586
+ onRename={() => {}}
587
+ onDelete={() => {}}
588
+ onTogglePin={() => {}}
589
+ renderAdditionalRowActions={() => <span>extra</span>}
590
+ />,
591
+ );
592
+ });
593
+
594
+ const rows = container.querySelectorAll<HTMLElement>(
595
+ ".an-chat-history-row",
596
+ );
597
+ expect(
598
+ rows[0]?.querySelector(".an-chat-history-row__menu-trigger"),
599
+ ).not.toBeNull();
600
+ expect(rows[1]?.querySelector(".an-chat-history-row__menu")).toBeNull();
601
+ expect(rows[1]?.className).not.toContain("an-chat-history-row--has-menu");
602
+
603
+ await act(async () => {
604
+ rows[1]?.dispatchEvent(
605
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true }),
606
+ );
607
+ await Promise.resolve();
608
+ });
609
+ expect(
610
+ document.body.querySelector(".an-chat-history-row__menu-content"),
611
+ ).toBeNull();
612
+
613
+ act(() => rows[1]?.querySelector("button")?.click());
614
+ expect(onSelect).toHaveBeenCalledWith("shared");
615
+
616
+ await openMenu(
617
+ rows[0]!.querySelector<HTMLButtonElement>(
618
+ ".an-chat-history-row__menu-trigger",
619
+ )!,
620
+ );
621
+ expect(getMenuItems().map((element) => element.textContent)).toContain(
622
+ "Delete",
623
+ );
624
+ });
625
+
626
+ it("hides the menu even when renderRowActions would return nothing", () => {
627
+ act(() => {
628
+ root.render(
629
+ <ChatHistoryList
630
+ items={[item({ id: "shared", hideActions: true })]}
631
+ onSelect={() => {}}
632
+ renderRowActions={() => null}
633
+ />,
634
+ );
635
+ });
636
+ expect(container.querySelector(".an-chat-history-row__menu")).toBeNull();
637
+ });
481
638
  });