Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3367,6 +3367,7 @@ export default function ChatView(props: ChatViewProps) {
<ThreadFind onClose={focusComposerStable} />
{isWorkspaceFileDragActive ? <WorkspaceFileDropOverlay /> : null}
<ChatBannerLane
environmentId={activeThread.environmentId}
providerStatus={visibleProviderStatus}
providerStatusBannerKey={providerStatusBannerKey}
setDismissedProviderStatusBannerKey={setDismissedProviderStatusBannerKey}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ChatBannerLane.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
EnvironmentId,
OrchestrationV2ProviderFailureClass,
ProviderInstanceId,
ServerProvider,
Expand All @@ -13,6 +14,7 @@ import { dismissThreadErrorBannerForSession, ThreadErrorBanner } from "./ThreadE
* values, so a ChatView render that changes neither banner leaves both cached.
*/
export function ChatBannerLane(props: {
environmentId: EnvironmentId;
providerStatus: ServerProvider | null;
providerStatusBannerKey: string | null;
setDismissedProviderStatusBannerKey: Dispatch<SetStateAction<string | null>>;
Expand Down Expand Up @@ -41,6 +43,7 @@ export function ChatBannerLane(props: {
return (
<div className="chat-banner-lane pointer-events-none absolute top-0 z-30 flex flex-col">
<ProviderStatusBanner
environmentId={props.environmentId}
status={providerStatus}
onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
onOpenProviderSetup={onOpenProviderSetup}
Expand Down
69 changes: 56 additions & 13 deletions apps/web/src/components/chat/ProviderStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -121,17 +150,31 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
<InfoIcon />
<AlertTitle>{title}</AlertTitle>
<AlertDescription>
<Tooltip>
<TooltipTrigger render={<div className="line-clamp-3" />}>{message}</TooltipTrigger>
<TooltipPopup side="top" className="whitespace-pre-wrap">
{message}
</TooltipPopup>
</Tooltip>
{onOpenProviderSetup && hasProviderSetup(status) ? (
<InlineButton onClick={() => onOpenProviderSetup(status.instanceId)}>
Open provider setup
</InlineButton>
) : null}
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger render={<div className="line-clamp-3" />}>{message}</TooltipTrigger>
<TooltipPopup side="top" className="whitespace-pre-wrap">
{message}
</TooltipPopup>
</Tooltip>
<div className="flex flex-wrap items-center justify-end gap-3">
{onOpenProviderSetup && hasProviderSetup(status) ? (
<InlineButton onClick={() => onOpenProviderSetup(status.instanceId)}>
Open provider setup
</InlineButton>
) : null}
<Button
aria-label={`Refresh ${providerName} provider status`}
disabled={!canRefresh || isRefreshing}
onClick={() => void refresh()}
size="xs"
variant="outline"
>
<RefreshIcon />
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
</div>
</div>
</AlertDescription>
<AlertAction>
<Button
Expand Down
34 changes: 34 additions & 0 deletions packages/client-runtime/src/state/commandPermissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,40 @@ const setup = Effect.gen(function* () {
});

describe("command permissions", () => {
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* () {
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/clientRpcPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading