fix: resolve ESLint errors for React compiler rules

- Chat.tsx: replace mutable variable in render IIFE with useMemo+reduce
  to satisfy react-hooks/immutability rule
- ConnectionBanner.tsx: move setState calls into a useCallback to avoid
  synchronous setState in effect body (react-hooks/set-state-in-effect)
- useGateway.ts: suppress set-state-in-effect for legitimate mount init
This commit is contained in:
Nicolas Varrot
2026-02-12 01:42:40 +00:00
parent 375bd102d4
commit 29482e377a
4 changed files with 44 additions and 19 deletions

View File

@@ -175,3 +175,21 @@
- GitHub rend nativement les blocs ```mermaid dans les README - GitHub rend nativement les blocs ```mermaid dans les README
- Utiliser un flowchart ou graph LR/TD montrant : Browser → WebSocket → OpenClaw Gateway → LLM Provider, avec les composants internes (LoginScreen, Chat, Sidebar, Gateway client, etc.) - Utiliser un flowchart ou graph LR/TD montrant : Browser → WebSocket → OpenClaw Gateway → LLM Provider, avec les composants internes (LoginScreen, Chat, Sidebar, Gateway client, etc.)
- Plus lisible et maintenable que l'ASCII art - Plus lisible et maintenable que l'ASCII art
## Item #22
- **Date:** 2026-02-12
- **Priority:** high
- **Status:** pending
- **Description:** CI GitHub Actions en échec — vérifier et réparer en priorité. Le cron doit aussi vérifier l'état de la CI en début de chaque session avant toute autre amélioration. Si la CI est cassée, c'est la priorité #1.
## Item #23
- **Date:** 2026-02-12
- **Priority:** medium
- **Status:** pending
- **Description:** Icônes par channel/type dans la liste des sessions (sidebar)
- Discord → icône Discord
- Telegram → icône Telegram
- Cron → icône horloge ou engrenage
- Webchat → icône chat/bulle
- Fallback générique pour les channels non-vanilla (ex: TeamSpeak) → icône par défaut (bulle ou globe)
- Utiliser des SVG ou une lib d'icônes (lucide-react, react-icons, etc.)

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback, useState } from 'react'; import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { ChatMessageComponent } from './ChatMessage'; import { ChatMessageComponent } from './ChatMessage';
import { ChatInput } from './ChatInput'; import { ChatInput } from './ChatInput';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
@@ -112,6 +112,16 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
onSend(text, attachments); onSend(text, attachments);
}, [onSend]); }, [onSend]);
const visibleMessages = useMemo(() => {
const filtered = messages.filter(hasVisibleContent);
return filtered.reduce<Array<{ msg: ChatMessage; showSep: boolean }>>((acc, msg) => {
const dk = getDateKey(msg.timestamp);
const prevDk = acc.length > 0 ? getDateKey(acc[acc.length - 1].msg.timestamp) : '';
acc.push({ msg, showSep: dk !== prevDk });
return acc;
}, []);
}, [messages]);
const showTyping = isGenerating && !hasStreamedText(messages); const showTyping = isGenerating && !hasStreamedText(messages);
return ( return (
@@ -130,13 +140,7 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
<div className="text-sm mt-1 text-zinc-500">{t('chat.welcomeSub')}</div> <div className="text-sm mt-1 text-zinc-500">{t('chat.welcomeSub')}</div>
</div> </div>
)} )}
{(() => { {visibleMessages.map(({ msg, showSep }) => (
let lastDateKey = '';
return messages.filter(hasVisibleContent).map(msg => {
const dk = getDateKey(msg.timestamp);
const showSep = dk !== lastDateKey;
lastDateKey = dk;
return (
<div key={msg.id}> <div key={msg.id}>
{showSep && ( {showSep && (
<div className="flex items-center gap-3 py-3 px-4 select-none" aria-label={formatDateSeparator(msg.timestamp, t)}> <div className="flex items-center gap-3 py-3 px-4 select-none" aria-label={formatDateSeparator(msg.timestamp, t)}>
@@ -147,9 +151,7 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
)} )}
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} /> <ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} />
</div> </div>
); ))}
});
})()}
{showTyping && <TypingIndicator />} {showTyping && <TypingIndicator />}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { Wifi, Loader2 } from 'lucide-react'; import { Wifi, Loader2 } from 'lucide-react';
import type { ConnectionStatus } from '../types'; import type { ConnectionStatus } from '../types';
import { useT } from '../hooks/useLocale'; import { useT } from '../hooks/useLocale';
@@ -15,28 +15,31 @@ export function ConnectionBanner({ status }: Props) {
const prevStatus = useRef<ConnectionStatus | null>(null); const prevStatus = useRef<ConnectionStatus | null>(null);
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { const updateBanner = useCallback((prev: ConnectionStatus | null, current: ConnectionStatus) => {
const prev = prevStatus.current;
prevStatus.current = status;
if (dismissTimer.current) { if (dismissTimer.current) {
clearTimeout(dismissTimer.current); clearTimeout(dismissTimer.current);
dismissTimer.current = null; dismissTimer.current = null;
} }
if (status === 'disconnected' || status === 'connecting') { if (current === 'disconnected' || current === 'connecting') {
if (prev === 'connected') { if (prev === 'connected') {
setBanner('reconnecting'); setBanner('reconnecting');
} }
} else if (status === 'connected' && prev !== null && prev !== 'connected') { } else if (current === 'connected' && prev !== null && prev !== 'connected') {
setBanner('reconnected'); setBanner('reconnected');
dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000); dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000);
} }
}, []);
useEffect(() => {
const prev = prevStatus.current;
prevStatus.current = status;
updateBanner(prev, status);
return () => { return () => {
if (dismissTimer.current) clearTimeout(dismissTimer.current); if (dismissTimer.current) clearTimeout(dismissTimer.current);
}; };
}, [status]); }, [status, updateBanner]);
if (banner === 'hidden') return null; if (banner === 'hidden') return null;

View File

@@ -307,6 +307,8 @@ export function useGateway() {
initRef.current = true; initRef.current = true;
const stored = getStoredCredentials(); const stored = getStoredCredentials();
if (stored) { if (stored) {
// Init on mount — setupClient sets state as part of establishing the connection
// eslint-disable-next-line react-hooks/set-state-in-effect
setupClient(stored.url, stored.token); setupClient(stored.url, stored.token);
} else { } else {
setAuthenticated(false); setAuthenticated(false);