diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 87520c6f84faee7a57971f59fb789df21cf36874..957a14d6e8d08e4315139ff527969a1e211ff897 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -213,7 +213,11 @@ import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayo
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { NoActiveThreadState } from "./NoActiveThreadState";
import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
-import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
+import {
+ getProviderStatusBannerKey,
+ ProviderStatusBanner,
+ shouldShowProviderStatusBanner,
+} from "./chat/ProviderStatusBanner";
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
import {
@@ -2320,11 +2324,22 @@ function ChatViewContent(props: ChatViewProps) {
const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
}, [activeProviderInstanceId, providerStatuses, selectedProvider]);
- const hasTimelineTopBanner =
- Boolean(threadError) ||
- (activeProviderStatus !== null &&
- activeProviderStatus.status !== "ready" &&
- activeProviderStatus.status !== "disabled");
+ const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
+ const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
+ string | null
+ >(null);
+ useEffect(() => {
+ if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) {
+ setDismissedProviderStatusBannerKey(null);
+ }
+ }, [dismissedProviderStatusBannerKey, providerStatusBannerKey]);
+ const visibleProviderStatus = shouldShowProviderStatusBanner(
+ activeProviderStatus,
+ dismissedProviderStatusBannerKey,
+ )
+ ? activeProviderStatus
+ : null;
+ const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null;
const activeProjectCwd = activeProject?.workspaceRoot ?? null;
const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
@@ -5463,7 +5478,10 @@ function ChatViewContent(props: ChatViewProps) {
{/* Provider status overlays the timeline without changing its content height. */}
-
+
setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
+ />
{/* Messages Wrapper */}
diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx
index 2bd217bfe42e19a942a894293418a62341b30d6f..2a5bfe9ed3a893d4f180e55f290ceda9d06a401e 100644
--- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx
+++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx
@@ -2,7 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
-import { ProviderStatusBanner } from "./ProviderStatusBanner";
+import {
+ getProviderStatusBannerKey,
+ ProviderStatusBanner,
+ shouldShowProviderStatusBanner,
+} from "./ProviderStatusBanner";
function warningProvider(): ServerProvider {
return {
@@ -23,11 +27,31 @@ function warningProvider(): ServerProvider {
}
describe("ProviderStatusBanner", () => {
+ it("stays hidden after its current warning is dismissed", () => {
+ const status = warningProvider();
+
+ expect(shouldShowProviderStatusBanner(status, null)).toBe(true);
+ expect(shouldShowProviderStatusBanner(status, getProviderStatusBannerKey(status))).toBe(false);
+ });
+
it("renders an accessible dismiss control for provider warnings", () => {
- const markup = renderToStaticMarkup(
);
+ const markup = renderToStaticMarkup(
+
{}} />,
+ );
expect(markup).toContain('role="alert"');
expect(markup).toContain('aria-label="Dismiss Codex provider warning"');
expect(markup).toContain("absolute top-2 right-2");
});
+
+ it("labels error dismiss controls with the correct severity", () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ />,
+ );
+
+ expect(markup).toContain('aria-label="Dismiss Codex provider error"');
+ });
});
diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx
index a900129aace7c6061a092debe4ad7886d22cca61..a64a94bc1c38bba4def7d3665fd9b1032f31a36c 100644
--- a/apps/web/src/components/chat/ProviderStatusBanner.tsx
+++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx
@@ -1,33 +1,34 @@
import { type ServerProvider } from "@t3tools/contracts";
-import { memo, useEffect, useState } from "react";
+import { memo } from "react";
import { InfoIcon, XIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { formatProviderDriverKindLabel } from "../../providerModels";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+export function getProviderStatusBannerKey(status: ServerProvider | null): string | null {
+ return !status || status.status === "ready" || status.status === "disabled"
+ ? null
+ : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000");
+}
+
+export function shouldShowProviderStatusBanner(
+ status: ServerProvider | null,
+ dismissedBannerKey: string | null,
+): boolean {
+ const bannerKey = getProviderStatusBannerKey(status);
+ return bannerKey !== null && bannerKey !== dismissedBannerKey;
+}
+
export const ProviderStatusBanner = memo(function ProviderStatusBanner({
+ onDismiss,
status,
}: {
+ onDismiss: () => void;
status: ServerProvider | null;
}) {
- const bannerKey =
- !status || status.status === "ready" || status.status === "disabled"
- ? null
- : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000");
- const [dismissedBannerKey, setDismissedBannerKey] = useState(null);
-
- useEffect(() => {
- if (bannerKey === null && dismissedBannerKey !== null) {
- setDismissedBannerKey(null);
- }
- }, [bannerKey, dismissedBannerKey]);
-
if (!status || status.status === "ready" || status.status === "disabled") {
return null;
}
- if (bannerKey === dismissedBannerKey) {
- return null;
- }
const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
@@ -66,9 +67,9 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({