diff --git a/apps/desktop/src/renderer/components/CreateLinkModal.tsx b/apps/desktop/src/renderer/components/CreateLinkModal.tsx index e5389060..7937645d 100644 --- a/apps/desktop/src/renderer/components/CreateLinkModal.tsx +++ b/apps/desktop/src/renderer/components/CreateLinkModal.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import { X, Copy, Check, Loader2, Link2, Monitor, Users, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ErrorBanner } from '@/components/ui/error-banner'; import { getElectronAPI } from '@/lib/ipc'; import type { Session, SessionMode } from '@pairux/shared-types'; import { APP_URL } from '../../shared/config'; @@ -105,16 +106,13 @@ export function CreateLinkModal({ isOpen, onClose, onStartSharing }: CreateLinkM {error && ( -
- {error} - -
+ void createSession()} + onDismiss={() => { + setError(null); + }} + /> )} {isCreating && ( diff --git a/apps/desktop/src/renderer/components/StartMeetingModal.tsx b/apps/desktop/src/renderer/components/StartMeetingModal.tsx index 2f43caa3..630a48d5 100644 --- a/apps/desktop/src/renderer/components/StartMeetingModal.tsx +++ b/apps/desktop/src/renderer/components/StartMeetingModal.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { X, Calendar, Loader2, Play, Users, Repeat, Radio } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ErrorBanner } from '@/components/ui/error-banner'; import { getElectronAPI } from '@/lib/ipc'; import { acceptedCount, @@ -118,16 +119,13 @@ export function StartMeetingModal({ isOpen, onClose, onStarted }: Props) { {error && ( -
- {error} - -
+ void load()} + onDismiss={() => { + setError(null); + }} + /> )} {isLoading && ( diff --git a/apps/desktop/src/renderer/components/auth/LoginForm.tsx b/apps/desktop/src/renderer/components/auth/LoginForm.tsx index eb0235be..0caee246 100644 --- a/apps/desktop/src/renderer/components/auth/LoginForm.tsx +++ b/apps/desktop/src/renderer/components/auth/LoginForm.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import { Mail, Lock, Eye, EyeOff, Loader2, AlertCircle, ExternalLink } from 'lucide-react'; +import { Mail, Lock, Eye, EyeOff, Loader2, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ErrorBanner } from '@/components/ui/error-banner'; import { useAuthStore } from '@/stores/auth'; import { loginSchema } from '@/lib/validations'; import { getElectronAPI } from '@/lib/ipc'; @@ -109,10 +109,12 @@ export function LoginForm() {
void handleSubmit(e)} className="space-y-4"> {error && ( - - - {error} - + { + setError(''); + }} + /> )}
diff --git a/apps/desktop/src/renderer/components/chat/ChatPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatPanel.tsx index ba539684..e2a50602 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPanel.tsx @@ -5,12 +5,11 @@ import { ChevronLeft, Wifi, WifiOff, - AlertCircle, - RefreshCw, Send, Loader2, ChevronUp, } from 'lucide-react'; +import { ErrorBanner } from '@/components/ui/error-banner'; import { ChatMessage } from './ChatMessage'; import { ParticipantList } from './ParticipantList'; import { useChat } from './useChat'; @@ -52,11 +51,20 @@ export function ChatPanel({ const [internalCollapsed, setInternalCollapsed] = useState(false); const isCollapsed = controlledCollapsed ?? internalCollapsed; - const { messages, isConnected, isLoading, error, hasMore, sendMessage, loadMore, reconnect } = - useChat({ - sessionId, - participantId, - }); + const { + messages, + isConnected, + isLoading, + error, + hasMore, + sendMessage, + loadMore, + reconnect, + clearError, + } = useChat({ + sessionId, + participantId, + }); const { participants: liveParticipants, isLoading: participantsLoading } = useParticipants({ sessionId, @@ -172,17 +180,13 @@ export function ChatPanel({ {/* Error banner */} {error && ( -
- - {error} - -
+ )} {/* Participant list */} @@ -244,9 +248,13 @@ export function ChatPanel({ {/* Message input */}
{sendError && ( -
- {sendError} -
+ { + setSendError(null); + }} + className="mb-2 px-3 py-2" + /> )}
diff --git a/apps/desktop/src/renderer/components/chat/useChat.ts b/apps/desktop/src/renderer/components/chat/useChat.ts index b7c3af94..cfd192ad 100644 --- a/apps/desktop/src/renderer/components/chat/useChat.ts +++ b/apps/desktop/src/renderer/components/chat/useChat.ts @@ -20,6 +20,7 @@ interface UseChatReturn extends ChatState { sendMessage: (content: string) => Promise; loadMore: () => Promise; reconnect: () => void; + clearError: () => void; } // Poll interval for new messages (in ms) @@ -198,6 +199,10 @@ export function useChat({ sessionId, autoConnect = true }: UseChatOptions): UseC }; }, [loadHistory, startPolling, autoConnect]); + const clearError = useCallback(() => { + setError(null); + }, []); + return { messages, isConnected, @@ -207,5 +212,6 @@ export function useChat({ sessionId, autoConnect = true }: UseChatOptions): UseC sendMessage, loadMore, reconnect, + clearError, }; } diff --git a/apps/desktop/src/renderer/components/ui/error-banner.test.tsx b/apps/desktop/src/renderer/components/ui/error-banner.test.tsx new file mode 100644 index 00000000..7b407f12 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/error-banner.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorBanner } from './error-banner'; + +describe('ErrorBanner', () => { + it('shows the message verbatim, including a raw fetch error', () => { + const raw = 'fetch failed (Connection timed out | UND_ERR_CONNECT_TIMEOUT | pairux.com:443)'; + render(); + + expect(screen.getByRole('alert')).toHaveTextContent(raw); + }); + + it('renders no affordances when neither handler is given', () => { + render(); + + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('calls onDismiss when the dismiss button is clicked', () => { + const onDismiss = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss error' })); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('offers retry and dismiss independently', () => { + const onRetry = vi.fn(); + const onDismiss = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss error' })); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('uses a custom retry label when given', () => { + render(); + + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/renderer/components/ui/error-banner.tsx b/apps/desktop/src/renderer/components/ui/error-banner.tsx new file mode 100644 index 00000000..b3d92459 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/error-banner.tsx @@ -0,0 +1,63 @@ +import { AlertCircle, RefreshCw, X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +interface ErrorBannerProps { + /** The error text to show. Rendered verbatim, so it may be a raw fetch error. */ + message: string; + /** Retry affordance. Omit when there is nothing sensible to re-run. */ + onRetry?: () => void; + retryLabel?: string; + /** Dismiss affordance. Omit only when the banner must stay until resolved. */ + onDismiss?: () => void; + className?: string; +} + +/** + * A destructive banner for an error the user can act on. Network failures are often + * transient — a dropped connection, a connect timeout — so the banner always leaves a + * way out: retry the call, or dismiss the message and carry on. + */ +export function ErrorBanner({ + message, + onRetry, + retryLabel = 'Try again', + onDismiss, + className, +}: ErrorBannerProps) { + return ( +
+ + {message} + + {onRetry && ( + + )} + + {onDismiss && ( + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/home.tsx b/apps/desktop/src/renderer/routes/home.tsx index 3b3f3a7c..98d71f99 100644 --- a/apps/desktop/src/renderer/routes/home.tsx +++ b/apps/desktop/src/renderer/routes/home.tsx @@ -8,6 +8,7 @@ import { shouldShowInAppSourcePicker, } from '@/lib/capturePicker'; import { CapturePreview } from '@/components/capture/CapturePreview'; +import { ErrorBanner } from '@/components/ui/error-banner'; import { CreateLinkModal } from '@/components/CreateLinkModal'; import { StartMeetingModal } from '@/components/StartMeetingModal'; import { getElectronAPI, isElectron } from '@/lib/ipc'; @@ -423,17 +424,13 @@ export function HomePage() { return (
{error && ( -
- {error} - -
+ { + setError(null); + }} + className="mb-4" + /> )} {!stream && !sessionActive ? ( diff --git a/apps/desktop/src/renderer/routes/join.tsx b/apps/desktop/src/renderer/routes/join.tsx index 1f199350..78062f99 100644 --- a/apps/desktop/src/renderer/routes/join.tsx +++ b/apps/desktop/src/renderer/routes/join.tsx @@ -1,15 +1,6 @@ import { useEffect, useState, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { - Monitor, - User, - Loader2, - AlertCircle, - Users, - ArrowLeft, - Calendar, - Clock, -} from 'lucide-react'; +import { Monitor, User, Loader2, Users, ArrowLeft, Calendar, Clock } from 'lucide-react'; /** * Parse a join code from user input. Handles both raw codes and full URLs. @@ -138,7 +129,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ErrorBanner } from '@/components/ui/error-banner'; import { useAuthStore } from '@/stores/auth'; import { getElectronAPI } from '@/lib/ipc'; @@ -474,10 +465,13 @@ export function JoinPage() { {error && ( - - - {error} - + { + setError(''); + }} + className="mb-4" + /> )} {!session ? ( diff --git a/apps/desktop/src/renderer/routes/settings.tsx b/apps/desktop/src/renderer/routes/settings.tsx index 74ce92f6..20ea0bf9 100644 --- a/apps/desktop/src/renderer/routes/settings.tsx +++ b/apps/desktop/src/renderer/routes/settings.tsx @@ -10,9 +10,9 @@ import { Cloud, Loader2, Check, - AlertCircle, } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ErrorBanner } from '@/components/ui/error-banner'; import { StreamDestinations } from '@/components/streaming'; import { LIVE_STREAM_CHANGED_EVENT } from '@/lib/liveStream'; import { SESSION_SOUNDS_CHANGED_EVENT } from '@/lib/sessionSounds'; @@ -249,18 +249,13 @@ export function SettingsPage() { {/* Error message */} {saveError && ( -
- - {saveError} - -
+ { + setSaveError(null); + }} + className="mb-6 max-w-3xl p-3" + /> )}