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:
Nicolas Varrot
2026-02-12 05:52:06 +00:00
parent 4b923a1ec2
commit cb882f5ead
4 changed files with 19 additions and 6 deletions

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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,
}; };

View File

@@ -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',