@agent-native/toolkit 0.221.0-same-host-mcp-20261027-20261027081850 → 0.221.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 (259) hide show
  1. package/agent-native.eject.json +16 -1
  2. package/dist/app/agentkit/react/components.d.ts +5 -3
  3. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  4. package/dist/app/agentkit/react/components.js +103 -42
  5. package/dist/app/agentkit/react/components.js.map +1 -1
  6. package/dist/app/agentkit/react/styles.css +93 -65
  7. package/dist/app/agentkit/react/voice-transcripts.d.ts +22 -1
  8. package/dist/app/agentkit/react/voice-transcripts.d.ts.map +1 -1
  9. package/dist/app/agentkit/react/voice-transcripts.js +209 -8
  10. package/dist/app/agentkit/react/voice-transcripts.js.map +1 -1
  11. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  12. package/dist/app/chat/AgentKitAssistantChat.js +55 -40
  13. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  14. package/dist/app/chat/agentkit-chat/guided-questions.d.ts +1 -0
  15. package/dist/app/chat/agentkit-chat/guided-questions.d.ts.map +1 -1
  16. package/dist/app/chat/agentkit-chat/guided-questions.js +18 -6
  17. package/dist/app/chat/agentkit-chat/guided-questions.js.map +1 -1
  18. package/dist/app/chat/agentkit-chat/index.d.ts +1 -1
  19. package/dist/app/chat/agentkit-chat/index.d.ts.map +1 -1
  20. package/dist/app/chat/agentkit-chat/index.js +1 -1
  21. package/dist/app/chat/agentkit-chat/index.js.map +1 -1
  22. package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
  23. package/dist/app/chat/chat/run-recovery.js +5 -1
  24. package/dist/app/chat/chat/run-recovery.js.map +1 -1
  25. package/dist/app/chat/composer/runtime-adapters.d.ts.map +1 -1
  26. package/dist/app/chat/composer/runtime-adapters.js +2 -0
  27. package/dist/app/chat/composer/runtime-adapters.js.map +1 -1
  28. package/dist/app/chat/reply-connection-suggestion.d.ts +29 -0
  29. package/dist/app/chat/reply-connection-suggestion.d.ts.map +1 -0
  30. package/dist/app/chat/reply-connection-suggestion.js +42 -0
  31. package/dist/app/chat/reply-connection-suggestion.js.map +1 -0
  32. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  33. package/dist/app/i18n/catalogs/ar-SA.js +7 -0
  34. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  35. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/de-DE.js +7 -0
  37. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  38. package/dist/app/i18n/catalogs/en-US.d.ts +7 -0
  39. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  40. package/dist/app/i18n/catalogs/en-US.js +7 -0
  41. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  42. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  43. package/dist/app/i18n/catalogs/es-ES.js +7 -0
  44. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  45. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  46. package/dist/app/i18n/catalogs/fr-FR.js +7 -0
  47. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  48. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  49. package/dist/app/i18n/catalogs/hi-IN.js +7 -0
  50. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  51. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  52. package/dist/app/i18n/catalogs/ja-JP.js +7 -0
  53. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  54. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  55. package/dist/app/i18n/catalogs/ko-KR.js +7 -0
  56. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  57. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  58. package/dist/app/i18n/catalogs/pt-BR.js +7 -0
  59. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  60. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  61. package/dist/app/i18n/catalogs/zh-CN.js +7 -0
  62. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  63. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  64. package/dist/app/i18n/catalogs/zh-TW.js +7 -0
  65. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  66. package/dist/app/mcp-approval/McpApprovalReview.d.ts +25 -0
  67. package/dist/app/mcp-approval/McpApprovalReview.d.ts.map +1 -0
  68. package/dist/app/mcp-approval/McpApprovalReview.js +342 -0
  69. package/dist/app/mcp-approval/McpApprovalReview.js.map +1 -0
  70. package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
  71. package/dist/app/onboarding/FirstRunOnboarding.js +299 -71
  72. package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
  73. package/dist/app/onboarding/GettingStartedCard.js +1 -1
  74. package/dist/app/onboarding/GettingStartedCard.js.map +1 -1
  75. package/dist/app/onboarding/first-run-builder-credits-flag.d.ts +2 -0
  76. package/dist/app/onboarding/first-run-builder-credits-flag.d.ts.map +1 -0
  77. package/dist/app/onboarding/first-run-builder-credits-flag.js +8 -0
  78. package/dist/app/onboarding/first-run-builder-credits-flag.js.map +1 -0
  79. package/dist/app/providers/AppProviders.d.ts.map +1 -1
  80. package/dist/app/providers/AppProviders.js +14 -1
  81. package/dist/app/providers/AppProviders.js.map +1 -1
  82. package/dist/app/resources/McpConnectionSuggestion.d.ts +2 -4
  83. package/dist/app/resources/McpConnectionSuggestion.d.ts.map +1 -1
  84. package/dist/app/resources/McpConnectionSuggestion.js +59 -36
  85. package/dist/app/resources/McpConnectionSuggestion.js.map +1 -1
  86. package/dist/app/settings/account/PreferencesSettings.d.ts.map +1 -1
  87. package/dist/app/settings/account/PreferencesSettings.js +14 -1
  88. package/dist/app/settings/account/PreferencesSettings.js.map +1 -1
  89. package/dist/app/settings/account/ThemeFamilySettings.js +2 -2
  90. package/dist/app/settings/account/ThemeFamilySettings.js.map +1 -1
  91. package/dist/app/settings/account/search-entries.d.ts +1 -0
  92. package/dist/app/settings/account/search-entries.d.ts.map +1 -1
  93. package/dist/app/settings/account/search-entries.js +5 -0
  94. package/dist/app/settings/account/search-entries.js.map +1 -1
  95. package/dist/app/settings/shell/SettingsShell.d.ts.map +1 -1
  96. package/dist/app/settings/shell/SettingsShell.js +26 -2
  97. package/dist/app/settings/shell/SettingsShell.js.map +1 -1
  98. package/dist/app/shared/AppShellSkeleton.d.ts.map +1 -1
  99. package/dist/app/shared/AppShellSkeleton.js +4 -10
  100. package/dist/app/shared/AppShellSkeleton.js.map +1 -1
  101. package/dist/app/shared/EnvironmentBadge.d.ts +2 -1
  102. package/dist/app/shared/EnvironmentBadge.d.ts.map +1 -1
  103. package/dist/app/shared/EnvironmentBadge.js +2 -3
  104. package/dist/app/shared/EnvironmentBadge.js.map +1 -1
  105. package/dist/app/shared/is-builder-io-employee.d.ts +8 -0
  106. package/dist/app/shared/is-builder-io-employee.d.ts.map +1 -0
  107. package/dist/app/shared/is-builder-io-employee.js +10 -0
  108. package/dist/app/shared/is-builder-io-employee.js.map +1 -0
  109. package/dist/app/styles/agent-conversation.css +30 -17
  110. package/dist/app/styles/theme-families.css +22 -5
  111. package/dist/app/styles/tokens/agent-kit.css +3 -3
  112. package/dist/composer/PromptComposer.d.ts +15 -3
  113. package/dist/composer/PromptComposer.d.ts.map +1 -1
  114. package/dist/composer/PromptComposer.js +74 -16
  115. package/dist/composer/PromptComposer.js.map +1 -1
  116. package/dist/composer/RealtimeVoiceMode.d.ts +7 -0
  117. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
  118. package/dist/composer/RealtimeVoiceMode.js +9 -2
  119. package/dist/composer/RealtimeVoiceMode.js.map +1 -1
  120. package/dist/composer/TiptapComposer.d.ts +7 -0
  121. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  122. package/dist/composer/TiptapComposer.js +12 -4
  123. package/dist/composer/TiptapComposer.js.map +1 -1
  124. package/dist/composer/VoiceButton.d.ts +2 -0
  125. package/dist/composer/VoiceButton.d.ts.map +1 -1
  126. package/dist/composer/VoiceButton.js +5 -4
  127. package/dist/composer/VoiceButton.js.map +1 -1
  128. package/dist/composer/index.d.ts +2 -2
  129. package/dist/composer/index.d.ts.map +1 -1
  130. package/dist/composer/index.js +1 -1
  131. package/dist/composer/index.js.map +1 -1
  132. package/dist/composer/prompt-attachments.d.ts +20 -0
  133. package/dist/composer/prompt-attachments.d.ts.map +1 -1
  134. package/dist/composer/prompt-attachments.js +188 -11
  135. package/dist/composer/prompt-attachments.js.map +1 -1
  136. package/dist/composer/realtime-voice-transcript.d.ts +27 -0
  137. package/dist/composer/realtime-voice-transcript.d.ts.map +1 -1
  138. package/dist/composer/realtime-voice-transcript.js +34 -1
  139. package/dist/composer/realtime-voice-transcript.js.map +1 -1
  140. package/dist/composer/realtime-voice-turn-metrics.d.ts +43 -0
  141. package/dist/composer/realtime-voice-turn-metrics.d.ts.map +1 -0
  142. package/dist/composer/realtime-voice-turn-metrics.js +132 -0
  143. package/dist/composer/realtime-voice-turn-metrics.js.map +1 -0
  144. package/dist/composer/runtime-adapters.d.ts +4 -1
  145. package/dist/composer/runtime-adapters.d.ts.map +1 -1
  146. package/dist/composer/runtime-adapters.js +2 -1
  147. package/dist/composer/runtime-adapters.js.map +1 -1
  148. package/dist/composer/useRealtimeVoiceMode.d.ts +61 -9
  149. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  150. package/dist/composer/useRealtimeVoiceMode.js +536 -99
  151. package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
  152. package/dist/composer/voice-chat-icon.d.ts +7 -0
  153. package/dist/composer/voice-chat-icon.d.ts.map +1 -0
  154. package/dist/composer/voice-chat-icon.js +14 -0
  155. package/dist/composer/voice-chat-icon.js.map +1 -0
  156. package/dist/editor/DragHandle.d.ts +12 -1
  157. package/dist/editor/DragHandle.d.ts.map +1 -1
  158. package/dist/editor/DragHandle.js +165 -41
  159. package/dist/editor/DragHandle.js.map +1 -1
  160. package/dist/editor/RegistryBlockNode.d.ts +20 -0
  161. package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
  162. package/dist/editor/RegistryBlockNode.js +58 -13
  163. package/dist/editor/RegistryBlockNode.js.map +1 -1
  164. package/dist/editor/index.d.ts +2 -2
  165. package/dist/editor/index.d.ts.map +1 -1
  166. package/dist/editor/index.js +1 -1
  167. package/dist/editor/index.js.map +1 -1
  168. package/dist/styles.css +2 -0
  169. package/dist/ui/dropdown-menu.d.ts.map +1 -1
  170. package/dist/ui/dropdown-menu.js +5 -1
  171. package/dist/ui/dropdown-menu.js.map +1 -1
  172. package/package.json +14 -4
  173. package/src/app/agentkit/react/components.regenerate-readiness.spec.tsx +3 -1
  174. package/src/app/agentkit/react/components.spec.tsx +24 -4
  175. package/src/app/agentkit/react/components.tsx +174 -44
  176. package/src/app/agentkit/react/composer-context.spec.tsx +3 -0
  177. package/src/app/agentkit/react/disclosures.spec.tsx +124 -0
  178. package/src/app/agentkit/react/lifecycle.spec.tsx +8 -2
  179. package/src/app/agentkit/react/message-actions.spec.tsx +161 -5
  180. package/src/app/agentkit/react/saved-reply-reload.spec.tsx +206 -0
  181. package/src/app/agentkit/react/styles.css +93 -65
  182. package/src/app/agentkit/react/voice-transcripts.spec.tsx +239 -4
  183. package/src/app/agentkit/react/voice-transcripts.ts +245 -8
  184. package/src/app/chat/AgentKitAssistantChat.spec.tsx +59 -11
  185. package/src/app/chat/AgentKitAssistantChat.tsx +74 -62
  186. package/src/app/chat/agentkit-chat/guided-answers-context.spec.ts +9 -0
  187. package/src/app/chat/agentkit-chat/guided-questions.tsx +17 -7
  188. package/src/app/chat/agentkit-chat/index.ts +1 -0
  189. package/src/app/chat/chat/run-recovery.spec.tsx +45 -0
  190. package/src/app/chat/chat/run-recovery.tsx +6 -1
  191. package/src/app/chat/composer/runtime-adapters.tsx +2 -0
  192. package/src/app/chat/reply-connection-suggestion.spec.ts +121 -0
  193. package/src/app/chat/reply-connection-suggestion.ts +76 -0
  194. package/src/app/i18n/catalogs/ar-SA.ts +10 -0
  195. package/src/app/i18n/catalogs/de-DE.ts +10 -0
  196. package/src/app/i18n/catalogs/en-US.ts +10 -0
  197. package/src/app/i18n/catalogs/es-ES.ts +10 -0
  198. package/src/app/i18n/catalogs/fr-FR.ts +10 -0
  199. package/src/app/i18n/catalogs/hi-IN.ts +10 -0
  200. package/src/app/i18n/catalogs/ja-JP.ts +10 -0
  201. package/src/app/i18n/catalogs/ko-KR.ts +10 -0
  202. package/src/app/i18n/catalogs/pt-BR.ts +10 -0
  203. package/src/app/i18n/catalogs/zh-CN.ts +10 -0
  204. package/src/app/i18n/catalogs/zh-TW.ts +10 -0
  205. package/src/app/mcp-approval/McpApprovalReview.spec.tsx +687 -0
  206. package/src/app/mcp-approval/McpApprovalReview.tsx +913 -0
  207. package/src/app/onboarding/FirstRunOnboarding.spec.tsx +736 -26
  208. package/src/app/onboarding/FirstRunOnboarding.tsx +473 -145
  209. package/src/app/onboarding/GettingStartedCard.tsx +1 -1
  210. package/src/app/onboarding/first-run-builder-credits-flag.spec.ts +13 -0
  211. package/src/app/onboarding/first-run-builder-credits-flag.ts +9 -0
  212. package/src/app/providers/AppProviders.spec.tsx +109 -0
  213. package/src/app/providers/AppProviders.tsx +53 -1
  214. package/src/app/resources/McpConnectionSuggestion.spec.ts +77 -12
  215. package/src/app/resources/McpConnectionSuggestion.tsx +69 -51
  216. package/src/app/settings/VoiceChatSettings.spec.tsx +6 -1
  217. package/src/app/settings/account/PreferencesSettings.spec.tsx +73 -0
  218. package/src/app/settings/account/PreferencesSettings.tsx +24 -7
  219. package/src/app/settings/account/ThemeFamilySettings.tsx +2 -2
  220. package/src/app/settings/account/search-entries.spec.ts +14 -0
  221. package/src/app/settings/account/search-entries.ts +9 -0
  222. package/src/app/settings/shell/SettingsShell.tsx +29 -2
  223. package/src/app/shared/AppShellSkeleton.spec.tsx +2 -2
  224. package/src/app/shared/AppShellSkeleton.tsx +3 -16
  225. package/src/app/shared/EnvironmentBadge.tsx +3 -4
  226. package/src/app/shared/is-builder-io-employee.ts +17 -0
  227. package/src/app/styles/agent-conversation.css +30 -17
  228. package/src/app/styles/theme-families.css +22 -5
  229. package/src/app/styles/theme-families.spec.ts +73 -0
  230. package/src/app/styles/tokens/agent-kit.css +3 -3
  231. package/src/composer/AgentSuggestionBar.overflow.spec.tsx +1 -1
  232. package/src/composer/AttachmentChip.spec.tsx +87 -0
  233. package/src/composer/PromptComposer.context.spec.tsx +8 -4
  234. package/src/composer/PromptComposer.setup-card.spec.tsx +170 -0
  235. package/src/composer/PromptComposer.tsx +125 -14
  236. package/src/composer/RealtimeVoiceMode.spec.tsx +48 -0
  237. package/src/composer/RealtimeVoiceMode.tsx +45 -2
  238. package/src/composer/TiptapComposer.tsx +25 -0
  239. package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +4 -1
  240. package/src/composer/VoiceButton.tsx +8 -3
  241. package/src/composer/index.ts +4 -0
  242. package/src/composer/prompt-attachments.spec.ts +160 -8
  243. package/src/composer/prompt-attachments.ts +221 -12
  244. package/src/composer/realtime-voice-transcript.spec.ts +36 -0
  245. package/src/composer/realtime-voice-transcript.ts +56 -1
  246. package/src/composer/realtime-voice-turn-metrics.spec.ts +175 -0
  247. package/src/composer/realtime-voice-turn-metrics.ts +192 -0
  248. package/src/composer/runtime-adapters.tsx +5 -1
  249. package/src/composer/useRealtimeVoiceMode.session.spec.tsx +487 -2
  250. package/src/composer/useRealtimeVoiceMode.spec.ts +267 -65
  251. package/src/composer/useRealtimeVoiceMode.tsx +716 -139
  252. package/src/composer/voice-chat-icon.tsx +19 -0
  253. package/src/editor/DragHandle.spec.ts +217 -0
  254. package/src/editor/DragHandle.ts +214 -55
  255. package/src/editor/RegistryBlockNode.spec.ts +172 -2
  256. package/src/editor/RegistryBlockNode.tsx +91 -13
  257. package/src/editor/index.ts +4 -0
  258. package/src/styles.css +2 -0
  259. package/src/ui/dropdown-menu.tsx +4 -0
