diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6ec89f8c3625..497ccfc87777 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3367,6 +3367,7 @@ export default function ChatView(props: ChatViewProps) { {isWorkspaceFileDragActive ? : null} >; @@ -41,6 +43,7 @@ export function ChatBannerLane(props: { return ( setDismissedProviderStatusBannerKey(providerStatusBannerKey)} onOpenProviderSetup={onOpenProviderSetup} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 0e258c10906a..64afa55ed0d7 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,10 +1,18 @@ -import { type ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { memo } from "react"; +import { + type EnvironmentId, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { memo, useRef, useState } from "react"; import { InfoIcon, XIcon } from "lucide-react"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; import { Button, InlineButton } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { RefreshIcon } from "../ui/refresh-icon"; function getIncompatibleVersion(status: ServerProvider) { const compatibility = status.compatibilityAdvisory; @@ -86,14 +94,35 @@ export function getProviderStatusMessage(status: ServerProvider): string { } export const ProviderStatusBanner = memo(function ProviderStatusBanner({ + environmentId, onDismiss, onOpenProviderSetup, status, }: { + environmentId: EnvironmentId; onDismiss: () => void; onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; status: ServerProvider | null; }) { + const canRefresh = useAtomValue(serverEnvironment.refreshProviders.permissionAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + label: "Refresh provider status", + }); + const refreshingRef = useRef(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refresh = async () => { + if (!status || !canRefresh || refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshing(true); + await refreshProviders({ + environmentId, + input: { instanceId: status.instanceId, refreshModels: true }, + }); + refreshingRef.current = false; + setIsRefreshing(false); + }; + if (!status || getProviderStatusBannerKey(status) === null) { return null; } @@ -121,17 +150,31 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ {title} - - }>{message} - - {message} - - - {onOpenProviderSetup && hasProviderSetup(status) ? ( - onOpenProviderSetup(status.instanceId)}> - Open provider setup - - ) : null} + + + }>{message} + + {message} + + + + {onOpenProviderSetup && hasProviderSetup(status) ? ( + onOpenProviderSetup(status.instanceId)}> + Open provider setup + + ) : null} + void refresh()} + size="xs" + variant="outline" + > + + {isRefreshing ? "Refreshing..." : "Refresh"} + + + { + it.effect("requires the destination read grant to refresh providers", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const refresh = createCommandPermissions(runtime, WS_METHODS.serverRefreshProviders); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(refresh.permissionAtom(env))).toBe(false); + expect((yield* refresh.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthOrchestrationReadScope], + permissions: [AuthOrchestrationReadScope], + }), + ); + expect(registry.get(refresh.permissionAtom(env))).toBe(true); + yield* refresh.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(refresh.permissionAtom(other))).toBe(false); + expect((yield* refresh.authorize(registry, other).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set(sessions(env), AsyncResult.success(grant(false))); + expect(registry.get(refresh.permissionAtom(env))).toBe(false); + expect((yield* refresh.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + }), + ), + ); + it.effect("requires the destination preview grant to close a browser tab", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index c2488226191d..91db78715551 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -20,6 +20,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.previewStopLocalServer]: AuthTerminalOperateScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, + [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,