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 && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ );
+});