@@ -0,0 +1,687 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { McpApprovalView } from "@agent-native/core/client/mcp-approval";
4
+ import { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+ const useSessionMock = vi.fn();
9
+ vi.mock("@agent-native/core/client/use-session", async (importOriginal) => ({
10
+ ...(await importOriginal<
11
+ typeof import("@agent-native/core/client/use-session")
12
+ >()),
13
+ useSession: () => useSessionMock(),
14
+ }));
15
+
16
+ import {
17
+ McpApprovalReviewGate,
18
+ McpApprovalReviewPage,
19
+ } from "./McpApprovalReview.js";
20
+
21
+ const ID = "0b7f4c1e-2f4a-4d1c-9a43-5f1e8f0d2c6b";
22
+ const NOW = Date.parse("2026-10-22T16:00:00Z");
23
+ const START = Date.parse("2026-10-22T14:00:00-07:00");
24
+
25
+ let container: HTMLDivElement;
26
+ let root: Root;
27
+ let fetchMock: ReturnType<typeof vi.fn>;
28
+
29
+ function pendingView(
30
+ overrides: Partial<McpApprovalView> = {},
31
+ ): McpApprovalView {
32
+ return {
33
+ id: ID,
34
+ state: "pending",
35
+ actionName: "create-event",
36
+ summary: {
37
+ title: "Create event",
38
+ recipients: ["ana@example.com", "bo@example.com"],
39
+ fields: [
40
+ { label: "Title", value: "Design review" },
41
+ {
42
+ label: "Start",
43
+ value: "2026-10-22T14:00:00-07:00",
44
+ time: { at: START, zone: "America/Los_Angeles" },
45
+ },
46
+ {
47
+ label: "End",
48
+ value: "2026-10-22T14:30:00",
49
+ wallTime: {
50
+ at: Date.parse("2026-10-22T14:30:00Z"),
51
+ zone: "calendar",
52
+ },
53
+ },
54
+ ],
55
+ details: '{\n "title": "Design review"\n}',
56
+ arguments: "stored",
57
+ cut: false,
58
+ },
59
+ requester: {
60
+ name: "Claude Code",
61
+ connectedAt: Date.parse("2026-10-01T12:00:00Z"),
62
+ },
63
+ createdAt: NOW,
64
+ expiresAt: NOW + 10 * 60_000,
65
+ servedAt: NOW,
66
+ priorRun: undefined,
67
+ readerTimeZone: "America/New_York",
68
+ locale: "en-US",
69
+ csrfToken: "token-1",
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ function respond(status: number, body: unknown) {
75
+ return new Response(JSON.stringify(body), {
76
+ status,
77
+ headers: { "content-type": "application/json" },
78
+ });
79
+ }
80
+
81
+ /** Answers the review's read with `view`, and its decision with `decision`. */
82
+ function serve(
83
+ view: () => Response | Promise<Response>,
84
+ decision: () => Response | Promise<Response> = () =>
85
+ respond(200, { state: "approved", expiresAt: NOW + 10 * 60_000 }),
86
+ ) {
87
+ fetchMock.mockImplementation(async (_url: string, init?: RequestInit) =>
88
+ init?.method === "POST" ? decision() : view(),
89
+ );
90
+ }
91
+
92
+ async function settle() {
93
+ await act(async () => {
94
+ for (let i = 0; i < 5; i++) await Promise.resolve();
95
+ });
96
+ }
97
+
98
+ async function renderPage(onOpenApp = vi.fn()) {
99
+ act(() => {
100
+ root.render(
101
+ <McpApprovalReviewPage
102
+ id={ID}
103
+ appName="Calendar"
104
+ onOpenApp={onOpenApp}
105
+ />,
106
+ );
107
+ });
108
+ await settle();
109
+ return onOpenApp;
110
+ }
111
+
112
+ /** The page's text, with the narrow spaces some ICU versions put before AM/PM. */
113
+ function text(): string {
114
+ return (container.textContent ?? "").replace(/ /g, " ");
115
+ }
116
+
117
+ /** Each summary row as its label and value. */
118
+ function facts(): [string, string][] {
119
+ return [...container.querySelectorAll("dt")].map((label) => [
120
+ label.textContent ?? "",
121
+ (label.nextElementSibling?.textContent ?? "").replace(/ /g, " "),
122
+ ]);
123
+ }
124
+
125
+ function button(name: string): HTMLButtonElement {
126
+ const found = [...container.querySelectorAll("button")].find(
127
+ (element) => element.textContent?.trim() === name,
128
+ );
129
+ if (!found) throw new Error(`No button named ${name}`);
130
+ return found;
131
+ }
132
+
133
+ async function click(name: string) {
134
+ await act(async () => {
135
+ button(name).click();
136
+ });
137
+ await settle();
138
+ }
139
+
140
+ (
141
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
142
+ ).IS_REACT_ACT_ENVIRONMENT = true;
143
+
144
+ beforeEach(() => {
145
+ vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
146
+ vi.setSystemTime(NOW);
147
+ container = document.createElement("div");
148
+ document.body.appendChild(container);
149
+ root = createRoot(container);
150
+ fetchMock = vi.fn();
151
+ vi.stubGlobal("fetch", fetchMock);
152
+ useSessionMock.mockReturnValue({
153
+ session: { email: "owner@example.com" },
154
+ status: "authenticated",
155
+ });
156
+ document.title = "Calendar";
157
+ });
158
+
159
+ afterEach(() => {
160
+ act(() => root.unmount());
161
+ container.remove();
162
+ vi.unstubAllGlobals();
163
+ vi.useRealTimers();
164
+ vi.clearAllMocks();
165
+ window.history.replaceState(null, "", "/");
166
+ });
167
+
168
+ describe("a pending review", () => {
169
+ it("shows everything the call does, in the reader's zone, and focuses the page rather than Approve", async () => {
170
+ serve(() => respond(200, pendingView()));
171
+
172
+ await renderPage();
173
+
174
+ const page = text();
175
+ expect(container.querySelector("h1")?.textContent).toBe(
176
+ "Claude Code wants to create event",
177
+ );
178
+ expect(page).toContain("ana@example.com");
179
+ expect(page).toContain("bo@example.com");
180
+ expect(facts()).toEqual([
181
+ ["Connected", "Oct 1, 2026"],
182
+ ["Title", "Design review"],
183
+ [
184
+ "Start",
185
+ "Thu, Oct 22, 5:00 PM EDT (Thu, Oct 22, 2:00 PM PDT as requested)",
186
+ ],
187
+ ["End", "Thu, Oct 22, 2:30 PM (in the calendar's time zone)"],
188
+ ]);
189
+ expect(page).toContain("Decide by 12:10 PM EDT.");
190
+ expect(document.title).toBe("Approve Create event?");
191
+ expect(document.activeElement?.tagName).toBe("MAIN");
192
+ expect(fetchMock).toHaveBeenCalledWith(
193
+ `/_agent-native/mcp/approvals/${ID}`,
194
+ expect.objectContaining({ headers: { accept: "application/json" } }),
195
+ );
196
+ });
197
+
198
+ it("names the call's zone when it only shares the reader's offset", async () => {
199
+ // London and Lagos are both UTC+1 until London's clocks go back.
200
+ const view = pendingView({ readerTimeZone: "Africa/Lagos" });
201
+ view.summary.fields = [
202
+ {
203
+ label: "Start",
204
+ value: "2026-10-22T14:00:00",
205
+ time: { at: Date.parse("2026-10-22T13:00:00Z"), zone: "Europe/London" },
206
+ },
207
+ ];
208
+ serve(() => respond(200, view));
209
+
210
+ await renderPage();
211
+
212
+ expect(facts()).toContainEqual([
213
+ "Start",
214
+ "Thu, Oct 22, 2:00 PM GMT+1 (Thu, Oct 22, 2:00 PM United Kingdom Time as requested)",
215
+ ]);
216
+ });
217
+
218
+ it("warns when the identical call already ran, and when the server couldn't keep all of it", async () => {
219
+ serve(() =>
220
+ respond(
221
+ 200,
222
+ pendingView({
223
+ priorRun: { at: NOW - 5 * 60_000, status: "completed" },
224
+ summary: { ...pendingView().summary, cut: true },
225
+ }),
226
+ ),
227
+ );
228
+
229
+ await renderPage();
230
+
231
+ const alerts = [...container.querySelectorAll('[role="alert"]')].map(
232
+ (alert) => alert.textContent?.replace(/ /g, " "),
233
+ );
234
+ expect(alerts).toEqual([
235
+ "This exact request already ran at 11:55 AM EDT. Approving runs it again.",
236
+ expect.stringContaining("Part of this request was too large to keep"),
237
+ ]);
238
+ });
239
+
240
+ it("says plainly when the arguments weren't kept", async () => {
241
+ serve(() =>
242
+ respond(
243
+ 200,
244
+ pendingView({
245
+ summary: {
246
+ ...pendingView().summary,
247
+ fields: [],
248
+ recipients: [],
249
+ details: undefined,
250
+ arguments: "not-stored",
251
+ },
252
+ }),
253
+ ),
254
+ );
255
+
256
+ await renderPage();
257
+
258
+ expect(text()).toContain(
259
+ "This request's arguments weren't saved, so they can't be shown.",
260
+ );
261
+ expect(text()).not.toContain("Details");
262
+ });
263
+
264
+ it("speaks the summary's language", async () => {
265
+ serve(() =>
266
+ respond(
267
+ 200,
268
+ pendingView({
269
+ locale: "de-DE",
270
+ summary: { ...pendingView().summary, title: "Termin erstellen" },
271
+ }),
272
+ ),
273
+ );
274
+
275
+ await renderPage();
276
+
277
+ expect(button("Genehmigen")).toBeTruthy();
278
+ expect(button("Ablehnen")).toBeTruthy();
279
+ expect(document.title).toBe("Termin erstellen genehmigen?");
280
+ });
281
+
282
+ it.each([
283
+ [
284
+ "Claude Code",
285
+ "en-US",
286
+ "Create event",
287
+ "Claude Code wants to create event",
288
+ ],
289
+ [null, "en-US", "Create event", "An unnamed app wants to create event"],
290
+ [undefined, "en-US", "RSVP to event", "Your agent wants to RSVP to event"],
291
+ [
292
+ "{action} $& Bot",
293
+ "en-US",
294
+ "Create event",
295
+ "{action} $& Bot wants to create event",
296
+ ],
297
+ [
298
+ "Claude Code",
299
+ "de-DE",
300
+ "Termin erstellen",
301
+ "Claude Code möchte Termin erstellen ausführen",
302
+ ],
303
+ ] as const)(
304
+ "leads with who asks and for what (client %j, %s)",
305
+ async (name, locale, title, heading) => {
306
+ serve(() =>
307
+ respond(
308
+ 200,
309
+ pendingView({
310
+ requester:
311
+ name === undefined ? undefined : { name, connectedAt: null },
312
+ locale,
313
+ summary: { ...pendingView().summary, title },
314
+ }),
315
+ ),
316
+ );
317
+
318
+ await renderPage();
319
+
320
+ expect(container.querySelector("h1")?.textContent).toBe(heading);
321
+ },
322
+ );
323
+
324
+ it("keeps each address whole, breaking a line only after its @, and copies it as written", async () => {
325
+ const cc = "Bo <bo-smith@example.com>, cy-jones@example.co.uk";
326
+ serve(() =>
327
+ respond(
328
+ 200,
329
+ pendingView({
330
+ summary: {
331
+ ...pendingView().summary,
332
+ recipients: ["ana-maria.long-name@example.com"],
333
+ fields: [{ label: "Cc", value: cc }],
334
+ details: `{\n "cc": "${cc}"\n}`,
335
+ },
336
+ }),
337
+ ),
338
+ );
339
+
340
+ await renderPage();
341
+
342
+ const unbroken = (element: Element) =>
343
+ [...element.querySelectorAll(".whitespace-nowrap")].map(
344
+ (part) => part.textContent,
345
+ );
346
+ const recipient = container.querySelector("li")!.lastElementChild!;
347
+ expect(recipient.textContent).toBe("ana-maria.long-name@example.com");
348
+ expect(unbroken(recipient)).toEqual([
349
+ "ana-maria.long-name@",
350
+ "example.com",
351
+ ]);
352
+ expect(recipient.querySelectorAll("wbr")).toHaveLength(1);
353
+
354
+ const value = container.querySelector("dd:last-of-type")!;
355
+ expect(value.textContent).toBe(cc);
356
+ expect(unbroken(value)).toEqual([
357
+ "bo-smith@",
358
+ "example.com",
359
+ "cy-jones@",
360
+ "example.co.uk",
361
+ ]);
362
+ await click("Details");
363
+ expect(container.querySelector("pre")?.textContent).toBe(
364
+ `{\n "cc": "${cc}"\n}`,
365
+ );
366
+ });
367
+
368
+ it("draws Approve in the theme's text color, which no theme dims", async () => {
369
+ serve(() => respond(200, pendingView()));
370
+
371
+ await renderPage();
372
+
373
+ expect(button("Approve").className).toContain(
374
+ "bg-foreground text-background",
375
+ );
376
+ });
377
+ });
378
+
379
+ describe("deciding", () => {
380
+ it("approves with the view's token, then says what happens next", async () => {
381
+ serve(() => respond(200, pendingView()));
382
+ const onOpenApp = await renderPage();
383
+
384
+ await click("Approve");
385
+
386
+ const [, init] = fetchMock.mock.calls.at(-1)!;
387
+ expect(JSON.parse(init.body)).toEqual({
388
+ decision: "approve",
389
+ csrfToken: "token-1",
390
+ });
391
+ expect(text()).toContain(
392
+ "Approved. Go back to your agent and ask it to try again before 12:10 PM EDT.",
393
+ );
394
+ expect(
395
+ [...container.querySelectorAll("p")].map((line) =>
396
+ line.textContent?.replace(/ /g, " "),
397
+ ),
398
+ ).toContain("Design review · Thu, Oct 22, 5:00 PM EDT");
399
+ expect(() => button("Approve")).toThrow();
400
+ expect(document.activeElement?.tagName).toBe("MAIN");
401
+
402
+ await click("Open Calendar");
403
+ expect(onOpenApp).toHaveBeenCalledOnce();
404
+ });
405
+
406
+ it("shows a decision made first elsewhere", async () => {
407
+ serve(
408
+ () => respond(200, pendingView()),
409
+ () => respond(409, { error: "Decided", state: "denied", expiresAt: 0 }),
410
+ );
411
+ await renderPage();
412
+
413
+ await click("Approve");
414
+
415
+ expect(text()).toContain("Denied. Nothing ran.");
416
+ });
417
+
418
+ it("lets the reader try again when the decision didn't save", async () => {
419
+ serve(
420
+ () => respond(200, pendingView()),
421
+ () => Promise.reject(new TypeError("offline")),
422
+ );
423
+ await renderPage();
424
+
425
+ await click("Approve");
426
+
427
+ expect(text()).toContain("Your decision wasn't saved. Try again.");
428
+ expect(button("Approve").disabled).toBe(false);
429
+ });
430
+
431
+ it("shows the decision the server saved when its answer was lost", async () => {
432
+ let saved = false;
433
+ serve(
434
+ () =>
435
+ respond(
436
+ 200,
437
+ saved
438
+ ? { ...pendingView(), state: "denied", csrfToken: undefined }
439
+ : pendingView(),
440
+ ),
441
+ () => {
442
+ saved = true;
443
+ return Promise.reject(new TypeError("offline"));
444
+ },
445
+ );
446
+ await renderPage();
447
+
448
+ await click("Deny");
449
+
450
+ expect(text()).toContain("Denied. Nothing ran.");
451
+ expect(text()).not.toContain("wasn't saved");
452
+ });
453
+
454
+ it("says it can't confirm a decision when the review can't be read either", async () => {
455
+ let reads = 0;
456
+ serve(
457
+ () =>
458
+ reads++ === 0
459
+ ? respond(200, pendingView())
460
+ : Promise.reject(new TypeError("offline")),
461
+ () => Promise.reject(new TypeError("offline")),
462
+ );
463
+ await renderPage();
464
+
465
+ await click("Approve");
466
+
467
+ expect(text()).toContain(
468
+ "Your decision couldn't be confirmed. Reload this page to see whether it was saved.",
469
+ );
470
+ expect(button("Approve").disabled).toBe(false);
471
+ });
472
+
473
+ it.each([
474
+ [403, {}, "This page is out of date. Reload it and decide again."],
475
+ [401, {}, "Sign in to review this request."],
476
+ [
477
+ 401,
478
+ { reason: "sign-in-unavailable" },
479
+ "This app can't confirm your sign-in on this page.",
480
+ ],
481
+ ])(
482
+ "stops offering a decision the server will refuse again: %i %j",
483
+ async (status, body, message) => {
484
+ serve(
485
+ () => respond(200, pendingView()),
486
+ () => respond(status, { error: "Refused", ...body }),
487
+ );
488
+ await renderPage();
489
+
490
+ await click("Deny");
491
+
492
+ expect(text()).toContain(message);
493
+ expect(button("Approve").disabled).toBe(true);
494
+ expect(button("Deny").disabled).toBe(true);
495
+ },
496
+ );
497
+ });
498
+
499
+ describe("a review that can't be decided", () => {
500
+ it("asks the server at a pending review's deadline, and shows its answer", async () => {
501
+ const states = ["pending", "expired"] as const;
502
+ let reads = 0;
503
+ serve(() => respond(200, pendingView({ state: states[reads++] })));
504
+ await renderPage();
505
+ expect(button("Approve")).toBeTruthy();
506
+
507
+ await act(async () => {
508
+ vi.advanceTimersByTime(10 * 60_000 + 1_100);
509
+ });
510
+ await settle();
511
+
512
+ expect(reads).toBe(2);
513
+ expect(text()).toContain("This request expired, and nothing ran.");
514
+ expect(() => button("Approve")).toThrow();
515
+ });
516
+
517
+ it("times the deadline on the server's clock when this device's runs fast", async () => {
518
+ const states = ["pending", "expired"] as const;
519
+ let reads = 0;
520
+ // This device reads five minutes later than the server.
521
+ serve(() =>
522
+ respond(
523
+ 200,
524
+ pendingView({
525
+ state: states[reads++],
526
+ servedAt: NOW - 5 * 60_000,
527
+ expiresAt: NOW + 5 * 60_000,
528
+ }),
529
+ ),
530
+ );
531
+ await renderPage();
532
+
533
+ await act(async () => {
534
+ vi.advanceTimersByTime(5 * 60_000 + 1_100);
535
+ });
536
+ await settle();
537
+ expect(reads).toBe(1);
538
+ expect(button("Approve").disabled).toBe(false);
539
+
540
+ await act(async () => {
541
+ vi.advanceTimersByTime(5 * 60_000);
542
+ });
543
+ await settle();
544
+ expect(reads).toBe(2);
545
+ expect(text()).toContain("This request expired, and nothing ran.");
546
+ });
547
+
548
+ it("times the deadline on the server's clock when this device's runs slow", async () => {
549
+ const states = ["approved", "used"] as const;
550
+ let reads = 0;
551
+ // This device reads five minutes earlier than the server.
552
+ serve(() =>
553
+ respond(
554
+ 200,
555
+ pendingView({
556
+ state: states[reads++],
557
+ csrfToken: undefined,
558
+ servedAt: NOW + 5 * 60_000,
559
+ expiresAt: NOW + 15 * 60_000,
560
+ }),
561
+ ),
562
+ );
563
+ await renderPage();
564
+
565
+ await act(async () => {
566
+ vi.advanceTimersByTime(10 * 60_000 + 1_100);
567
+ });
568
+ await settle();
569
+
570
+ expect(reads).toBe(2);
571
+ expect(text()).toContain("This request was approved and has run.");
572
+ });
573
+
574
+ it("asks the server at an approved grant's deadline, since the agent may have used it", async () => {
575
+ const states = ["approved", "used"] as const;
576
+ let reads = 0;
577
+ serve(() =>
578
+ respond(
579
+ 200,
580
+ pendingView({ state: states[reads++], csrfToken: undefined }),
581
+ ),
582
+ );
583
+ await renderPage();
584
+
585
+ await act(async () => {
586
+ vi.advanceTimersByTime(10 * 60_000 + 1_100);
587
+ });
588
+ await settle();
589
+
590
+ expect(reads).toBe(2);
591
+ expect(text()).toContain("This request was approved and has run.");
592
+ expect(text()).not.toContain("nothing ran");
593
+ });
594
+
595
+ it("names the signed-in account when the grant is in another organization", async () => {
596
+ serve(() =>
597
+ respond(404, { error: "Not found", reason: "other-organization" }),
598
+ );
599
+
600
+ await renderPage();
601
+
602
+ expect(text()).toContain(
603
+ "This request belongs to owner@example.com in another organization.",
604
+ );
605
+ expect(button("Open Calendar")).toBeTruthy();
606
+ });
607
+
608
+ it("says the app can't confirm the sign-in rather than asking for one", async () => {
609
+ serve(() =>
610
+ respond(401, {
611
+ error: "Custom authentication requires a hook",
612
+ reason: "sign-in-unavailable",
613
+ }),
614
+ );
615
+
616
+ await renderPage();
617
+
618
+ expect(text()).toContain(
619
+ "This app can't confirm your sign-in on this page.",
620
+ );
621
+ expect(text()).not.toContain("Sign in to review");
622
+ expect(button("Open Calendar")).toBeTruthy();
623
+ });
624
+
625
+ it("offers a retry when the review couldn't load", async () => {
626
+ let calls = 0;
627
+ serve(() =>
628
+ ++calls === 1
629
+ ? respond(503, { error: "Unavailable" })
630
+ : respond(200, pendingView()),
631
+ );
632
+ await renderPage();
633
+ expect(text()).toContain("This request couldn't be loaded.");
634
+
635
+ await click("Try again");
636
+
637
+ expect(button("Approve")).toBeTruthy();
638
+ });
639
+ });
640
+
641
+ describe("the gate", () => {
642
+ function renderGate() {
643
+ act(() => {
644
+ root.render(
645
+ <McpApprovalReviewGate appName="Calendar" wrap={(review) => review}>
646
+ <div data-testid="app">app</div>
647
+ </McpApprovalReviewGate>,
648
+ );
649
+ });
650
+ }
651
+
652
+ it("shows the app when the URL names no review", () => {
653
+ window.history.replaceState(null, "", "/home#section");
654
+
655
+ renderGate();
656
+
657
+ expect(container.querySelector('[data-testid="app"]')).not.toBeNull();
658
+ expect(fetchMock).not.toHaveBeenCalled();
659
+ });
660
+
661
+ it("shows the review the fragment names, then the app once the reader leaves it", async () => {
662
+ window.history.replaceState(null, "", `/home?view=week#mcpApproval=${ID}`);
663
+ serve(() => respond(404, { error: "Not found" }));
664
+
665
+ renderGate();
666
+ await settle();
667
+ expect(container.querySelector('[data-testid="app"]')).toBeNull();
668
+
669
+ await click("Open Calendar");
670
+
671
+ expect(container.querySelector('[data-testid="app"]')).not.toBeNull();
672
+ expect(`${window.location.pathname}${window.location.search}`).toBe(
673
+ "/home?view=week",
674
+ );
675
+ expect(window.location.hash).toBe("");
676
+ });
677
+
678
+ it("never shows a review inside a frame", () => {
679
+ window.history.replaceState(null, "", `/home#mcpApproval=${ID}`);
680
+ vi.stubGlobal("top", {});
681
+
682
+ renderGate();
683
+
684
+ expect(container.querySelector('[data-testid="app"]')).not.toBeNull();
685
+ expect(fetchMock).not.toHaveBeenCalled();
686
+ });
687
+ });