| author | |
| committer | |
| log | b44ed835c63caafae619e303ca019a251c2698b2 |
| tree | fe3e3ae53d13c9c15d0a42aaa92a78a5fc82b57e |
| parent | 9c9916aefe18586b305e1d973d609e60bd6de4c0 |
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 | 213 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 214 | 214 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 215 | 215 | import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 216 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | |
| 216 | import { | |
| 217 | getProviderStatusBannerKey, | |
| 218 | ProviderStatusBanner, | |
| 219 | shouldShowProviderStatusBanner, | |
| 220 | } from "./chat/ProviderStatusBanner"; | |
| 217 | 221 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 218 | 222 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 219 | 223 | import { |
| ... | ... | @@ -2320,11 +2324,22 @@ function ChatViewContent(props: ChatViewProps) { |
| 2320 | 2324 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); |
| 2321 | 2325 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; |
| 2322 | 2326 | }, [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; | |
| 2328 | 2343 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 2329 | 2344 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; |
| 2330 | 2345 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; |
| ... | ... | @@ -5463,7 +5478,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 5463 | 5478 | <div className="relative flex min-h-0 min-w-0 flex-1 flex-col"> |
| 5464 | 5479 | {/* Provider status overlays the timeline without changing its content height. */} |
| 5465 | 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 | 5485 | </div> |
| 5468 | 5486 | {/* Messages Wrapper */} |
| 5469 | 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 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { ProviderStatusBanner } from "./ProviderStatusBanner"; | |
| 5 | import { | |
| 6 | getProviderStatusBannerKey, | |
| 7 | ProviderStatusBanner, | |
| 8 | shouldShowProviderStatusBanner, | |
| 9 | } from "./ProviderStatusBanner"; | |
| 6 | 10 | |
| 7 | 11 | function warningProvider(): ServerProvider { |
| 8 | 12 | return { |
| ... | ... | @@ -23,11 +27,31 @@ function warningProvider(): ServerProvider { |
| 23 | 27 | } |
| 24 | 28 | |
| 25 | 29 | describe("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 | 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 | ); | |
| 28 | 41 | |
| 29 | 42 | expect(markup).toContain('role="alert"'); |
| 30 | 43 | expect(markup).toContain('aria-label="Dismiss Codex provider warning"'); |
| 31 | 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 | 1 | import { type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { memo, useEffect, useState } from "react"; | |
| 2 | import { memo } from "react"; | |
| 3 | 3 | import { InfoIcon, XIcon } from "lucide-react"; |
| 4 | 4 | import { cn } from "~/lib/utils"; |
| 5 | 5 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 6 | 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | 7 | |
| 8 | export 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 | ||
| 14 | export 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 | ||
| 8 | 22 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 23 | onDismiss, | |
| 9 | 24 | status, |
| 10 | 25 | }: { |
| 26 | onDismiss: () => void; | |
| 11 | 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 | 29 | if (!status || status.status === "ready" || status.status === "disabled") { |
| 26 | 30 | return null; |
| 27 | 31 | } |
| 28 | if (bannerKey === dismissedBannerKey) { | |
| 29 | return null; | |
| 30 | } | |
| 31 | 32 | |
| 32 | 33 | const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); |
| 33 | 34 | const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; |
| ... | ... | @@ -66,9 +67,9 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 66 | 67 | </div> |
| 67 | 68 | <button |
| 68 | 69 | type="button" |
| 69 | aria-label={`Dismiss ${providerName} provider warning`} | |
| 70 | aria-label={`Dismiss ${providerName} provider ${status.status}`} | |
| 70 | 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 | 74 | <XIcon aria-hidden className="size-3.5" /> |
| 74 | 75 | </button> |