Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions apps/desktop/src/renderer/components/CreateLinkModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,16 +106,13 @@ export function CreateLinkModal({ isOpen, onClose, onStartSharing }: CreateLinkM
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
<Button
variant="link"
className="ml-2 h-auto p-0 text-destructive"
onClick={() => void createSession()}
>
Try again
</Button>
</div>
<ErrorBanner
message={error}
onRetry={() => void createSession()}
onDismiss={() => {
setError(null);
}}
/>
)}

{isCreating && (
Expand Down
18 changes: 8 additions & 10 deletions apps/desktop/src/renderer/components/StartMeetingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -118,16 +119,13 @@ export function StartMeetingModal({ isOpen, onClose, onStarted }: Props) {

<CardContent className="flex-1 space-y-3 overflow-y-auto">
{error && (
<div className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
<Button
variant="link"
className="ml-2 h-auto p-0 text-destructive"
onClick={() => void load()}
>
Try again
</Button>
</div>
<ErrorBanner
message={error}
onRetry={() => void load()}
onDismiss={() => {
setError(null);
}}
/>
)}

{isLoading && (
Expand Down
14 changes: 8 additions & 6 deletions apps/desktop/src/renderer/components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -109,10 +109,12 @@ export function LoginForm() {
<CardContent>
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<ErrorBanner
message={error}
onDismiss={() => {
setError('');
}}
/>
)}

<div className="space-y-2">
Expand Down
50 changes: 29 additions & 21 deletions apps/desktop/src/renderer/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -172,17 +180,13 @@ export function ChatPanel({

{/* Error banner */}
{error && (
<div className="flex items-center gap-2 border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button
onClick={reconnect}
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-destructive/20"
>
<RefreshCw className="h-3 w-3" />
Retry
</button>
</div>
<ErrorBanner
message={error}
onRetry={reconnect}
retryLabel="Retry"
onDismiss={clearError}
className="rounded-none border-b border-destructive/20 px-4 py-2"
/>
)}

{/* Participant list */}
Expand Down Expand Up @@ -244,9 +248,13 @@ export function ChatPanel({
{/* Message input */}
<div className="border-t border-border p-3">
{sendError && (
<div className="mb-2 rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
{sendError}
</div>
<ErrorBanner
message={sendError}
onDismiss={() => {
setSendError(null);
}}
className="mb-2 px-3 py-2"
/>
)}

<div className="flex items-end gap-2">
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/components/chat/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface UseChatReturn extends ChatState {
sendMessage: (content: string) => Promise<void>;
loadMore: () => Promise<void>;
reconnect: () => void;
clearError: () => void;
}

// Poll interval for new messages (in ms)
Expand Down Expand Up @@ -198,6 +199,10 @@ export function useChat({ sessionId, autoConnect = true }: UseChatOptions): UseC
};
}, [loadHistory, startPolling, autoConnect]);

const clearError = useCallback(() => {
setError(null);
}, []);

return {
messages,
isConnected,
Expand All @@ -207,5 +212,6 @@ export function useChat({ sessionId, autoConnect = true }: UseChatOptions): UseC
sendMessage,
loadMore,
reconnect,
clearError,
};
}
49 changes: 49 additions & 0 deletions apps/desktop/src/renderer/components/ui/error-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ErrorBanner message={raw} />);

expect(screen.getByRole('alert')).toHaveTextContent(raw);
});

it('renders no affordances when neither handler is given', () => {
render(<ErrorBanner message="Something went wrong" />);

expect(screen.queryByRole('button')).toBeNull();
});

it('calls onDismiss when the dismiss button is clicked', () => {
const onDismiss = vi.fn();
render(<ErrorBanner message="Could not reach pairux.com" onDismiss={onDismiss} />);

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(
<ErrorBanner message="Could not reach pairux.com" onRetry={onRetry} onDismiss={onDismiss} />
);

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(<ErrorBanner message="Disconnected" onRetry={vi.fn()} retryLabel="Retry" />);

expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});
63 changes: 63 additions & 0 deletions apps/desktop/src/renderer/components/ui/error-banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
className={cn(
'flex items-start gap-2 rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive',
className
)}
>
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span className="min-w-0 flex-1 break-words">{message}</span>

{onRetry && (
<button
type="button"
onClick={onRetry}
className="flex flex-shrink-0 items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-destructive/20"
>
<RefreshCw className="h-3 w-3" />
{retryLabel}
</button>
)}

{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss error"
title="Dismiss"
className="flex-shrink-0 rounded p-1 hover:bg-destructive/20"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
19 changes: 8 additions & 11 deletions apps/desktop/src/renderer/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -423,17 +424,13 @@ export function HomePage() {
return (
<div className="flex flex-1 flex-col p-6">
{error && (
<div className="mb-4 rounded-lg bg-destructive/10 p-4 text-destructive">
{error}
<button
onClick={() => {
setError(null);
}}
className="ml-4 text-sm underline"
>
Dismiss
</button>
</div>
<ErrorBanner
message={error}
onDismiss={() => {
setError(null);
}}
className="mb-4"
/>
)}

{!stream && !sessionActive ? (
Expand Down
24 changes: 9 additions & 15 deletions apps/desktop/src/renderer/routes/join.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -474,10 +465,13 @@ export function JoinPage() {
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<ErrorBanner
message={error}
onDismiss={() => {
setError('');
}}
className="mb-4"
/>
)}

{!session ? (
Expand Down
Loading
Loading