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...@@ -213,7 +213,11 @@ import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayo
213import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";213import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
214import { NoActiveThreadState } from "./NoActiveThreadState";214import { NoActiveThreadState } from "./NoActiveThreadState";
215import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";215import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
216import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";216import {
217 getProviderStatusBannerKey,
218 ProviderStatusBanner,
219 shouldShowProviderStatusBanner,
220} from "./chat/ProviderStatusBanner";
217import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";221import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
218import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";222import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
219import {223import {
...@@ -2320,11 +2324,22 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2320,11 +2324,22 @@ function ChatViewContent(props: ChatViewProps) {
2320 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);2324 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
2321 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;2325 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
2322 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);2326 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
2323 const hasTimelineTopBanner =2327 const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
2324 Boolean(threadError) ||2328 const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
2325 (activeProviderStatus !== null &&2329 string | null
2326 activeProviderStatus.status !== "ready" &&2330 >(null);
2327 activeProviderStatus.status !== "disabled");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;
2328 const activeProjectCwd = activeProject?.workspaceRoot ?? null;2343 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
2329 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;2344 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
2330 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;2345 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
...@@ -5463,7 +5478,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5463,7 +5478,10 @@ function ChatViewContent(props: ChatViewProps) {
5463 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">5478 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
5464 {/* Provider status overlays the timeline without changing its content height. */}5479 {/* Provider status overlays the timeline without changing its content height. */}
5465 <div className="pointer-events-none absolute inset-x-0 top-0 z-20">5480 <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 />
5467 </div>5485 </div>
5468 {/* Messages Wrapper */}5486 {/* Messages Wrapper */}
5469 <div className="relative flex min-h-0 flex-1 flex-col">5487 <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...@@ -2,7 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { ProviderStatusBanner } from "./ProviderStatusBanner";5import {
6 getProviderStatusBannerKey,
7 ProviderStatusBanner,
8 shouldShowProviderStatusBanner,
9} from "./ProviderStatusBanner";
610
7function warningProvider(): ServerProvider {11function warningProvider(): ServerProvider {
8 return {12 return {
...@@ -23,11 +27,31 @@ function warningProvider(): ServerProvider {...@@ -23,11 +27,31 @@ function warningProvider(): ServerProvider {
23}27}
2428
25describe("ProviderStatusBanner", () => {29describe("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
26 it("renders an accessible dismiss control for provider warnings", () => {37 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
29 expect(markup).toContain('role="alert"');42 expect(markup).toContain('role="alert"');
30 expect(markup).toContain('aria-label="Dismiss Codex provider warning"');43 expect(markup).toContain('aria-label="Dismiss Codex provider warning"');
31 expect(markup).toContain("absolute top-2 right-2");44 expect(markup).toContain("absolute top-2 right-2");
32 });45 });
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 });
33});57});
apps/web/src/components/chat/ProviderStatusBanner.tsx+19-18
...@@ -1,33 +1,34 @@...@@ -1,33 +1,34 @@
1import { type ServerProvider } from "@t3tools/contracts";1import { type ServerProvider } from "@t3tools/contracts";
2import { memo, useEffect, useState } from "react";2import { memo } from "react";
3import { InfoIcon, XIcon } from "lucide-react";3import { InfoIcon, XIcon } from "lucide-react";
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { formatProviderDriverKindLabel } from "../../providerModels";5import { formatProviderDriverKindLabel } from "../../providerModels";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";6import { 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
8export const ProviderStatusBanner = memo(function ProviderStatusBanner({22export const ProviderStatusBanner = memo(function ProviderStatusBanner({
23 onDismiss,
9 status,24 status,
10}: {25}: {
26 onDismiss: () => void;
11 status: ServerProvider | null;27 status: ServerProvider | null;
12}) {28}) {
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
25 if (!status || status.status === "ready" || status.status === "disabled") {29 if (!status || status.status === "ready" || status.status === "disabled") {
26 return null;30 return null;
27 }31 }
28 if (bannerKey === dismissedBannerKey) {
29 return null;
30 }
3132
32 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);33 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
33 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";34 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
...@@ -66,9 +67,9 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({...@@ -66,9 +67,9 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
66 </div>67 </div>
67 <button68 <button
68 type="button"69 type="button"
69 aria-label={`Dismiss ${providerName} provider warning`}70 aria-label={`Dismiss ${providerName} provider ${status.status}`}
70 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 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}
72 >73 >
73 <XIcon aria-hidden className="size-3.5" />74 <XIcon aria-hidden className="size-3.5" />
74 </button>75 </button>