feat: add loading indicator when switching sessions
Show a spinner with 'Loading messages…' text while chat history is being fetched during session switches, instead of briefly flashing the empty welcome screen. Includes EN/FR i18n.
This commit is contained in:
@@ -10,7 +10,7 @@ import { KeyboardShortcuts } from './components/KeyboardShortcuts';
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
status, messages, sessions, activeSession, isGenerating,
|
status, messages, sessions, activeSession, isGenerating, isLoadingHistory,
|
||||||
sendMessage, abort, switchSession,
|
sendMessage, abort, switchSession,
|
||||||
authenticated, login, logout, connectError, isConnecting,
|
authenticated, login, logout, connectError, isConnecting,
|
||||||
} = useGateway();
|
} = useGateway();
|
||||||
@@ -77,7 +77,7 @@ export default function App() {
|
|||||||
<div className="flex-1 flex flex-col min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
<div className="flex-1 flex flex-col min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
||||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} />
|
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} />
|
||||||
<ConnectionBanner status={status} />
|
<ConnectionBanner status={status} />
|
||||||
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
|
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} onSend={sendMessage} onAbort={abort} />
|
||||||
</div>
|
</div>
|
||||||
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { ChatMessageComponent } from './ChatMessage';
|
|||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { TypingIndicator } from './TypingIndicator';
|
import { TypingIndicator } from './TypingIndicator';
|
||||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||||
import { Bot, ArrowDown } from 'lucide-react';
|
import { Bot, ArrowDown, Loader2 } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
import { getLocale, type TranslationKey } from '../lib/i18n';
|
import { getLocale, type TranslationKey } from '../lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
|
isLoadingHistory: boolean;
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
@@ -63,7 +64,7 @@ function getDateKey(ts: number): string {
|
|||||||
/** Threshold in pixels — if the user is within this distance of the bottom, auto-scroll */
|
/** Threshold in pixels — if the user is within this distance of the bottom, auto-scroll */
|
||||||
const SCROLL_THRESHOLD = 150;
|
const SCROLL_THRESHOLD = 150;
|
||||||
|
|
||||||
export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props) {
|
export function Chat({ messages, isGenerating, isLoadingHistory, status, onSend, onAbort }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -128,7 +129,13 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
|
|||||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
|
||||||
<div className="max-w-4xl mx-auto py-4">
|
<div className="max-w-4xl mx-auto py-4">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && isLoadingHistory && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500">
|
||||||
|
<Loader2 className="h-8 w-8 text-cyan-300/60 animate-spin mb-4" />
|
||||||
|
<div className="text-sm text-zinc-500">{t('chat.loadingHistory')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.length === 0 && !isLoadingHistory && (
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500">
|
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500">
|
||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<div className="absolute -inset-4 rounded-3xl bg-gradient-to-r from-cyan-400/10 via-indigo-500/10 to-violet-500/10 blur-2xl" />
|
<div className="absolute -inset-4 rounded-3xl bg-gradient-to-r from-cyan-400/10 via-indigo-500/10 to-violet-500/10 blur-2xl" />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function useGateway() {
|
|||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [activeSession, setActiveSession] = useState('agent:main:main');
|
const [activeSession, setActiveSession] = useState('agent:main:main');
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null); // null = checking
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null); // null = checking
|
||||||
const [connectError, setConnectError] = useState<string | null>(null);
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
@@ -100,6 +101,7 @@ export function useGateway() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadHistory = useCallback(async (sessionKey: string) => {
|
const loadHistory = useCallback(async (sessionKey: string) => {
|
||||||
|
setIsLoadingHistory(true);
|
||||||
try {
|
try {
|
||||||
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
|
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
|
||||||
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
|
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
|
||||||
@@ -173,6 +175,8 @@ export function useGateway() {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore history load failures
|
// Silently ignore history load failures
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHistory(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -388,7 +392,7 @@ export function useGateway() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status, messages, sessions: enrichedSessions, activeSession, isGenerating,
|
status, messages, sessions: enrichedSessions, activeSession, isGenerating, isLoadingHistory,
|
||||||
sendMessage, abort, switchSession, loadSessions,
|
sendMessage, abort, switchSession, loadSessions,
|
||||||
authenticated, login, logout, connectError, isConnecting,
|
authenticated, login, logout, connectError, isConnecting,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const en = {
|
|||||||
// Chat
|
// Chat
|
||||||
'chat.welcome': 'PinchChat',
|
'chat.welcome': 'PinchChat',
|
||||||
'chat.welcomeSub': 'Send a message to get started',
|
'chat.welcomeSub': 'Send a message to get started',
|
||||||
|
'chat.loadingHistory': 'Loading messages…',
|
||||||
'chat.inputPlaceholder': 'Type a message…',
|
'chat.inputPlaceholder': 'Type a message…',
|
||||||
'chat.inputLabel': 'Message',
|
'chat.inputLabel': 'Message',
|
||||||
'chat.attachFile': 'Attach file',
|
'chat.attachFile': 'Attach file',
|
||||||
@@ -106,6 +107,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'chat.welcome': 'PinchChat',
|
'chat.welcome': 'PinchChat',
|
||||||
'chat.welcomeSub': 'Envoyez un message pour commencer',
|
'chat.welcomeSub': 'Envoyez un message pour commencer',
|
||||||
|
'chat.loadingHistory': 'Chargement des messages…',
|
||||||
'chat.inputPlaceholder': 'Tapez un message…',
|
'chat.inputPlaceholder': 'Tapez un message…',
|
||||||
'chat.inputLabel': 'Message',
|
'chat.inputLabel': 'Message',
|
||||||
'chat.attachFile': 'Joindre un fichier',
|
'chat.attachFile': 'Joindre un fichier',
|
||||||
|
|||||||
Reference in New Issue
Block a user