authorgravatar for maria@kuuro.netmaria-rcks <maria@kuuro.net> 2026-07-23 01:02:48-04:00
committergravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-07-23 01:13:29-04:00
logb44ed835c63caafae619e303ca019a251c2698b2
treefe3e3ae53d13c9c15d0a42aaa92a78a5fc82b57e
parent9c9916aefe18586b305e1d973d609e60bd6de4c0

fix(web): sync provider banner dismissal


3 files changed, 70 insertions(+), 27 deletions(-)

apps/web/src/components/ChatView.tsx+25-7
......@@ -213,7 +213,11 @@ import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayo
213213import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
214214import { NoActiveThreadState } from "./NoActiveThreadState";
215215import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
216import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
216import {
217 getProviderStatusBannerKey,
218 ProviderStatusBanner,
219 shouldShowProviderStatusBanner,
220} from "./chat/ProviderStatusBanner";
217221import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
218222import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
219223import {
......@@ -2320,11 +2324,22 @@ function ChatViewContent(props: ChatViewProps) {
23202324 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
23212325 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
23222326 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
2323 const hasTimelineTopBanner =
2324 Boolean(threadError) ||
2325 (activeProviderStatus !== null &&
2326 activeProviderStatus.status !== "ready" &&
2327 activeProviderStatus.status !== "disabled");
2327 const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
2328 const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
2329 string | null
2330 >(null);
2331 useEffect(() => {
2332 if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) {
2333 setDismissedProviderStatusBannerKey(null);
2334 }
2335 }, [dismissedProviderStatusBannerKey, providerStatusBannerKey]);
2336 const visibleProviderStatus = shouldShowProviderStatusBanner(
2337 activeProviderStatus,
2338 dismissedProviderStatusBannerKey,
2339 )
2340 ? activeProviderStatus
2341 : null;
2342 const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null;
23282343 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
23292344 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
23302345 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
......@@ -5463,7 +5478,10 @@ function ChatViewContent(props: ChatViewProps) {
54635478 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
54645479 {/* Provider status overlays the timeline without changing its content height. */}
54655480 <div className="pointer-events-none absolute inset-x-0 top-0 z-20">
5466 <ProviderStatusBanner status={activeProviderStatus} />
5481 <ProviderStatusBanner
5482 status={visibleProviderStatus}
5483 onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
5484 />
54675485 </div>
54685486 {/* Messages Wrapper */}
54695487 <div className="relative flex min-h-0 flex-1 flex-col">
apps/web/src/components/chat/ProviderStatusBanner.test.tsx+26-2
......@@ -2,7 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3
22import { renderToStaticMarkup } from "react-dom/server";
33import { describe, expect, it } from "vite-plus/test";
44
5import { ProviderStatusBanner } from "./ProviderStatusBanner";
5import {
6 getProviderStatusBannerKey,
7 ProviderStatusBanner,
8 shouldShowProviderStatusBanner,
9} from "./ProviderStatusBanner";
610
711function warningProvider(): ServerProvider {
812 return {
......@@ -23,11 +27,31 @@ function warningProvider(): ServerProvider {
2327}
2428
2529describe("ProviderStatusBanner", () => {
30 it("stays hidden after its current warning is dismissed", () => {
31 const status = warningProvider();
32
33 expect(shouldShowProviderStatusBanner(status, null)).toBe(true);
34 expect(shouldShowProviderStatusBanner(status, getProviderStatusBannerKey(status))).toBe(false);
35 });
36
2637 it("renders an accessible dismiss control for provider warnings", () => {
27 const markup = renderToStaticMarkup(<ProviderStatusBanner status={warningProvider()} />);
38 const markup = renderToStaticMarkup(
39 <ProviderStatusBanner status={warningProvider()} onDismiss={() => {}} />,
40 );
2841
2942 expect(markup).toContain('role="alert"');
3043 expect(markup).toContain('aria-label="Dismiss Codex provider warning"');
3144 expect(markup).toContain("absolute top-2 right-2");
3245 });
46
47 it("labels error dismiss controls with the correct severity", () => {
48 const markup = renderToStaticMarkup(
49 <ProviderStatusBanner
50 status={{ ...warningProvider(), status: "error" }}
51 onDismiss={() => {}}
52 />,
53 );
54
55 expect(markup).toContain('aria-label="Dismiss Codex provider error"');
56 });
3357});
apps/web/src/components/chat/ProviderStatusBanner.tsx+19-18
......@@ -1,33 +1,34 @@
11import { type ServerProvider } from "@t3tools/contracts";
2import { memo, useEffect, useState } from "react";
2import { memo } from "react";
33import { InfoIcon, XIcon } from "lucide-react";
44import { cn } from "~/lib/utils";
55import { formatProviderDriverKindLabel } from "../../providerModels";
66import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
77
8export function getProviderStatusBannerKey(status: ServerProvider | null): string | null {
9 return !status || status.status === "ready" || status.status === "disabled"
10 ? null
11 : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000");
12}
13
14export function shouldShowProviderStatusBanner(
15 status: ServerProvider | null,
16 dismissedBannerKey: string | null,
17): boolean {
18 const bannerKey = getProviderStatusBannerKey(status);
19 return bannerKey !== null && bannerKey !== dismissedBannerKey;
20}
21
822export const ProviderStatusBanner = memo(function ProviderStatusBanner({
23 onDismiss,
924 status,
1025}: {
26 onDismiss: () => void;
1127 status: ServerProvider | null;
1228}) {
13 const bannerKey =
14 !status || status.status === "ready" || status.status === "disabled"
15 ? null
16 : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000");
17 const [dismissedBannerKey, setDismissedBannerKey] = useState<string | null>(null);
18
19 useEffect(() => {
20 if (bannerKey === null && dismissedBannerKey !== null) {
21 setDismissedBannerKey(null);
22 }
23 }, [bannerKey, dismissedBannerKey]);
24
2529 if (!status || status.status === "ready" || status.status === "disabled") {
2630 return null;
2731 }
28 if (bannerKey === dismissedBannerKey) {
29 return null;
30 }
3132
3233 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
3334 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
......@@ -66,9 +67,9 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
6667 </div>
6768 <button
6869 type="button"
69 aria-label={`Dismiss ${providerName} provider warning`}
70 aria-label={`Dismiss ${providerName} provider ${status.status}`}
7071 className="absolute top-2 right-2 inline-flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-foreground/8 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
71 onClick={() => setDismissedBannerKey(bannerKey)}
72 onClick={onDismiss}
7273 >
7374 <XIcon aria-hidden className="size-3.5" />
7475 </button>