@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.
- package/agent-native.eject.json +16 -1
- package/dist/app/agentkit/react/components.d.ts +5 -3
- package/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +103 -42
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/agentkit/react/styles.css +93 -65
- package/dist/app/agentkit/react/voice-transcripts.d.ts +22 -1
- package/dist/app/agentkit/react/voice-transcripts.d.ts.map +1 -1
- package/dist/app/agentkit/react/voice-transcripts.js +209 -8
- package/dist/app/agentkit/react/voice-transcripts.js.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.js +55 -40
- package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
- package/dist/app/chat/agentkit-chat/guided-questions.d.ts +1 -0
- package/dist/app/chat/agentkit-chat/guided-questions.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/guided-questions.js +18 -6
- package/dist/app/chat/agentkit-chat/guided-questions.js.map +1 -1
- package/dist/app/chat/agentkit-chat/index.d.ts +1 -1
- package/dist/app/chat/agentkit-chat/index.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/index.js +1 -1
- package/dist/app/chat/agentkit-chat/index.js.map +1 -1
- package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
- package/dist/app/chat/chat/run-recovery.js +5 -1
- package/dist/app/chat/chat/run-recovery.js.map +1 -1
- package/dist/app/chat/composer/runtime-adapters.d.ts.map +1 -1
- package/dist/app/chat/composer/runtime-adapters.js +2 -0
- package/dist/app/chat/composer/runtime-adapters.js.map +1 -1
- package/dist/app/chat/reply-connection-suggestion.d.ts +29 -0
- package/dist/app/chat/reply-connection-suggestion.d.ts.map +1 -0
- package/dist/app/chat/reply-connection-suggestion.js +42 -0
- package/dist/app/chat/reply-connection-suggestion.js.map +1 -0
- package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ar-SA.js +7 -0
- package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.js +7 -0
- package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
- package/dist/app/i18n/catalogs/en-US.d.ts +7 -0
- package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/en-US.js +7 -0
- package/dist/app/i18n/catalogs/en-US.js.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.js +7 -0
- package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.js +7 -0
- package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.js +7 -0
- package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.js +7 -0
- package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.js +7 -0
- package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.js +7 -0
- package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.js +7 -0
- package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.js +7 -0
- package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
- package/dist/app/mcp-approval/McpApprovalReview.d.ts +25 -0
- package/dist/app/mcp-approval/McpApprovalReview.d.ts.map +1 -0
- package/dist/app/mcp-approval/McpApprovalReview.js +342 -0
- package/dist/app/mcp-approval/McpApprovalReview.js.map +1 -0
- package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
- package/dist/app/onboarding/FirstRunOnboarding.js +299 -71
- package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
- package/dist/app/onboarding/GettingStartedCard.js +1 -1
- package/dist/app/onboarding/GettingStartedCard.js.map +1 -1
- package/dist/app/onboarding/first-run-builder-credits-flag.d.ts +2 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.d.ts.map +1 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.js +8 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.js.map +1 -0
- package/dist/app/providers/AppProviders.d.ts.map +1 -1
- package/dist/app/providers/AppProviders.js +14 -1
- package/dist/app/providers/AppProviders.js.map +1 -1
- package/dist/app/resources/McpConnectionSuggestion.d.ts +2 -4
- package/dist/app/resources/McpConnectionSuggestion.d.ts.map +1 -1
- package/dist/app/resources/McpConnectionSuggestion.js +59 -36
- package/dist/app/resources/McpConnectionSuggestion.js.map +1 -1
- package/dist/app/settings/account/PreferencesSettings.d.ts.map +1 -1
- package/dist/app/settings/account/PreferencesSettings.js +14 -1
- package/dist/app/settings/account/PreferencesSettings.js.map +1 -1
- package/dist/app/settings/account/ThemeFamilySettings.js +2 -2
- package/dist/app/settings/account/ThemeFamilySettings.js.map +1 -1
- package/dist/app/settings/account/search-entries.d.ts +1 -0
- package/dist/app/settings/account/search-entries.d.ts.map +1 -1
- package/dist/app/settings/account/search-entries.js +5 -0
- package/dist/app/settings/account/search-entries.js.map +1 -1
- package/dist/app/settings/shell/SettingsShell.d.ts.map +1 -1
- package/dist/app/settings/shell/SettingsShell.js +26 -2
- package/dist/app/settings/shell/SettingsShell.js.map +1 -1
- package/dist/app/shared/AppShellSkeleton.d.ts.map +1 -1
- package/dist/app/shared/AppShellSkeleton.js +4 -10
- package/dist/app/shared/AppShellSkeleton.js.map +1 -1
- package/dist/app/shared/EnvironmentBadge.d.ts +2 -1
- package/dist/app/shared/EnvironmentBadge.d.ts.map +1 -1
- package/dist/app/shared/EnvironmentBadge.js +2 -3
- package/dist/app/shared/EnvironmentBadge.js.map +1 -1
- package/dist/app/shared/is-builder-io-employee.d.ts +8 -0
- package/dist/app/shared/is-builder-io-employee.d.ts.map +1 -0
- package/dist/app/shared/is-builder-io-employee.js +10 -0
- package/dist/app/shared/is-builder-io-employee.js.map +1 -0
- package/dist/app/styles/agent-conversation.css +30 -17
- package/dist/app/styles/theme-families.css +22 -5
- package/dist/app/styles/tokens/agent-kit.css +3 -3
- package/dist/composer/PromptComposer.d.ts +15 -3
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +74 -16
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/composer/RealtimeVoiceMode.d.ts +7 -0
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/RealtimeVoiceMode.js +9 -2
- package/dist/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +7 -0
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +12 -4
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/VoiceButton.d.ts +2 -0
- package/dist/composer/VoiceButton.d.ts.map +1 -1
- package/dist/composer/VoiceButton.js +5 -4
- package/dist/composer/VoiceButton.js.map +1 -1
- package/dist/composer/index.d.ts +2 -2
- package/dist/composer/index.d.ts.map +1 -1
- package/dist/composer/index.js +1 -1
- package/dist/composer/index.js.map +1 -1
- package/dist/composer/prompt-attachments.d.ts +20 -0
- package/dist/composer/prompt-attachments.d.ts.map +1 -1
- package/dist/composer/prompt-attachments.js +188 -11
- package/dist/composer/prompt-attachments.js.map +1 -1
- package/dist/composer/realtime-voice-transcript.d.ts +27 -0
- package/dist/composer/realtime-voice-transcript.d.ts.map +1 -1
- package/dist/composer/realtime-voice-transcript.js +34 -1
- package/dist/composer/realtime-voice-transcript.js.map +1 -1
- package/dist/composer/realtime-voice-turn-metrics.d.ts +43 -0
- package/dist/composer/realtime-voice-turn-metrics.d.ts.map +1 -0
- package/dist/composer/realtime-voice-turn-metrics.js +132 -0
- package/dist/composer/realtime-voice-turn-metrics.js.map +1 -0
- package/dist/composer/runtime-adapters.d.ts +4 -1
- package/dist/composer/runtime-adapters.d.ts.map +1 -1
- package/dist/composer/runtime-adapters.js +2 -1
- package/dist/composer/runtime-adapters.js.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.d.ts +61 -9
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.js +536 -99
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/composer/voice-chat-icon.d.ts +7 -0
- package/dist/composer/voice-chat-icon.d.ts.map +1 -0
- package/dist/composer/voice-chat-icon.js +14 -0
- package/dist/composer/voice-chat-icon.js.map +1 -0
- package/dist/editor/DragHandle.d.ts +12 -1
- package/dist/editor/DragHandle.d.ts.map +1 -1
- package/dist/editor/DragHandle.js +165 -41
- package/dist/editor/DragHandle.js.map +1 -1
- package/dist/editor/RegistryBlockNode.d.ts +20 -0
- package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
- package/dist/editor/RegistryBlockNode.js +58 -13
- package/dist/editor/RegistryBlockNode.js.map +1 -1
- package/dist/editor/index.d.ts +2 -2
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +1 -1
- package/dist/editor/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/dist/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/ui/dropdown-menu.js +5 -1
- package/dist/ui/dropdown-menu.js.map +1 -1
- package/package.json +14 -4
- package/src/app/agentkit/react/components.regenerate-readiness.spec.tsx +3 -1
- package/src/app/agentkit/react/components.spec.tsx +24 -4
- package/src/app/agentkit/react/components.tsx +174 -44
- package/src/app/agentkit/react/composer-context.spec.tsx +3 -0
- package/src/app/agentkit/react/disclosures.spec.tsx +124 -0
- package/src/app/agentkit/react/lifecycle.spec.tsx +8 -2
- package/src/app/agentkit/react/message-actions.spec.tsx +161 -5
- package/src/app/agentkit/react/saved-reply-reload.spec.tsx +206 -0
- package/src/app/agentkit/react/styles.css +93 -65
- package/src/app/agentkit/react/voice-transcripts.spec.tsx +239 -4
- package/src/app/agentkit/react/voice-transcripts.ts +245 -8
- package/src/app/chat/AgentKitAssistantChat.spec.tsx +59 -11
- package/src/app/chat/AgentKitAssistantChat.tsx +74 -62
- package/src/app/chat/agentkit-chat/guided-answers-context.spec.ts +9 -0
- package/src/app/chat/agentkit-chat/guided-questions.tsx +17 -7
- package/src/app/chat/agentkit-chat/index.ts +1 -0
- package/src/app/chat/chat/run-recovery.spec.tsx +45 -0
- package/src/app/chat/chat/run-recovery.tsx +6 -1
- package/src/app/chat/composer/runtime-adapters.tsx +2 -0
- package/src/app/chat/reply-connection-suggestion.spec.ts +121 -0
- package/src/app/chat/reply-connection-suggestion.ts +76 -0
- package/src/app/i18n/catalogs/ar-SA.ts +10 -0
- package/src/app/i18n/catalogs/de-DE.ts +10 -0
- package/src/app/i18n/catalogs/en-US.ts +10 -0
- package/src/app/i18n/catalogs/es-ES.ts +10 -0
- package/src/app/i18n/catalogs/fr-FR.ts +10 -0
- package/src/app/i18n/catalogs/hi-IN.ts +10 -0
- package/src/app/i18n/catalogs/ja-JP.ts +10 -0
- package/src/app/i18n/catalogs/ko-KR.ts +10 -0
- package/src/app/i18n/catalogs/pt-BR.ts +10 -0
- package/src/app/i18n/catalogs/zh-CN.ts +10 -0
- package/src/app/i18n/catalogs/zh-TW.ts +10 -0
- package/src/app/mcp-approval/McpApprovalReview.spec.tsx +687 -0
- package/src/app/mcp-approval/McpApprovalReview.tsx +913 -0
- package/src/app/onboarding/FirstRunOnboarding.spec.tsx +736 -26
- package/src/app/onboarding/FirstRunOnboarding.tsx +473 -145
- package/src/app/onboarding/GettingStartedCard.tsx +1 -1
- package/src/app/onboarding/first-run-builder-credits-flag.spec.ts +13 -0
- package/src/app/onboarding/first-run-builder-credits-flag.ts +9 -0
- package/src/app/providers/AppProviders.spec.tsx +109 -0
- package/src/app/providers/AppProviders.tsx +53 -1
- package/src/app/resources/McpConnectionSuggestion.spec.ts +77 -12
- package/src/app/resources/McpConnectionSuggestion.tsx +69 -51
- package/src/app/settings/VoiceChatSettings.spec.tsx +6 -1
- package/src/app/settings/account/PreferencesSettings.spec.tsx +73 -0
- package/src/app/settings/account/PreferencesSettings.tsx +24 -7
- package/src/app/settings/account/ThemeFamilySettings.tsx +2 -2
- package/src/app/settings/account/search-entries.spec.ts +14 -0
- package/src/app/settings/account/search-entries.ts +9 -0
- package/src/app/settings/shell/SettingsShell.tsx +29 -2
- package/src/app/shared/AppShellSkeleton.spec.tsx +2 -2
- package/src/app/shared/AppShellSkeleton.tsx +3 -16
- package/src/app/shared/EnvironmentBadge.tsx +3 -4
- package/src/app/shared/is-builder-io-employee.ts +17 -0
- package/src/app/styles/agent-conversation.css +30 -17
- package/src/app/styles/theme-families.css +22 -5
- package/src/app/styles/theme-families.spec.ts +73 -0
- package/src/app/styles/tokens/agent-kit.css +3 -3
- package/src/composer/AgentSuggestionBar.overflow.spec.tsx +1 -1
- package/src/composer/AttachmentChip.spec.tsx +87 -0
- package/src/composer/PromptComposer.context.spec.tsx +8 -4
- package/src/composer/PromptComposer.setup-card.spec.tsx +170 -0
- package/src/composer/PromptComposer.tsx +125 -14
- package/src/composer/RealtimeVoiceMode.spec.tsx +48 -0
- package/src/composer/RealtimeVoiceMode.tsx +45 -2
- package/src/composer/TiptapComposer.tsx +25 -0
- package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +4 -1
- package/src/composer/VoiceButton.tsx +8 -3
- package/src/composer/index.ts +4 -0
- package/src/composer/prompt-attachments.spec.ts +160 -8
- package/src/composer/prompt-attachments.ts +221 -12
- package/src/composer/realtime-voice-transcript.spec.ts +36 -0
- package/src/composer/realtime-voice-transcript.ts +56 -1
- package/src/composer/realtime-voice-turn-metrics.spec.ts +175 -0
- package/src/composer/realtime-voice-turn-metrics.ts +192 -0
- package/src/composer/runtime-adapters.tsx +5 -1
- package/src/composer/useRealtimeVoiceMode.session.spec.tsx +487 -2
- package/src/composer/useRealtimeVoiceMode.spec.ts +267 -65
- package/src/composer/useRealtimeVoiceMode.tsx +716 -139
- package/src/composer/voice-chat-icon.tsx +19 -0
- package/src/editor/DragHandle.spec.ts +217 -0
- package/src/editor/DragHandle.ts +214 -55
- package/src/editor/RegistryBlockNode.spec.ts +172 -2
- package/src/editor/RegistryBlockNode.tsx +91 -13
- package/src/editor/index.ts +4 -0
- package/src/styles.css +2 -0
- package/src/ui/dropdown-menu.tsx +4 -0
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
import { useOptionalLocale } from "@agent-native/core/client/i18n";
|
|
2
|
+
import {
|
|
3
|
+
describeFieldValue,
|
|
4
|
+
fetchMcpApproval,
|
|
5
|
+
formatApprovalTime,
|
|
6
|
+
formatRequestedTime,
|
|
7
|
+
formatWallTime,
|
|
8
|
+
mcpApprovalMessagesForLocale,
|
|
9
|
+
readMcpApprovalId,
|
|
10
|
+
sendMcpApprovalDecision,
|
|
11
|
+
type McpApprovalDecisionResult,
|
|
12
|
+
type McpApprovalField,
|
|
13
|
+
type McpApprovalMessages,
|
|
14
|
+
type McpApprovalView,
|
|
15
|
+
type McpApprovalViewState,
|
|
16
|
+
} from "@agent-native/core/client/mcp-approval";
|
|
17
|
+
import { useSession } from "@agent-native/core/client/use-session";
|
|
18
|
+
import { Alert, AlertDescription } from "@agent-native/toolkit/ui/alert";
|
|
19
|
+
import { Avatar, AvatarFallback } from "@agent-native/toolkit/ui/avatar";
|
|
20
|
+
import { Button } from "@agent-native/toolkit/ui/button";
|
|
21
|
+
import { Card, CardContent, CardFooter } from "@agent-native/toolkit/ui/card";
|
|
22
|
+
import {
|
|
23
|
+
Collapsible,
|
|
24
|
+
CollapsibleContent,
|
|
25
|
+
CollapsibleTrigger,
|
|
26
|
+
} from "@agent-native/toolkit/ui/collapsible";
|
|
27
|
+
import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
|
|
28
|
+
import { Spinner } from "@agent-native/toolkit/ui/spinner";
|
|
29
|
+
import { cn } from "@agent-native/toolkit/utils";
|
|
30
|
+
import {
|
|
31
|
+
IconAlertTriangle,
|
|
32
|
+
IconBan,
|
|
33
|
+
IconChevronRight,
|
|
34
|
+
IconCircleCheck,
|
|
35
|
+
IconCircleX,
|
|
36
|
+
IconClockOff,
|
|
37
|
+
type Icon,
|
|
38
|
+
} from "@tabler/icons-react";
|
|
39
|
+
import {
|
|
40
|
+
useEffect,
|
|
41
|
+
useRef,
|
|
42
|
+
useState,
|
|
43
|
+
type ReactNode,
|
|
44
|
+
type RefObject,
|
|
45
|
+
} from "react";
|
|
46
|
+
|
|
47
|
+
import { AgentNativeIcon } from "../shared/AgentNativeIcon.js";
|
|
48
|
+
|
|
49
|
+
type TimeStyle = "time" | "date-time" | "date";
|
|
50
|
+
|
|
51
|
+
type Loaded =
|
|
52
|
+
| { status: "loading" }
|
|
53
|
+
| { status: "failed" }
|
|
54
|
+
| { status: "signed-out" }
|
|
55
|
+
| { status: "sign-in-unavailable" }
|
|
56
|
+
| { status: "not-found"; otherOrganization: boolean }
|
|
57
|
+
| {
|
|
58
|
+
status: "ready";
|
|
59
|
+
view: McpApprovalView;
|
|
60
|
+
/** How far the server's clock is ahead of this one. */
|
|
61
|
+
clockOffset: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Why the last decision didn't land, in the page's own words. */
|
|
65
|
+
type DecisionProblem =
|
|
66
|
+
| "saveFailed"
|
|
67
|
+
| "decisionUnconfirmed"
|
|
68
|
+
| "pageOutOfDate"
|
|
69
|
+
| "signInToReview"
|
|
70
|
+
| "signInUnavailable";
|
|
71
|
+
|
|
72
|
+
const UNDECIDABLE: Record<
|
|
73
|
+
Exclude<McpApprovalDecisionResult["status"], "decided" | "not-found">,
|
|
74
|
+
DecisionProblem
|
|
75
|
+
> = {
|
|
76
|
+
"out-of-date": "pageOutOfDate",
|
|
77
|
+
"signed-out": "signInToReview",
|
|
78
|
+
"sign-in-unavailable": "signInUnavailable",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// A review link can be framed by any site that has it, and the app's session
|
|
82
|
+
// cookie can reach a frame, so a decision is only taken in a top-level window,
|
|
83
|
+
// as the framework's own review page only renders in one.
|
|
84
|
+
function isTopLevelWindow(): boolean {
|
|
85
|
+
return window.top === window.self;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readApprovalIdFromLocation(): string | undefined {
|
|
89
|
+
return isTopLevelWindow()
|
|
90
|
+
? readMcpApprovalId(window.location.hash)
|
|
91
|
+
: undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface McpApprovalReviewGateProps {
|
|
95
|
+
appName: string;
|
|
96
|
+
/** Wraps the review, e.g. in the app's session gate and theme sync. */
|
|
97
|
+
wrap: (review: ReactNode) => ReactNode;
|
|
98
|
+
children: ReactNode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Shows the review for the grant the URL fragment names in place of the app,
|
|
103
|
+
* and the app once the reader leaves it. Client only: it reads the window
|
|
104
|
+
* while rendering.
|
|
105
|
+
*/
|
|
106
|
+
export function McpApprovalReviewGate({
|
|
107
|
+
appName,
|
|
108
|
+
wrap,
|
|
109
|
+
children,
|
|
110
|
+
}: McpApprovalReviewGateProps) {
|
|
111
|
+
const [id, setId] = useState(readApprovalIdFromLocation);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const onHashChange = () => setId(readApprovalIdFromLocation());
|
|
115
|
+
window.addEventListener("hashchange", onHashChange);
|
|
116
|
+
return () => window.removeEventListener("hashchange", onHashChange);
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
if (!id) return <>{children}</>;
|
|
120
|
+
const openApp = () => {
|
|
121
|
+
const { pathname, search } = window.location;
|
|
122
|
+
window.history.replaceState(window.history.state, "", pathname + search);
|
|
123
|
+
setId(undefined);
|
|
124
|
+
};
|
|
125
|
+
return wrap(
|
|
126
|
+
<McpApprovalReviewPage
|
|
127
|
+
key={id}
|
|
128
|
+
id={id}
|
|
129
|
+
appName={appName}
|
|
130
|
+
onOpenApp={openApp}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface McpApprovalReviewPageProps {
|
|
136
|
+
id: string;
|
|
137
|
+
appName: string;
|
|
138
|
+
onOpenApp: () => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One MCP approval review, in the app's theme. */
|
|
142
|
+
export function McpApprovalReviewPage({
|
|
143
|
+
id,
|
|
144
|
+
appName,
|
|
145
|
+
onOpenApp,
|
|
146
|
+
}: McpApprovalReviewPageProps) {
|
|
147
|
+
const appLocale = useOptionalLocale()?.locale;
|
|
148
|
+
const [loaded, setLoaded] = useState<Loaded>({ status: "loading" });
|
|
149
|
+
const [attempt, setAttempt] = useState(0);
|
|
150
|
+
const mainRef = useRef<HTMLElement>(null);
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
setLoaded({ status: "loading" });
|
|
155
|
+
readReview(id, controller.signal, setLoaded);
|
|
156
|
+
return () => controller.abort();
|
|
157
|
+
}, [id, attempt]);
|
|
158
|
+
|
|
159
|
+
// Focus lands on the page, not a button, so a stray Enter decides nothing;
|
|
160
|
+
// and again once a decision takes the buttons away.
|
|
161
|
+
const phase = loaded.status === "ready" ? loaded.view.state : loaded.status;
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (phase !== "loading") mainRef.current?.focus();
|
|
164
|
+
}, [phase]);
|
|
165
|
+
|
|
166
|
+
const view = loaded.status === "ready" ? loaded.view : undefined;
|
|
167
|
+
const messages = mcpApprovalMessagesForLocale(
|
|
168
|
+
view?.locale ?? appLocale ?? "en-US",
|
|
169
|
+
);
|
|
170
|
+
useDocumentTitle(
|
|
171
|
+
view
|
|
172
|
+
? messages.documentTitle.replace("{action}", view.summary.title)
|
|
173
|
+
: undefined,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const clockOffset = loaded.status === "ready" ? loaded.clockOffset : 0;
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!view || (view.state !== "pending" && view.state !== "approved")) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
// Only the server knows whether the request was decided, or the grant
|
|
183
|
+
// used, in time. Its deadline is timed on its clock, not this device's,
|
|
184
|
+
// and read a second late so the answer is final rather than "approved"
|
|
185
|
+
// again.
|
|
186
|
+
const timer = window.setTimeout(
|
|
187
|
+
() => readReview(id, controller.signal, setLoaded),
|
|
188
|
+
Math.max(0, view.expiresAt - (Date.now() + clockOffset)) + 1_000,
|
|
189
|
+
);
|
|
190
|
+
return () => {
|
|
191
|
+
window.clearTimeout(timer);
|
|
192
|
+
controller.abort();
|
|
193
|
+
};
|
|
194
|
+
}, [id, view, clockOffset]);
|
|
195
|
+
|
|
196
|
+
const decided = (state: McpApprovalViewState, expiresAt: number) =>
|
|
197
|
+
setLoaded((current) =>
|
|
198
|
+
current.status === "ready"
|
|
199
|
+
? { ...current, view: { ...current.view, state, expiresAt } }
|
|
200
|
+
: current,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<ReviewFrame appName={appName} mainRef={mainRef}>
|
|
205
|
+
{loaded.status === "loading" ? (
|
|
206
|
+
<ReviewSkeleton />
|
|
207
|
+
) : loaded.status === "ready" ? (
|
|
208
|
+
<Review
|
|
209
|
+
id={id}
|
|
210
|
+
view={loaded.view}
|
|
211
|
+
appName={appName}
|
|
212
|
+
messages={messages}
|
|
213
|
+
onDecided={decided}
|
|
214
|
+
onNotFound={() =>
|
|
215
|
+
setLoaded({ status: "not-found", otherOrganization: false })
|
|
216
|
+
}
|
|
217
|
+
onOpenApp={onOpenApp}
|
|
218
|
+
/>
|
|
219
|
+
) : (
|
|
220
|
+
<ReviewNotice
|
|
221
|
+
loaded={loaded}
|
|
222
|
+
appName={appName}
|
|
223
|
+
messages={messages}
|
|
224
|
+
onRetry={() => setAttempt((count) => count + 1)}
|
|
225
|
+
onOpenApp={onOpenApp}
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
</ReviewFrame>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Shown while the session and the review load: the same frame and card. */
|
|
233
|
+
export function McpApprovalReviewSkeleton({ appName }: { appName: string }) {
|
|
234
|
+
return (
|
|
235
|
+
<ReviewFrame appName={appName}>
|
|
236
|
+
<ReviewSkeleton />
|
|
237
|
+
</ReviewFrame>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readReview(
|
|
242
|
+
id: string,
|
|
243
|
+
signal: AbortSignal,
|
|
244
|
+
setLoaded: (loaded: Loaded) => void,
|
|
245
|
+
) {
|
|
246
|
+
fetchMcpApproval(id, { signal }).then(
|
|
247
|
+
(result) => {
|
|
248
|
+
if (signal.aborted) return;
|
|
249
|
+
setLoaded(
|
|
250
|
+
result.status === "ready"
|
|
251
|
+
? { ...result, clockOffset: result.view.servedAt - Date.now() }
|
|
252
|
+
: result,
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
() => {
|
|
256
|
+
if (!signal.aborted) setLoaded({ status: "failed" });
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function useDocumentTitle(title: string | undefined) {
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (title === undefined) return;
|
|
264
|
+
const previous = document.title;
|
|
265
|
+
document.title = title;
|
|
266
|
+
return () => {
|
|
267
|
+
document.title = previous;
|
|
268
|
+
};
|
|
269
|
+
}, [title]);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ReviewFrame({
|
|
273
|
+
appName,
|
|
274
|
+
mainRef,
|
|
275
|
+
children,
|
|
276
|
+
}: {
|
|
277
|
+
appName: string;
|
|
278
|
+
mainRef?: RefObject<HTMLElement | null>;
|
|
279
|
+
children: ReactNode;
|
|
280
|
+
}) {
|
|
281
|
+
return (
|
|
282
|
+
<div className="flex min-h-screen flex-col items-center bg-muted/40 px-4 py-10 sm:justify-center">
|
|
283
|
+
<div className="mb-6 flex items-center gap-1.5 text-sm font-semibold">
|
|
284
|
+
<AgentNativeIcon
|
|
285
|
+
aria-hidden="true"
|
|
286
|
+
className="h-3.5 w-6 text-primary"
|
|
287
|
+
/>
|
|
288
|
+
{appName}
|
|
289
|
+
</div>
|
|
290
|
+
<main
|
|
291
|
+
ref={mainRef}
|
|
292
|
+
tabIndex={-1}
|
|
293
|
+
className="w-full max-w-lg outline-none"
|
|
294
|
+
>
|
|
295
|
+
<Card className="border border-border">{children}</Card>
|
|
296
|
+
</main>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function ReviewSkeleton() {
|
|
302
|
+
return (
|
|
303
|
+
<div aria-hidden="true">
|
|
304
|
+
<div className="flex flex-col gap-2 p-6">
|
|
305
|
+
<Skeleton className="h-6 w-4/5" />
|
|
306
|
+
<Skeleton className="h-6 w-1/2" />
|
|
307
|
+
</div>
|
|
308
|
+
<CardContent className="flex flex-col gap-5">
|
|
309
|
+
<Skeleton className="h-24 w-full rounded-lg" />
|
|
310
|
+
<div className="flex flex-col gap-2">
|
|
311
|
+
<Skeleton className="h-4 w-2/3" />
|
|
312
|
+
<Skeleton className="h-4 w-1/2" />
|
|
313
|
+
<Skeleton className="h-4 w-3/5" />
|
|
314
|
+
</div>
|
|
315
|
+
</CardContent>
|
|
316
|
+
<CardFooter className="justify-end gap-2">
|
|
317
|
+
<Skeleton className="h-9 w-16" />
|
|
318
|
+
<Skeleton className="h-9 w-24" />
|
|
319
|
+
</CardFooter>
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type FillKey = "client" | "email" | "app" | "action";
|
|
325
|
+
|
|
326
|
+
/** Fills `{client}`, `{email}`, `{app}` and `{action}`, and puts `time` where `{time}` was. */
|
|
327
|
+
function fill(
|
|
328
|
+
message: string,
|
|
329
|
+
values: Partial<Record<FillKey, string>>,
|
|
330
|
+
time?: ReactNode,
|
|
331
|
+
): ReactNode {
|
|
332
|
+
// One pass, so a value that reads like a placeholder stays as written.
|
|
333
|
+
const text = (part: string) =>
|
|
334
|
+
part.replace(
|
|
335
|
+
/\{(client|email|app|action)\}/g,
|
|
336
|
+
(token, key: FillKey) => values[key] ?? token,
|
|
337
|
+
);
|
|
338
|
+
// Split first, so a client named "{time}" stays a name.
|
|
339
|
+
const at = message.indexOf("{time}");
|
|
340
|
+
if (time === undefined || at < 0) return text(message);
|
|
341
|
+
return (
|
|
342
|
+
<>
|
|
343
|
+
{text(message.slice(0, at))}
|
|
344
|
+
{time}
|
|
345
|
+
{text(message.slice(at + "{time}".length))}
|
|
346
|
+
</>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Who asks, and for what. Action titles are English in sentence case, and
|
|
352
|
+
* only the English copy runs one into a sentence, so only there does its
|
|
353
|
+
* first word lose its capital: "wants to create event", but "RSVP" stays.
|
|
354
|
+
*/
|
|
355
|
+
function RequestHeading({
|
|
356
|
+
view,
|
|
357
|
+
messages,
|
|
358
|
+
}: {
|
|
359
|
+
view: McpApprovalView;
|
|
360
|
+
messages: McpApprovalMessages;
|
|
361
|
+
}) {
|
|
362
|
+
const { title } = view.summary;
|
|
363
|
+
const action = view.locale.startsWith("en")
|
|
364
|
+
? title.replace(/^\p{Lu}(?=\p{Ll}*(?:\s|$))/u, (first) =>
|
|
365
|
+
first.toLowerCase(),
|
|
366
|
+
)
|
|
367
|
+
: title;
|
|
368
|
+
return (
|
|
369
|
+
<h1 className="text-xl font-semibold leading-snug text-balance [overflow-wrap:anywhere]">
|
|
370
|
+
{view.requester
|
|
371
|
+
? fill(messages.clientWantsTo, {
|
|
372
|
+
client: view.requester.name ?? messages.unnamedClient,
|
|
373
|
+
action,
|
|
374
|
+
})
|
|
375
|
+
: fill(messages.agentWantsTo, { action })}
|
|
376
|
+
</h1>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// An address inside text: what `approval-summary.ts` reads as one, minus the
|
|
381
|
+
// quotes and brackets around it in JSON.
|
|
382
|
+
const ADDRESS_IN_TEXT = /[^\s@<>(),;:"'[\]{}]+@[^\s@<>(),;:"'[\]{}]+\.[\w-]+/g;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* `text` with each email address whole: a line may break after an address's
|
|
386
|
+
* "@" and nowhere else in it, so one address never reads as two. The breaks
|
|
387
|
+
* add no characters, so copying the text copies the addresses unchanged.
|
|
388
|
+
*/
|
|
389
|
+
function KeepAddresses({ text }: { text: string }) {
|
|
390
|
+
const parts: ReactNode[] = [];
|
|
391
|
+
let last = 0;
|
|
392
|
+
for (const match of text.matchAll(ADDRESS_IN_TEXT)) {
|
|
393
|
+
const start = match.index;
|
|
394
|
+
const domain = start + match[0].lastIndexOf("@") + 1;
|
|
395
|
+
parts.push(
|
|
396
|
+
text.slice(last, start),
|
|
397
|
+
<span key={start} className="whitespace-nowrap">
|
|
398
|
+
{text.slice(start, domain)}
|
|
399
|
+
</span>,
|
|
400
|
+
<wbr key={`${start}:break`} />,
|
|
401
|
+
<span key={`${start}:domain`} className="whitespace-nowrap">
|
|
402
|
+
{text.slice(domain, start + match[0].length)}
|
|
403
|
+
</span>,
|
|
404
|
+
);
|
|
405
|
+
last = start + match[0].length;
|
|
406
|
+
}
|
|
407
|
+
parts.push(text.slice(last));
|
|
408
|
+
return <>{parts}</>;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readerZone(view: McpApprovalView): string {
|
|
412
|
+
return (
|
|
413
|
+
view.readerTimeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* `at` in the reader's zone; with the call's own `zone`, also the time as the
|
|
419
|
+
* call wrote it when that zone reads differently (`formatRequestedTime`).
|
|
420
|
+
*/
|
|
421
|
+
function ApprovalTime({
|
|
422
|
+
at,
|
|
423
|
+
style,
|
|
424
|
+
zone,
|
|
425
|
+
view,
|
|
426
|
+
messages,
|
|
427
|
+
}: {
|
|
428
|
+
at: number;
|
|
429
|
+
style: TimeStyle;
|
|
430
|
+
zone?: string;
|
|
431
|
+
view: McpApprovalView;
|
|
432
|
+
messages: McpApprovalMessages;
|
|
433
|
+
}) {
|
|
434
|
+
const reader = readerZone(view);
|
|
435
|
+
let text = formatApprovalTime(at, reader, view.locale, style);
|
|
436
|
+
const requested =
|
|
437
|
+
zone && formatRequestedTime(at, zone, reader, view.locale, style);
|
|
438
|
+
if (requested) {
|
|
439
|
+
text += ` (${messages.requestedAs.replace("{time}", requested)})`;
|
|
440
|
+
}
|
|
441
|
+
return <time dateTime={new Date(at).toISOString()}>{text}</time>;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function FieldValue({
|
|
445
|
+
field,
|
|
446
|
+
view,
|
|
447
|
+
messages,
|
|
448
|
+
}: {
|
|
449
|
+
field: McpApprovalField;
|
|
450
|
+
view: McpApprovalView;
|
|
451
|
+
messages: McpApprovalMessages;
|
|
452
|
+
}) {
|
|
453
|
+
if (field.time) {
|
|
454
|
+
return (
|
|
455
|
+
<ApprovalTime
|
|
456
|
+
at={field.time.at}
|
|
457
|
+
style="date-time"
|
|
458
|
+
zone={field.time.zone}
|
|
459
|
+
view={view}
|
|
460
|
+
messages={messages}
|
|
461
|
+
/>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
if (field.wallTime) {
|
|
465
|
+
return (
|
|
466
|
+
<>
|
|
467
|
+
{formatWallTime(field.wallTime.at, view.locale)} (
|
|
468
|
+
{field.wallTime.zone === "calendar"
|
|
469
|
+
? messages.calendarTimeZone
|
|
470
|
+
: messages.noTimeZone}
|
|
471
|
+
)
|
|
472
|
+
</>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return <KeepAddresses text={describeFieldValue(field, view.locale)} />;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
type DecidedState = Exclude<McpApprovalViewState, "pending">;
|
|
479
|
+
|
|
480
|
+
const OUTCOME_ICONS: Record<DecidedState, Icon> = {
|
|
481
|
+
approved: IconCircleCheck,
|
|
482
|
+
used: IconCircleCheck,
|
|
483
|
+
denied: IconBan,
|
|
484
|
+
closed: IconCircleX,
|
|
485
|
+
expired: IconClockOff,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
function Review({
|
|
489
|
+
id,
|
|
490
|
+
view,
|
|
491
|
+
appName,
|
|
492
|
+
messages,
|
|
493
|
+
onDecided,
|
|
494
|
+
onNotFound,
|
|
495
|
+
onOpenApp,
|
|
496
|
+
}: {
|
|
497
|
+
id: string;
|
|
498
|
+
view: McpApprovalView;
|
|
499
|
+
appName: string;
|
|
500
|
+
messages: McpApprovalMessages;
|
|
501
|
+
onDecided: (state: McpApprovalViewState, expiresAt: number) => void;
|
|
502
|
+
onNotFound: () => void;
|
|
503
|
+
onOpenApp: () => void;
|
|
504
|
+
}) {
|
|
505
|
+
const [saving, setSaving] = useState<"approve" | "deny" | null>(null);
|
|
506
|
+
const [problem, setProblem] = useState<DecisionProblem | null>(null);
|
|
507
|
+
const { summary } = view;
|
|
508
|
+
const pending = view.state === "pending";
|
|
509
|
+
|
|
510
|
+
// A lost or unreadable answer doesn't mean the decision wasn't saved; only
|
|
511
|
+
// the grant's own state says.
|
|
512
|
+
const recheck = async () => {
|
|
513
|
+
try {
|
|
514
|
+
const current = await fetchMcpApproval(id);
|
|
515
|
+
if (current.status !== "ready") {
|
|
516
|
+
setProblem("decisionUnconfirmed");
|
|
517
|
+
} else if (current.view.state === "pending") {
|
|
518
|
+
setProblem("saveFailed");
|
|
519
|
+
} else {
|
|
520
|
+
onDecided(current.view.state, current.view.expiresAt);
|
|
521
|
+
}
|
|
522
|
+
} catch {
|
|
523
|
+
setProblem("decisionUnconfirmed");
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const decide = async (decision: "approve" | "deny") => {
|
|
528
|
+
if (!view.csrfToken) return;
|
|
529
|
+
setSaving(decision);
|
|
530
|
+
setProblem(null);
|
|
531
|
+
try {
|
|
532
|
+
const result = await sendMcpApprovalDecision(
|
|
533
|
+
id,
|
|
534
|
+
decision,
|
|
535
|
+
view.csrfToken,
|
|
536
|
+
);
|
|
537
|
+
if (result.status === "decided") {
|
|
538
|
+
onDecided(result.state, result.expiresAt);
|
|
539
|
+
} else if (result.status === "not-found") {
|
|
540
|
+
onNotFound();
|
|
541
|
+
} else {
|
|
542
|
+
setProblem(UNDECIDABLE[result.status]);
|
|
543
|
+
}
|
|
544
|
+
} catch {
|
|
545
|
+
await recheck();
|
|
546
|
+
}
|
|
547
|
+
setSaving(null);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// The token or session behind this page is gone: deciding again here would
|
|
551
|
+
// fail the same way, so the buttons stay off until a reload.
|
|
552
|
+
const stuck =
|
|
553
|
+
problem !== null && Object.values(UNDECIDABLE).includes(problem);
|
|
554
|
+
|
|
555
|
+
return (
|
|
556
|
+
<>
|
|
557
|
+
<div className="p-6">
|
|
558
|
+
<RequestHeading view={view} messages={messages} />
|
|
559
|
+
</div>
|
|
560
|
+
<CardContent className="flex flex-col gap-5">
|
|
561
|
+
{view.state !== "pending" ? (
|
|
562
|
+
<Outcome state={view.state} view={view} messages={messages} />
|
|
563
|
+
) : (
|
|
564
|
+
<>
|
|
565
|
+
{view.priorRun ? (
|
|
566
|
+
<Alert>
|
|
567
|
+
<IconAlertTriangle className="size-4" aria-hidden="true" />
|
|
568
|
+
<AlertDescription>
|
|
569
|
+
{fill(
|
|
570
|
+
view.priorRun.status === "completed"
|
|
571
|
+
? messages.priorCompleted
|
|
572
|
+
: messages.priorUnknown,
|
|
573
|
+
{},
|
|
574
|
+
<ApprovalTime
|
|
575
|
+
at={view.priorRun.at}
|
|
576
|
+
style="time"
|
|
577
|
+
view={view}
|
|
578
|
+
messages={messages}
|
|
579
|
+
/>,
|
|
580
|
+
)}
|
|
581
|
+
</AlertDescription>
|
|
582
|
+
</Alert>
|
|
583
|
+
) : null}
|
|
584
|
+
{summary.cut ? (
|
|
585
|
+
<Alert>
|
|
586
|
+
<IconAlertTriangle className="size-4" aria-hidden="true" />
|
|
587
|
+
<AlertDescription>{messages.argumentsCut}</AlertDescription>
|
|
588
|
+
</Alert>
|
|
589
|
+
) : null}
|
|
590
|
+
<Recipients recipients={summary.recipients} messages={messages} />
|
|
591
|
+
<Facts view={view} messages={messages} />
|
|
592
|
+
<Details view={view} messages={messages} />
|
|
593
|
+
<p className="text-sm text-muted-foreground">
|
|
594
|
+
{fill(
|
|
595
|
+
messages.decideBy,
|
|
596
|
+
{},
|
|
597
|
+
<ApprovalTime
|
|
598
|
+
at={view.expiresAt}
|
|
599
|
+
style="time"
|
|
600
|
+
view={view}
|
|
601
|
+
messages={messages}
|
|
602
|
+
/>,
|
|
603
|
+
)}
|
|
604
|
+
</p>
|
|
605
|
+
</>
|
|
606
|
+
)}
|
|
607
|
+
<p
|
|
608
|
+
role="status"
|
|
609
|
+
className={cn(
|
|
610
|
+
"text-sm",
|
|
611
|
+
problem ? "text-destructive" : "text-muted-foreground",
|
|
612
|
+
!problem && !saving && "sr-only",
|
|
613
|
+
)}
|
|
614
|
+
>
|
|
615
|
+
{problem ? messages[problem] : saving ? messages.saving : ""}
|
|
616
|
+
</p>
|
|
617
|
+
</CardContent>
|
|
618
|
+
<CardFooter className="justify-end gap-2">
|
|
619
|
+
{pending ? (
|
|
620
|
+
<>
|
|
621
|
+
<Button
|
|
622
|
+
variant="outline"
|
|
623
|
+
disabled={saving !== null || stuck}
|
|
624
|
+
onClick={() => void decide("deny")}
|
|
625
|
+
>
|
|
626
|
+
{saving === "deny" ? <Spinner aria-hidden="true" /> : null}
|
|
627
|
+
{messages.deny}
|
|
628
|
+
</Button>
|
|
629
|
+
{/* The chat's approval card's Approve: a theme's primary can be a
|
|
630
|
+
mid gray in dark mode, which reads as disabled. */}
|
|
631
|
+
<Button
|
|
632
|
+
className="bg-foreground text-background hover:bg-foreground/90"
|
|
633
|
+
disabled={saving !== null || stuck}
|
|
634
|
+
onClick={() => void decide("approve")}
|
|
635
|
+
>
|
|
636
|
+
{saving === "approve" ? <Spinner aria-hidden="true" /> : null}
|
|
637
|
+
{messages.approve}
|
|
638
|
+
</Button>
|
|
639
|
+
</>
|
|
640
|
+
) : (
|
|
641
|
+
<Button variant="outline" onClick={onOpenApp}>
|
|
642
|
+
{fill(messages.openApp, { app: appName })}
|
|
643
|
+
</Button>
|
|
644
|
+
)}
|
|
645
|
+
</CardFooter>
|
|
646
|
+
</>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function Outcome({
|
|
651
|
+
state,
|
|
652
|
+
view,
|
|
653
|
+
messages,
|
|
654
|
+
}: {
|
|
655
|
+
state: DecidedState;
|
|
656
|
+
view: McpApprovalView;
|
|
657
|
+
messages: McpApprovalMessages;
|
|
658
|
+
}) {
|
|
659
|
+
const Icon = OUTCOME_ICONS[state];
|
|
660
|
+
return (
|
|
661
|
+
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/40 p-4">
|
|
662
|
+
<Icon
|
|
663
|
+
className={cn(
|
|
664
|
+
"mt-0.5 size-5 shrink-0",
|
|
665
|
+
state === "approved" || state === "used"
|
|
666
|
+
? "text-primary"
|
|
667
|
+
: "text-muted-foreground",
|
|
668
|
+
)}
|
|
669
|
+
aria-hidden="true"
|
|
670
|
+
/>
|
|
671
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
672
|
+
<p className="text-base font-medium leading-relaxed">
|
|
673
|
+
{fill(
|
|
674
|
+
messages[state],
|
|
675
|
+
{},
|
|
676
|
+
<ApprovalTime
|
|
677
|
+
at={view.expiresAt}
|
|
678
|
+
style="time"
|
|
679
|
+
view={view}
|
|
680
|
+
messages={messages}
|
|
681
|
+
/>,
|
|
682
|
+
)}
|
|
683
|
+
</p>
|
|
684
|
+
<RequestLine view={view} messages={messages} />
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const REQUEST_LINE_MAX = 80;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The request in one line, from its first short text and its first time, so
|
|
694
|
+
* a reader with several reviews open knows which this one was. A grant keeps
|
|
695
|
+
* its arguments only while it can still run, so a final one has none.
|
|
696
|
+
*/
|
|
697
|
+
function RequestLine({
|
|
698
|
+
view,
|
|
699
|
+
messages,
|
|
700
|
+
}: {
|
|
701
|
+
view: McpApprovalView;
|
|
702
|
+
messages: McpApprovalMessages;
|
|
703
|
+
}) {
|
|
704
|
+
const { fields } = view.summary;
|
|
705
|
+
const named = fields.find(
|
|
706
|
+
(field) =>
|
|
707
|
+
!field.time &&
|
|
708
|
+
!field.wallTime &&
|
|
709
|
+
field.choice === undefined &&
|
|
710
|
+
field.timeZone === undefined &&
|
|
711
|
+
field.value !== messages.yes &&
|
|
712
|
+
field.value !== messages.no &&
|
|
713
|
+
field.value.length <= REQUEST_LINE_MAX &&
|
|
714
|
+
!field.value.includes("\n"),
|
|
715
|
+
);
|
|
716
|
+
const timed = fields.find((field) => field.time || field.wallTime);
|
|
717
|
+
if (!named && !timed) return null;
|
|
718
|
+
return (
|
|
719
|
+
<p className="text-sm text-muted-foreground [overflow-wrap:anywhere]">
|
|
720
|
+
{named ? <KeepAddresses text={named.value} /> : null}
|
|
721
|
+
{named && timed ? " · " : null}
|
|
722
|
+
{timed?.time ? (
|
|
723
|
+
<ApprovalTime
|
|
724
|
+
at={timed.time.at}
|
|
725
|
+
style="date-time"
|
|
726
|
+
view={view}
|
|
727
|
+
messages={messages}
|
|
728
|
+
/>
|
|
729
|
+
) : timed?.wallTime ? (
|
|
730
|
+
formatWallTime(timed.wallTime.at, view.locale)
|
|
731
|
+
) : null}
|
|
732
|
+
</p>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// A sans font's widest average glyph, with room to spare, and the smallest
|
|
737
|
+
// size an address is still easy to read at.
|
|
738
|
+
const ADDRESS_EM_PER_CHAR = 0.6;
|
|
739
|
+
const ADDRESS_MIN_PX = 11;
|
|
740
|
+
|
|
741
|
+
function Recipients({
|
|
742
|
+
recipients,
|
|
743
|
+
messages,
|
|
744
|
+
}: {
|
|
745
|
+
recipients: string[];
|
|
746
|
+
messages: McpApprovalMessages;
|
|
747
|
+
}) {
|
|
748
|
+
if (recipients.length === 0) return null;
|
|
749
|
+
// An address breaks only after its "@", so the longest name (or domain)
|
|
750
|
+
// has to fit on one line: the list's text shrinks until it does, and only
|
|
751
|
+
// a name too long even at the floor scrolls.
|
|
752
|
+
const longest = Math.max(
|
|
753
|
+
...recipients.flatMap((email) => {
|
|
754
|
+
const domain = email.lastIndexOf("@") + 1;
|
|
755
|
+
return [domain, email.length - domain];
|
|
756
|
+
}),
|
|
757
|
+
);
|
|
758
|
+
const fit = `min(1em, max(${ADDRESS_MIN_PX}px, calc(100cqi / ${(
|
|
759
|
+
longest * ADDRESS_EM_PER_CHAR
|
|
760
|
+
).toFixed(2)})))`;
|
|
761
|
+
return (
|
|
762
|
+
<section className="rounded-lg border border-border p-4">
|
|
763
|
+
<h2 className="text-xs font-medium text-muted-foreground">
|
|
764
|
+
{messages.reaches}
|
|
765
|
+
</h2>
|
|
766
|
+
<ul className="mt-2 flex flex-col gap-2">
|
|
767
|
+
{recipients.map((email) => (
|
|
768
|
+
<li key={email} className="flex min-w-0 items-center gap-2.5">
|
|
769
|
+
<Avatar className="hidden size-7 shrink-0 sm:flex">
|
|
770
|
+
<AvatarFallback className="text-xs">
|
|
771
|
+
{email.charAt(0).toUpperCase()}
|
|
772
|
+
</AvatarFallback>
|
|
773
|
+
</Avatar>
|
|
774
|
+
<div className="@container min-w-0 flex-1 text-sm font-medium sm:text-base">
|
|
775
|
+
<span className="block overflow-x-auto" style={{ fontSize: fit }}>
|
|
776
|
+
<KeepAddresses text={email} />
|
|
777
|
+
</span>
|
|
778
|
+
</div>
|
|
779
|
+
</li>
|
|
780
|
+
))}
|
|
781
|
+
</ul>
|
|
782
|
+
</section>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function Facts({
|
|
787
|
+
view,
|
|
788
|
+
messages,
|
|
789
|
+
}: {
|
|
790
|
+
view: McpApprovalView;
|
|
791
|
+
messages: McpApprovalMessages;
|
|
792
|
+
}) {
|
|
793
|
+
const { summary } = view;
|
|
794
|
+
// The heading names the client; this says when the reader connected it.
|
|
795
|
+
const connectedAt = view.requester?.connectedAt ?? null;
|
|
796
|
+
if (connectedAt === null && summary.fields.length === 0) return null;
|
|
797
|
+
return (
|
|
798
|
+
<dl className="grid grid-cols-1 gap-x-6 text-sm sm:grid-cols-[max-content_minmax(0,1fr)] sm:gap-y-1.5">
|
|
799
|
+
{connectedAt !== null ? (
|
|
800
|
+
<Fact label={messages.connected}>
|
|
801
|
+
<ApprovalTime
|
|
802
|
+
at={connectedAt}
|
|
803
|
+
style="date"
|
|
804
|
+
view={view}
|
|
805
|
+
messages={messages}
|
|
806
|
+
/>
|
|
807
|
+
</Fact>
|
|
808
|
+
) : null}
|
|
809
|
+
{summary.fields.map((field, index) => (
|
|
810
|
+
<Fact key={`${index}:${field.label}`} label={field.label}>
|
|
811
|
+
<FieldValue field={field} view={view} messages={messages} />
|
|
812
|
+
</Fact>
|
|
813
|
+
))}
|
|
814
|
+
</dl>
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function Fact({ label, children }: { label: string; children: ReactNode }) {
|
|
819
|
+
return (
|
|
820
|
+
<>
|
|
821
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
822
|
+
<dd className="mb-2 min-w-0 overflow-x-auto whitespace-pre-wrap [overflow-wrap:anywhere] sm:mb-0">
|
|
823
|
+
{children}
|
|
824
|
+
</dd>
|
|
825
|
+
</>
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function Details({
|
|
830
|
+
view,
|
|
831
|
+
messages,
|
|
832
|
+
}: {
|
|
833
|
+
view: McpApprovalView;
|
|
834
|
+
messages: McpApprovalMessages;
|
|
835
|
+
}) {
|
|
836
|
+
const { summary } = view;
|
|
837
|
+
if (summary.arguments !== "stored") {
|
|
838
|
+
return (
|
|
839
|
+
<p className="text-sm text-muted-foreground">
|
|
840
|
+
{summary.arguments === "not-stored"
|
|
841
|
+
? messages.argumentsNotStored
|
|
842
|
+
: messages.argumentsUnreadable}
|
|
843
|
+
</p>
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
return (
|
|
847
|
+
<Collapsible>
|
|
848
|
+
<CollapsibleTrigger asChild>
|
|
849
|
+
<Button
|
|
850
|
+
variant="ghost"
|
|
851
|
+
size="sm"
|
|
852
|
+
className="group -ms-2 w-fit text-muted-foreground"
|
|
853
|
+
>
|
|
854
|
+
<IconChevronRight
|
|
855
|
+
aria-hidden="true"
|
|
856
|
+
className="transition-transform group-data-[state=open]:rotate-90 rtl:rotate-180 rtl:group-data-[state=open]:rotate-90"
|
|
857
|
+
/>
|
|
858
|
+
{messages.details}
|
|
859
|
+
</Button>
|
|
860
|
+
</CollapsibleTrigger>
|
|
861
|
+
<CollapsibleContent className="mt-2">
|
|
862
|
+
<pre className="max-h-[40vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 font-mono text-xs leading-relaxed [overflow-wrap:anywhere]">
|
|
863
|
+
<KeepAddresses text={summary.details ?? ""} />
|
|
864
|
+
</pre>
|
|
865
|
+
</CollapsibleContent>
|
|
866
|
+
</Collapsible>
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function ReviewNotice({
|
|
871
|
+
loaded,
|
|
872
|
+
appName,
|
|
873
|
+
messages,
|
|
874
|
+
onRetry,
|
|
875
|
+
onOpenApp,
|
|
876
|
+
}: {
|
|
877
|
+
loaded: Exclude<Loaded, { status: "loading" } | { status: "ready" }>;
|
|
878
|
+
appName: string;
|
|
879
|
+
messages: McpApprovalMessages;
|
|
880
|
+
onRetry: () => void;
|
|
881
|
+
onOpenApp: () => void;
|
|
882
|
+
}) {
|
|
883
|
+
const email = useSession().session?.email ?? "";
|
|
884
|
+
const message =
|
|
885
|
+
loaded.status === "failed"
|
|
886
|
+
? messages.loadFailed
|
|
887
|
+
: loaded.status === "signed-out"
|
|
888
|
+
? messages.signInToReview
|
|
889
|
+
: loaded.status === "sign-in-unavailable"
|
|
890
|
+
? messages.signInUnavailable
|
|
891
|
+
: fill(
|
|
892
|
+
loaded.otherOrganization
|
|
893
|
+
? messages.otherOrganization
|
|
894
|
+
: messages.notFound,
|
|
895
|
+
{ email },
|
|
896
|
+
);
|
|
897
|
+
return (
|
|
898
|
+
<>
|
|
899
|
+
<CardContent className="p-6">
|
|
900
|
+
<p className="text-base leading-relaxed">{message}</p>
|
|
901
|
+
</CardContent>
|
|
902
|
+
<CardFooter className="justify-end gap-2">
|
|
903
|
+
{loaded.status === "failed" ? (
|
|
904
|
+
<Button onClick={onRetry}>{messages.tryAgain}</Button>
|
|
905
|
+
) : (
|
|
906
|
+
<Button variant="outline" onClick={onOpenApp}>
|
|
907
|
+
{fill(messages.openApp, { app: appName })}
|
|
908
|
+
</Button>
|
|
909
|
+
)}
|
|
910
|
+
</CardFooter>
|
|
911
|
+
</>
|
|
912
|
+
);
|
|
913
|
+
}
|