diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9c763386..14bda316 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -204,10 +204,8 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; -import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; -import { CompanionConnectionBanner } from "./chat/CompanionConnectionBanner"; import { MobileThreadAttentionBar } from "./chat/MobileThreadAttentionBar"; -import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { ErrorNotificationBar } from "./chat/ErrorNotificationBar"; import { buildAutoSelectedWorktreeBaseBranchToastCopy, buildHiddenProviderInput, @@ -4743,17 +4741,14 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { /> - {/* Error banner */} - {isMobileCompanion ? ( -
- -
- ) : null} - - setThreadError(activeThread.id, null)} + onDismissThreadError={() => setThreadError(activeThread.id, null)} + providerStatus={activeProviderStatus} + transportState={transportState} + isMobileCompanion={isMobileCompanion} /> {/* Main content area with optional plan sidebar */}
diff --git a/apps/web/src/components/chat/ErrorNotificationBar.tsx b/apps/web/src/components/chat/ErrorNotificationBar.tsx new file mode 100644 index 00000000..79ce25dd --- /dev/null +++ b/apps/web/src/components/chat/ErrorNotificationBar.tsx @@ -0,0 +1,332 @@ +import { memo, useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { + CircleAlertIcon, + ChevronDownIcon, + ChevronUpIcon, + XIcon, + WifiOffIcon, + WifiIcon, +} from "lucide-react"; +import { type ServerProviderStatus } from "@okcode/contracts"; +import type { TransportState } from "../../wsTransport"; +import { humanizeThreadError, isAuthenticationThreadError } from "./threadError"; +import { + getProviderStatusHeading, + getProviderStatusDescription, +} from "./providerStatusPresentation"; +import { cn } from "~/lib/utils"; + +interface ErrorNotificationBarProps { + /** Thread error string (from activeThread.error) */ + threadError: string | null; + /** Whether to show auth failures as errors */ + showAuthFailuresAsErrors?: boolean; + /** Dismiss the thread error */ + onDismissThreadError?: () => void; + /** Provider health status */ + providerStatus: ServerProviderStatus | null; + /** Companion transport state (only relevant for mobile companion) */ + transportState?: TransportState; + /** Whether this is a mobile companion */ + isMobileCompanion?: boolean; +} + +interface NotificationItem { + id: string; + icon: React.ElementType; + title: string; + description: string; + technicalDetails?: string | null; + severity: "error" | "warning" | "info"; + dismissible: boolean; + onDismiss?: () => void; +} + +export const ErrorNotificationBar = memo(function ErrorNotificationBar({ + threadError, + showAuthFailuresAsErrors = true, + onDismissThreadError, + providerStatus, + transportState, + isMobileCompanion, +}: ErrorNotificationBarProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [dismissedIds, setDismissedIds] = useState>(new Set()); + + // Track which notification IDs are currently active so we can + // re-show a notification if the error clears and returns. + const prevActiveIdsRef = useRef>(new Set()); + + const notifications = useMemo(() => { + const items: NotificationItem[] = []; + + // Connection status notification (mobile companion only) + if (isMobileCompanion && transportState && transportState !== "open") { + const connectionNotif: NotificationItem = + transportState === "reconnecting" + ? { + id: "connection", + icon: WifiOffIcon, + title: "Reconnecting to OK Code", + description: "Trying to restore the remote session.", + severity: "warning", + dismissible: false, + } + : transportState === "closed" + ? { + id: "connection", + icon: WifiOffIcon, + title: "Disconnected from OK Code", + description: "The remote server is unavailable.", + severity: "error", + dismissible: false, + } + : { + id: "connection", + icon: WifiIcon, + title: "Connecting to OK Code", + description: "Establishing the remote session connection.", + severity: "info", + dismissible: false, + }; + items.push(connectionNotif); + } + + // Provider health notification + if (providerStatus && providerStatus.status !== "ready") { + const title = getProviderStatusHeading(providerStatus); + const description = getProviderStatusDescription(providerStatus); + items.push({ + id: "provider", + icon: CircleAlertIcon, + title, + description, + severity: providerStatus.status === "error" ? "error" : "warning", + dismissible: false, + }); + } + + // Thread error notification + if (threadError) { + if (showAuthFailuresAsErrors || !isAuthenticationThreadError(threadError)) { + const presentation = humanizeThreadError(threadError); + items.push({ + id: "thread-error", + icon: CircleAlertIcon, + title: presentation.title ?? "Error", + description: presentation.description, + technicalDetails: presentation.technicalDetails, + severity: "error", + dismissible: !!onDismissThreadError, + onDismiss: onDismissThreadError, + }); + } + } + + return items; + }, [ + threadError, + showAuthFailuresAsErrors, + onDismissThreadError, + providerStatus, + transportState, + isMobileCompanion, + ]); + + // When an error clears and a new one appears for the same source, + // un-dismiss it so the user sees the new error. + useEffect(() => { + const currentIds = new Set(notifications.map((n) => n.id)); + const prevIds = prevActiveIdsRef.current; + + // Find IDs that were absent last render but are now present (re-appeared) + const reappeared = new Set(); + for (const id of currentIds) { + if (!prevIds.has(id)) { + reappeared.add(id); + } + } + + if (reappeared.size > 0) { + setDismissedIds((prev) => { + const next = new Set(prev); + for (const id of reappeared) { + next.delete(id); + } + return next.size === prev.size ? prev : next; + }); + } + + // Collapse expanded view when all errors resolve + if (currentIds.size === 0 && isExpanded) { + setIsExpanded(false); + } + + prevActiveIdsRef.current = currentIds; + }, [notifications, isExpanded]); + + const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); + + const handleDismiss = useCallback( + (notif: NotificationItem) => { + if (notif.onDismiss) { + notif.onDismiss(); + } + setDismissedIds((prev) => new Set(prev).add(notif.id)); + }, + [], + ); + + const handleDismissAll = useCallback(() => { + for (const notif of visibleNotifications) { + if (notif.dismissible && notif.onDismiss) { + notif.onDismiss(); + } + } + setDismissedIds(new Set(notifications.map((n) => n.id))); + }, [visibleNotifications, notifications]); + + // Nothing to show + if (visibleNotifications.length === 0) return null; + + const primary = visibleNotifications[0]!; + const PrimaryIcon = primary.icon; + const count = visibleNotifications.length; + const hasMultiple = count > 1; + + const severityColor = { + error: "text-destructive", + warning: "text-warning", + info: "text-info", + } as const; + + const severityBg = { + error: "bg-destructive/6 border-destructive/20", + warning: "bg-warning/6 border-warning/20", + info: "bg-info/6 border-info/20", + } as const; + + // Find the highest severity across all notifications + const highestSeverity = visibleNotifications.reduce<"error" | "warning" | "info">( + (acc, n) => { + if (acc === "error" || n.severity === "error") return "error"; + if (acc === "warning" || n.severity === "warning") return "warning"; + return "info"; + }, + "info", + ); + + return ( +
+
+ {/* Collapsed bar - always visible */} +
+ + + + {primary.title !== "Error" ? ( + <> + {primary.title} + — {primary.description} + + ) : ( + {primary.description} + )} + + +
+ {/* Count badge */} + {hasMultiple && ( + + {count} + + )} + + {/* Expand/collapse toggle */} + {(hasMultiple || primary.technicalDetails) && ( + + )} + + {/* Dismiss button */} + +
+
+ + {/* Expanded view */} + {isExpanded && ( +
+ {visibleNotifications.map((notif) => { + const Icon = notif.icon; + return ( +
+ +
+

{notif.title}

+

{notif.description}

+ {notif.technicalDetails && ( +
+ + Technical details + +
+                          {notif.technicalDetails}
+                        
+
+ )} +
+ {notif.dismissible && ( + + )} +
+ ); + })} +
+ )} +
+
+ ); +});