| 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,7 +213,11 @@ import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayo |
| 213 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 213 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 214 | import { NoActiveThreadState } from "./NoActiveThreadState"; | 214 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 215 | import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; | 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 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 221 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 218 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 222 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 219 | import { | 223 | import { |
| ... | @@ -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 |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 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 | function warningProvider(): ServerProvider { | 11 | function warningProvider(): ServerProvider { |
| 8 | return { | 12 | return { |
| ... | @@ -23,11 +27,31 @@ function warningProvider(): ServerProvider { | ... | @@ -23,11 +27,31 @@ function warningProvider(): ServerProvider { |
| 23 | } | 27 | } |
| 24 | 28 | ||
| 25 | describe("ProviderStatusBanner", () => { | 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 | 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 | ); | ||
| 28 | 41 | ||
| 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 @@ |
| 1 | import { type ServerProvider } from "@t3tools/contracts"; | 1 | import { type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { memo, useEffect, useState } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { InfoIcon, XIcon } from "lucide-react"; | 3 | import { InfoIcon, XIcon } from "lucide-react"; |
| 4 | import { cn } from "~/lib/utils"; | 4 | import { cn } from "~/lib/utils"; |
| 5 | import { formatProviderDriverKindLabel } from "../../providerModels"; | 5 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 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 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ | 22 | export 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 | } | ||
| 31 | 32 | ||
| 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 | <button | 68 | <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> |